Compare commits

..

1 Commits

Author SHA1 Message Date
Farhaan Bukhsh
b06315373b feat: Add max value warning and disable the submit button
Co-authored-by: lkatsikaris <lkatsikaris@@users.noreply.github.com>

Signed-off-by: Farhaan Bukhsh <farhaan@opencraft.com>
2025-01-12 19:48:42 +05:30
128 changed files with 9026 additions and 12662 deletions

2
.env
View File

@@ -34,5 +34,3 @@ ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=''
APP_ID='' APP_ID=''
MFE_CONFIG_API_URL='' MFE_CONFIG_API_URL=''
DISPLAY_FEEDBACK_WIDGET='true' DISPLAY_FEEDBACK_WIDGET='true'
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -40,5 +40,3 @@ ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
APP_ID='' APP_ID=''
MFE_CONFIG_API_URL='' MFE_CONFIG_API_URL=''
DISPLAY_FEEDBACK_WIDGET='false' DISPLAY_FEEDBACK_WIDGET='false'
# Fallback in local style files
PARAGON_THEME_URLS={}

6
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,6 @@
# Code owners for frontend-app-gradebook, editable gradebook micro-frontend (MFE)
# These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence, they will
# be requested for review when someone opens a pull request.
* @openedx/content-aurora

View File

@@ -47,7 +47,7 @@ jobs:
- name: Send failure notification - name: Send failure notification
if: ${{ failure() }} if: ${{ failure() }}
uses: dawidd6/action-send-mail@v6 uses: dawidd6/action-send-mail@v4
with: with:
server_address: email-smtp.us-east-1.amazonaws.com server_address: email-smtp.us-east-1.amazonaws.com
server_port: 465 server_port: 465

2
.nvmrc
View File

@@ -1 +1 @@
24 20

197
README.md Executable file
View File

@@ -0,0 +1,197 @@
# frontend-app-gradebook
[![Build Status](https://api.travis-ci.com/edx/frontend-app-gradebook.svg?branch=master)](https://travis-ci.com/edx/frontend-app-gradebook)
[![Codecov](https://img.shields.io/codecov/c/gh/openedx/frontend-app-gradebook)](https://app.codecov.io/gh/openedx/frontend-app-gradebook)
[![npm_version](https://img.shields.io/npm/v/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook)
[![npm_downloads](https://img.shields.io/npm/dt/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook)
[![license](https://img.shields.io/npm/l/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook)
[![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)
# 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.
Jump to:
- [Should I use Gradebook in my course?](#should-i-use-gradebook-in-my-course)
- [Quickstart](#quickstart)
For existing documentation see:
- Basic Usage: [Review Learner Grades (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#review-learner-grades-on-the-instructor-dashboard)
- Bulk Grade Management: [Override Learner Subsection Scores in Bulk (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#override-learner-subsection-scores-in-bulk)
## Should I use Gradebook in my course?
### What does this offer over the legacy gradebook?
The micro-frontend offers a great deal more granularity when searching for problems, an easy interface for editing grades, an
audit trail for seeing who edited what grade and what reason they gave (if any) for doing so.
UsageProblems can be filtered by student as in the traditional gradebook, but can also be filtered by scores to see who
scored within a certain range, and by assignment types (note: Not problem types, but categories like Exams or
Homework).
### What does the legacy gradebook offer that this project does not?
This project does not (yet, at least) create any graphs, which the traditional gradebook does. It also does not give
quick links to the problems for the instructor to visit. It expects the instructor to be familiar with the problems they
are grading and which unit they refer to.
The gradebook is expected to be much more performant for larger numbers of students as well. The Instructor Dashboard
link for the legacy gradebook reports that "this feature is available only to courses with a small number of enrolled
learners." However, this project comes with no such warning.
### Who should not change to this gradebook?
Groups whose instructors need not ever manually override grades do not need this project, but may not be any worse off
depending on their needs. Instructors that expect to review grades infrequently enough that not having a direct link
to the problem in question will have a worse UX than the legacy gradebook provides. Instructors that rely on the graphs
generated by the current gradebook might find the lack of autogenerated graphs to be frustrating.
## Getting Started
### Installation
To install gradebook into your project:
```
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
To install the project please refer to the [`edX Developer Stack`](https://github.com/openedx/devstack) instructions.
The web application runs on port **1994**, so when you go to `http://localhost:1994/course-v1:edX+DemoX+Demo_Course` you should see the UI (assuming you have such a Demo Course in your devstack). Note that you always have to provide a course id to actually see a gradebook.
If you don't, you can see the log messages for the docker container by executing `make gradebook-logs` in the `devstack` directory.
Note that starting the container executes the `npm run start` script which will hot-reload JavaScript and Sass files changes, so you should (:crossed_fingers:) not need to do anything (other than wait) when making changes.
## Configuring for local use in edx-platform
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
to point to your local gradebook from the instructor dashboard by putting this setting in `lms/env/private.py`:
```
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
```
There are also several edx-platform waffle and feature flags you'll have to enable from the Django admin:
1. Grades > Persistent grades enabled flag. Add this flag if it doesn't exist,
check the ``enabled`` and ``enabled for all courses`` boxes.
2. Waffle > Switches. Add the ``grades.assume_zero_grade_if_absent`` switch and make it active.
3. Waffle_utils > Waffle flag course overrides. Activate waffle flags for courses where you want to enable Gradebook functionality:
- Enable Gradebook by adding the ``grades.writable_gradebook`` add checking the ``enabled`` box.
- Enable Bulk Grade Management by adding the ``grades.bulk_management`` flag and checking the ``enabled`` box.
Alternatively, you could add these as regular waffle flags to enable the functionality for all courses.
**NOTE:** IF the above flags are not configured correctly, the gradebook may appear to work, but will return bogus
numbers for grades. If your gradebook isn't accepting your changes, or the changes aren't resulting in sane,
recalculated grade values, verify you've set all flags correctly.
## 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
1. Assuming that you're operating in the context of the edX devstack,
run `gradebook-shell` from your devstack directory. This will start a bash shell inside your
running gradebook container.
2. Run `make test` (which executes `npm run test`). This will run all of the gradebook tests.
## Directory Structure
* `config`
* Directory for [`webpack`](https://webpack.js.org/) configurations
* `public`
* Entry point for the single-page application - `gradebook` has a single `index.html` file
* `src`
* `components`
* Directory for presentational `React` components
* `containers`
* Directory for container `React` components
* `data`
* `actions`
* Directory for `Redux` action creators
* `constants`
* `reducers`
* Directory for `Redux` reducers
## 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.
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,253 +0,0 @@
frontend-app-gradebook
#######################
|Build Status| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
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.
Jump to:
- `Should I use Gradebook in my course?`_
- Quickstart_
For existing documentation see:
- Basic Usage: `Review Learner Grades (read-the-docs) <https://docs.openedx.org/en/latest/educators/how-tos/data/view_learner_grades.html>`_
- Bulk Grade Management: `Override Learner Subsection Scores in Bulk (read-the-docs) <https://docs.openedx.org/en/latest/educators/how-tos/data/manage_learner_grades.html#override-learner-subsection-scores-in-bulk>`_
Should I use Gradebook in my course?
*************************************
What does this offer over the legacy gradebook?
================================================
The micro-frontend offers a great deal more granularity when searching for problems, an easy interface for editing grades, an
audit trail for seeing who edited what grade and what reason they gave (if any) for doing so.
UsageProblems can be filtered by student as in the traditional gradebook, but can also be filtered by scores to see who
scored within a certain range, and by assignment types (note: Not problem types, but categories like 'Exams' or
'Homework').
What does the legacy gradebook offer that this project does not?
=================================================================
This project does not (yet, at least) create any graphs, which the traditional gradebook does. It also does not give
quick links to the problems for the instructor to visit. It expects the instructor to be familiar with the problems they
are grading and which unit they refer to.
The gradebook is expected to be much more performant for larger numbers of students as well. The Instructor Dashboard
link for the legacy gradebook reports that "this feature is available only to courses with a small number of enrolled
learners." However, this project comes with no such warning.
Who should not change to this gradebook?
=========================================
Groups whose instructors need not ever manually override grades do not need this project, but may not be any worse off
depending on their needs. Instructors that expect to review grades infrequently enough that not having a direct link
to the problem in question will have a worse UX than the legacy gradebook provides. Instructors that rely on the graphs
generated by the current gradebook might find the lack of autogenerated graphs to be frustrating.
Getting Started
***************
Installation
============
To install gradebook into your project:
.. code-block:: bash
npm i --save @edx/frontend-app-gradebook
Quickstart
==========
Cloning and Setup
-----------------
1. Clone your new repo:
.. code-block:: bash
git clone https://github.com/openedx/frontend-app-gradebook.git
2. Use the version of Node specified in ``.nvmrc``
3. Stop the Tutor devstack, if it's running:
.. code-block:: bash
tutor dev stop
4. Next, we need to tell Tutor that we're going to be running this repo in development mode, and it should be excluded from the mfe container that otherwise runs every MFE. Run this:
.. code-block:: bash
tutor mounts add /path/to/frontend-app-gradebook
5. Start Tutor in development mode. This command will start the LMS and Studio,
and other required MFEs like ``authn`` and ``account``, but will not start the
Gradebook MFE, which we're going to run on the host instead of in a container
managed by Tutor. Run:
.. code-block:: bash
tutor dev start lms cms mfe
Startup
-------
1. Install npm dependencies:
.. code-block:: bash
cd frontend-app-gradebook && npm install
2. Start the dev server:
.. code-block:: bash
npm run dev
Running the UI Standalone
==========================
To install the project please refer to the `MFE Development on Tutor <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`_ instructions.
When not mounted, gradebook will run in the shared MFE container at http://apps.local.openedx.io/gradebook/course-v1:edX+DemoX+Demo_Course.
When mounted in the tutor ``gradebook`` container, or when running a local (host) webpack dev server, the web application runs on port **1994**, so when you go to http://apps.local.openedx.io:1994/gradebook/course-v1:edX+DemoX+Demo_Course you should see the UI (assuming you have such a Demo Course in your devstack). Note that you always have to provide a course id to actually see a gradebook.
(Note: This may not work in Tutor; these instructions are for the deprecated Devstack) You can see the log messages for the docker container by executing ``make gradebook-logs`` in the ``devstack`` directory.
Note that starting the container executes the ``npm run start`` script which will hot-reload JavaScript and Sass files changes, so you should (:crossed_fingers:) not need to do anything (other than wait) when making changes.
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
*************
Run:
.. code-block:: bash
nvm use
npm ci
npm test
Directory Structure
*******************
* ``config``
* Directory for `webpack <https://webpack.js.org/>`_ configurations
* ``public``
* Entry point for the single-page application - ``gradebook`` has a single ``index.html`` file
* ``src``
* ``components``
* Directory for presentational ``React`` components
* ``containers``
* Directory for container ``React`` components
* ``data``
* ``actions``
* Directory for ``Redux`` action creators
* ``constants``
* ``reducers``
* Directory for ``Redux`` reducers
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.
License
*******
The code in this repository is licensed under the AGPLv3 unless otherwise
noted.
Please see `LICENSE <LICENSE>`_ for details.
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.
.. |Build Status| image:: https://api.travis-ci.com/edx/frontend-app-gradebook.svg?branch=master
:target: https://travis-ci.com/edx/frontend-app-gradebook
.. |Codecov| image:: https://img.shields.io/codecov/c/gh/openedx/frontend-app-gradebook
:target: https://app.codecov.io/gh/openedx/frontend-app-gradebook
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-gradebook.svg
:target: @edx/frontend-app-gradebook
.. |npm_downloads| image:: https://img.shields.io/npm/dt/@edx/frontend-app-gradebook.svg
:target: @edx/frontend-app-gradebook
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-gradebook.svg
:target: @edx/frontend-app-gradebook
.. |semantic-release| image:: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg
:target: https://github.com/semantic-release/semantic-release

View File

@@ -7,7 +7,6 @@ metadata:
description: "The frontend (MFE) for Open edX Gradebook" description: "The frontend (MFE) for Open edX Gradebook"
annotations: annotations:
openedx.org/arch-interest-groups: "" openedx.org/arch-interest-groups: ""
openedx.org/release: "master"
spec: spec:
owner: user:farhaanbukhsh owner: user:farhaanbukhsh
type: 'website' type: 'website'

View File

@@ -4,15 +4,15 @@ Instructions for setting up environments and data for testing Gradebook.
## Set up a course with graded content ## Set up a course with graded content
A course with graded content is the first prerequisite to testing. Use an existing course (e.g. the DemoX Demonstration Course in Devstack) or see [Building and Running an edX Course > Developing Your Course](https://docs.openedx.org/en/latest/educators/quickstarts/build_a_course.html) for notes on how to develop a course from scratch. A course with graded content is the first prerequisite to testing. Use an existing course (e.g. the DemoX Demonstration Course in Devstack) or see [Building and Running an edX Course > Developing Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/index.html) for notes on how to develop a course from scratch.
Notably, the course needs a grading policy and subsections with scoreable content. Notably, the course needs a grading policy and subsections with scoreable content.
After creating subsections with content, they need to be configured with an "Assignment Type" to be included in grading. After creating subsections with content, they need to be configured with an "Assignment Type" to be included in grading.
Suggested resources: Suggested resources:
- [Establishing a Grading Policy For Your Course](https://docs.openedx.org/en/latest/educators/how-tos/data/manage_learner_grades.html#review-how-grading-is-configured-for-your-course) - [Establishing a Grading Policy For Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
- [Adding Exercises and Tools](https://docs.openedx.org/en/latest/educators/concepts/exercise_tools/about_problems_exercises_tools.html) - [Adding Exercises and Tools](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
- [Set the Assignment Type and Due Date for a Subsection](https://docs.openedx.org/en/latest/educators/how-tos/course_development/set_subsection_problem_date.html#set-the-assignment-type-and-due-date-for-a-subsection) - [Set the Assignment Type and Due Date for a Subsection](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/course_subsections.html#set-the-assignment-type-and-due-date-for-a-subsection)
## Enable Gradebook for course ## Enable Gradebook for course
@@ -35,7 +35,7 @@ Bulk Management is an added feature to allow modifying grades in bulk via CSV up
## Create a Master's track for testing Master's-only features ## Create a Master's track for testing Master's-only features
[source - note: possibly outdated, edx.org-specific](https://openedx.atlassian.net/wiki/spaces/MS/pages/1453818012/Add+a+learner+into+a+master+s+track) [source](https://openedx.atlassian.net/wiki/spaces/MS/pages/1453818012/Add+a+learner+into+a+master+s+track)
Add a Master's track in your course: Add a Master's track in your course:
- As an admin user, go to Django Admin (`{lms-url}/admin`) > Course Modes and add a new course mode - As an admin user, go to Django Admin (`{lms-url}/admin`) > Course Modes and add a new course mode

View File

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

9
openedx.yaml Normal file
View File

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

14670
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@edx/frontend-app-gradebook", "name": "@edx/frontend-app-gradebook",
"version": "1.6.3", "version": "1.6.2",
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections", "description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -15,7 +15,6 @@
"prepush": "npm run lint", "prepush": "npm run lint",
"semantic-release": "semantic-release", "semantic-release": "semantic-release",
"start": "fedx-scripts webpack-dev-server --progress", "start": "fedx-scripts webpack-dev-server --progress",
"dev": "PUBLIC_PATH=/gradebook/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests", "test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
"watch-tests": "jest --watch" "watch-tests": "jest --watch"
}, },
@@ -30,16 +29,18 @@
], ],
"dependencies": { "dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0", "@edx/frontend-component-header": "^5.6.0",
"@edx/frontend-component-header": "^6.6.1", "@edx/frontend-platform": "8.0.0",
"@edx/frontend-platform": "^8.3.7",
"@edx/openedx-atlas": "^0.6.0", "@edx/openedx-atlas": "^0.6.0",
"@edx/react-unit-test-utils": "^3.0.0",
"@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",
"@fortawesome/free-solid-svg-icons": "^5.11.2", "@fortawesome/free-solid-svg-icons": "^5.11.2",
"@fortawesome/react-fontawesome": "^0.1.5", "@fortawesome/react-fontawesome": "^0.1.5",
"@openedx/frontend-plugin-framework": "^1.6.0", "@openedx/frontend-plugin-framework": "^1.2.1",
"@openedx/paragon": "^23.4.5", "@openedx/frontend-slot-footer": "^1.0.2",
"@openedx/paragon": "^22.1.1",
"@redux-beacon/segment": "^1.0.0", "@redux-beacon/segment": "^1.0.0",
"@reduxjs/toolkit": "^1.5.1", "@reduxjs/toolkit": "^1.5.1",
"classnames": "^2.2.6", "classnames": "^2.2.6",
@@ -49,8 +50,8 @@
"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": "^18.3.1", "react": "17.0.2",
"react-dom": "^18.3.1", "react-dom": "17.0.2",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-redux": "^7.2.9", "react-redux": "^7.2.9",
"react-router": "6.15.0", "react-router": "6.15.0",
@@ -67,16 +68,16 @@
}, },
"devDependencies": { "devDependencies": {
"@edx/browserslist-config": "^1.1.1", "@edx/browserslist-config": "^1.1.1",
"@openedx/frontend-build": "^14.6.2", "@openedx/frontend-build": "14.0.3",
"@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "12.1.5",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"es-check": "^2.3.0", "es-check": "^2.3.0",
"fetch-mock": "^12.2.0", "fetch-mock": "^12.2.0",
"husky": "2.7.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"react-dev-utils": "^12.0.1", "react-dev-utils": "^12.0.1",
"react-test-renderer": "^18.3.1", "react-test-renderer": "17.0.2",
"reactifex": "1.1.1",
"redux-mock-store": "^1.5.3" "redux-mock-store": "^1.5.3"
} }
} }

View File

@@ -3,7 +3,7 @@ import { Route, Routes } from 'react-router-dom';
import { AppProvider } from '@edx/frontend-platform/react'; import { AppProvider } from '@edx/frontend-platform/react';
import { FooterSlot } 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 store from 'data/store'; import store from 'data/store';

View File

@@ -1,10 +1,13 @@
// frontend-app-*/src/index.scss // frontend-app-*/src/index.scss
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints.css" as paragonCustomMediaBreakpoints; @import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables";
@import "~@openedx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
$fa-font-path: "~font-awesome/fonts"; $fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome"; @import "~font-awesome/scss/font-awesome";
$input-focus-box-shadow: var(--pgn-elevation-form-input-base); // hack to get upgrade to paragon 4.0.0 to work $input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.0.0 to work
@import "~@edx/frontend-component-header/dist/index"; @import "~@edx/frontend-component-header/dist/index";
@import "~@edx/frontend-component-footer/dist/_footer"; @import "~@edx/frontend-component-footer/dist/_footer";

View File

@@ -1,63 +1,63 @@
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { shallow } from '@edx/react-unit-test-utils';
import { Route } from 'react-router-dom';
import store from 'data/store';
import GradebookPage from 'containers/GradebookPage';
import App from './App'; import App from './App';
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
Routes: ({ children }) => children, BrowserRouter: () => 'BrowserRouter',
Route: ({ element }) => element, Route: () => 'Route',
Routes: () => 'Routes',
})); }));
jest.mock('@edx/frontend-platform/react', () => ({ jest.mock('@edx/frontend-platform/react', () => ({
AppProvider: ({ children }) => children, AppProvider: () => 'AppProvider',
})); }));
jest.mock('@edx/frontend-component-footer', () => ({ FooterSlot: 'Footer' }));
jest.mock('data/store', () => 'testStore');
jest.mock('containers/GradebookPage', () => 'GradebookPage');
jest.mock('@edx/frontend-component-header', () => 'Header');
jest.mock('./head/Head', () => 'Head');
jest.mock('@edx/frontend-component-header', () => ({ let el;
__esModule: true, let secondChild;
default: () => <div>Header</div>,
}));
jest.mock('@edx/frontend-component-footer', () => ({ describe('App router component', () => {
FooterSlot: () => <div>Footer</div>, test('snapshot', () => {
})); expect(shallow(<App />).snapshot).toMatchSnapshot();
jest.mock('./head/Head', () => ({
__esModule: true,
default: () => <div>Head</div>,
}));
jest.mock('containers/GradebookPage', () => ({
__esModule: true,
default: () => <div>Gradebook</div>,
}));
describe('App', () => {
beforeEach(() => {
render(<App />);
}); });
describe('component', () => {
afterEach(() => { beforeEach(() => {
jest.clearAllMocks(); el = shallow(<App />);
}); secondChild = el.instance.children;
});
it('renders Head component', () => { describe('AppProvider', () => {
const head = screen.getByText('Head'); test('AppProvider is the parent component, passed the redux store props', () => {
expect(head).toBeInTheDocument(); expect(el.instance.type).toBe('AppProvider');
}); expect(el.instance.props.store).toEqual(store);
});
it('renders Header component', () => { });
const header = screen.getByText('Header'); describe('Head', () => {
expect(header).toBeInTheDocument(); test('first child of AppProvider', () => {
}); expect(el.instance.children[0].type).toBe('Head');
});
it('renders Footer component', () => { });
const footer = screen.getByText('Footer'); describe('Router', () => {
expect(footer).toBeInTheDocument(); test('second child of AppProvider', () => {
}); expect(secondChild[1].type).toBe('div');
});
it('renders main content wrapper', () => { test('Header is above/outside-of the routing', () => {
const main = screen.getByRole('main'); expect(secondChild[1].children[0].type).toBe('Header');
expect(main).toBeInTheDocument(); expect(secondChild[1].children[1].type).toBe('main');
const gradebook = screen.getByText('Gradebook'); });
expect(gradebook).toBeInTheDocument(); test('Routing - GradebookPage is only route', () => {
expect(secondChild[1].findByType(Route)).toHaveLength(1);
expect(secondChild[1].findByType(Route)[0].props.path).toEqual('/:courseId');
expect(secondChild[1].findByType(Route)[0].props.element.type).toEqual(GradebookPage);
});
});
}); });
}); });

View File

@@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`App router component snapshot 1`] = `
<AppProvider
store="testStore"
>
<Head />
<div>
<Header />
<main>
<Routes>
<Route
element={<GradebookPage />}
path="/:courseId"
/>
</Routes>
</main>
<FooterSlot />
</div>
</AppProvider>
`;

View File

@@ -1,10 +1,20 @@
import React from 'react'; import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { Alert } from '@openedx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors'; import selectors from 'data/selectors';
import messages from './messages';
import { BulkManagementAlerts, mapStateToProps } from './BulkManagementAlerts'; import { BulkManagementAlerts, mapStateToProps } from './BulkManagementAlerts';
import { renderWithIntl, screen } from '../../testUtilsExtra';
jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m,
FormattedMessage: () => 'FormattedMessage',
}));
jest.mock('@openedx/paragon', () => ({
Alert: () => 'Alert',
}));
jest.mock('data/selectors', () => ({ jest.mock('data/selectors', () => ({
__esModule: true, __esModule: true,
default: { default: {
@@ -19,19 +29,47 @@ const errorMessage = 'Oh noooooo';
describe('BulkManagementAlerts', () => { describe('BulkManagementAlerts', () => {
describe('component', () => { describe('component', () => {
describe('states of the warnings', () => { let el;
test('no alert shown', () => { describe('no errer, no upload success', () => {
renderWithIntl(<BulkManagementAlerts bulkImportError="" uploadSuccess={false} />); beforeEach(() => {
expect(document.querySelectorAll('.alert').length).toEqual(0); el = shallow(<BulkManagementAlerts />);
}); });
test('Just success alert shown', () => { test('snapshot - bulkImportError closed, success closed', () => {
renderWithIntl(<BulkManagementAlerts bulkImportError="" uploadSuccess />); expect(el.snapshot).toMatchSnapshot();
expect(document.querySelectorAll('.alert-success').length).toEqual(1);
}); });
test('Just error alert shown', () => { test('closed danger alert', () => {
renderWithIntl(<BulkManagementAlerts bulkImportError={errorMessage} uploadSuccess={false} />); expect(el.instance.children[0].type).toBe('Alert');
expect(document.querySelectorAll('.alert-danger').length).toEqual(1); expect(el.instance.findByType(Alert)[0].props.show).toEqual(false);
expect(screen.getByText(errorMessage)).toBeInTheDocument(); expect(el.instance.findByType(Alert)[0].props.variant).toEqual('danger');
});
test('closed success alert', () => {
expect(el.instance.children[1].type).toBe('Alert');
expect(el.instance.findByType(Alert)[1].props.show).toEqual(false);
expect(el.instance.findByType(Alert)[1].props.variant).toEqual('success');
});
});
describe('no errer, no upload success', () => {
beforeEach(() => {
el = shallow(<BulkManagementAlerts uploadSuccess bulkImportError={errorMessage} />);
});
const assertions = [
'danger alert open with bulkImportError',
'success alert open with messages.successDialog',
];
test(`snapshot - ${assertions.join(', ')}`, () => {
expect(el.snapshot).toMatchSnapshot();
});
test('open danger alert with bulkImportError content', () => {
expect(el.instance.children[0].type).toBe('Alert');
expect(el.instance.findByType(Alert)[0].children[0].el).toEqual(errorMessage);
expect(el.instance.findByType(Alert)[0].props.show).toEqual(true);
});
test('open success alert with messages.successDialog content', () => {
expect(el.instance.children[1].type).toBe('Alert');
expect(el.shallowWrapper.props.children[1].props.children).toEqual(
<FormattedMessage {...messages.successDialog} />,
);
expect(el.instance.children[1].props.show).toEqual(true);
}); });
}); });
}); });

View File

@@ -1,187 +1,108 @@
/* eslint-disable import/no-named-as-default */
import React from 'react'; import React from 'react';
import { render, screen, initializeMocks } from 'testUtilsExtra'; import { shallow } from '@edx/react-unit-test-utils';
import { DataTable } from '@openedx/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';
import { HistoryTable, mapHistoryRows, mapStateToProps } from './HistoryTable';
import ResultsSummary from './ResultsSummary'; import ResultsSummary from './ResultsSummary';
import { HistoryTable, mapStateToProps } from './HistoryTable';
initializeMocks(); jest.mock('@openedx/paragon', () => ({ DataTable: () => 'DataTable' }));
jest.mock('@openedx/paragon', () => ({ jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@openedx/paragon'), defineMessages: m => m,
DataTable: jest.fn(() => <div data-testid="data-table">DataTable</div>), FormattedMessage: () => 'FormattedMessage',
})); }));
jest.mock('./ResultsSummary', () => jest.fn(() => <div data-testid="results-summary">ResultsSummary</div>));
jest.mock('data/selectors', () => ({ jest.mock('data/selectors', () => ({
__esModule: true, __esModule: true,
default: { default: {
grades: { grades: {
bulkManagementHistoryEntries: jest.fn(), bulkManagementHistoryEntries: jest.fn(state => ({ historyEntries: state })),
}, },
}, },
})); }));
jest.mock('./ResultsSummary', () => 'ResultsSummary');
describe('HistoryTable', () => { describe('HistoryTable', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const mockBulkManagementHistory = [
{
originalFilename: 'test-file-1.csv',
user: 'test-user-1',
timeUploaded: '2025-01-01T10:00:00Z',
resultsSummary: {
rowId: 1,
text: 'Download results 1',
},
},
{
originalFilename: 'test-file-2.csv',
user: 'test-user-2',
timeUploaded: '2025-01-02T10:00:00Z',
resultsSummary: {
rowId: 2,
text: 'Download results 2',
},
},
];
beforeEach(() => {
jest.clearAllMocks();
});
describe('mapHistoryRows', () => {
const mockRow = {
resultsSummary: {
rowId: 1,
text: 'Download results',
},
originalFilename: 'test-file.csv',
user: 'test-user',
timeUploaded: '2025-01-01T10:00:00Z',
};
it('transforms row data correctly', () => {
const result = mapHistoryRows(mockRow);
expect(result).toHaveProperty('resultsSummary');
expect(result).toHaveProperty('filename');
expect(result).toHaveProperty('user');
expect(result).toHaveProperty('timeUploaded');
expect(result.timeUploaded).toBe('2025-01-01T10:00:00Z');
});
it('wraps filename in span with correct class', () => {
const result = mapHistoryRows(mockRow);
render(<div>{result.filename}</div>);
const filenameSpan = screen.getByText('test-file.csv');
expect(filenameSpan).toBeInTheDocument();
expect(filenameSpan).toHaveClass('wrap-text-in-cell');
});
it('wraps user in span with correct class', () => {
const result = mapHistoryRows(mockRow);
render(<div>{result.user}</div>);
const userSpan = screen.getByText('test-user');
expect(userSpan).toBeInTheDocument();
expect(userSpan).toHaveClass('wrap-text-in-cell');
});
it('renders ResultsSummary component with correct props', () => {
const result = mapHistoryRows(mockRow);
render(<div>{result.resultsSummary}</div>);
expect(ResultsSummary).toHaveBeenCalledWith(mockRow.resultsSummary, {});
expect(screen.getByTestId('results-summary')).toBeInTheDocument();
});
});
describe('component', () => { describe('component', () => {
it('renders DataTable with empty data when no history provided', () => { const entry1 = {
render(<HistoryTable />); originalFilename: 'blue.png',
user: 'Eifel',
expect(DataTable).toHaveBeenCalledWith( timeUploaded: '65',
{ resultsSummary: {
data: [], rowId: 12,
hasFixedColumnWidths: true, courseId: 'Da Bu Dee',
columns: bulkManagementColumns, text: 'Da ba daa',
className: 'table-striped', },
itemCount: 0, };
}, const entry2 = {
{}, originalFilename: 'allStar.jpg',
); user: 'Smashmouth',
expect(screen.getByTestId('data-table')).toBeInTheDocument(); timeUploaded: '2000s?',
}); resultsSummary: {
courseId: 'rockstar',
it('renders DataTable with mapped history data', () => { rowId: 2,
render( text: 'all that glitters is gold',
<HistoryTable bulkManagementHistory={mockBulkManagementHistory} />, },
); };
const props = {
expect(DataTable).toHaveBeenCalledWith( bulkManagementHistory: [entry1, entry2],
{ };
data: expect.arrayContaining([ let el;
expect.objectContaining({ describe('snapshot', () => {
filename: expect.any(Object), beforeEach(() => {
user: expect.any(Object), el = shallow(<HistoryTable {...props} />);
resultsSummary: expect.any(Object), });
timeUploaded: '2025-01-01T10:00:00Z', test('snapshot - loads formatted table', () => {
}), expect(el.snapshot).toMatchSnapshot();
expect.objectContaining({ });
filename: expect.any(Object), describe('history table', () => {
user: expect.any(Object), let table;
resultsSummary: expect.any(Object), beforeEach(() => {
timeUploaded: '2025-01-02T10:00:00Z', table = el.instance.findByType(DataTable);
}), });
]), describe('data (from bulkManagementHistory.map(this.formatHistoryRow)', () => {
hasFixedColumnWidths: true, const fieldAssertions = [
columns: bulkManagementColumns, 'maps resultsSummay to ResultsSummary',
className: 'table-striped', 'wraps filename and user',
itemCount: 2, 'forwards the rest',
}, ];
{}, test(`snapshot: ${fieldAssertions.join(', ')}`, () => {
); expect(table[0].props.data).toMatchSnapshot();
}); });
test(fieldAssertions.join(', '), () => {
it('passes correct props to DataTable', () => { const rows = table[0].props.data;
render( expect(rows[0].resultsSummary).toEqual(<ResultsSummary {...entry1.resultsSummary} />);
<HistoryTable bulkManagementHistory={mockBulkManagementHistory} />, expect(rows[0].user).toEqual(<span className="wrap-text-in-cell">{entry1.user}</span>);
); expect(
rows[0].filename,
const dataTableCall = DataTable.mock.calls[0][0]; ).toEqual(<span className="wrap-text-in-cell">{entry1.originalFilename}</span>);
expect(dataTableCall.hasFixedColumnWidths).toBe(true); expect(rows[1].resultsSummary).toEqual(<ResultsSummary {...entry2.resultsSummary} />);
expect(dataTableCall.columns).toBe(bulkManagementColumns); expect(rows[1].user).toEqual(<span className="wrap-text-in-cell">{entry2.user}</span>);
expect(dataTableCall.className).toBe('table-striped'); expect(
expect(dataTableCall.itemCount).toBe(mockBulkManagementHistory.length); rows[1].filename,
).toEqual(<span className="wrap-text-in-cell">{entry2.originalFilename}</span>);
});
});
test('columns from bulkManagementColumns', () => {
expect(table[0].props.columns).toEqual(bulkManagementColumns);
});
});
}); });
}); });
describe('mapStateToProps', () => { describe('mapStateToProps', () => {
const mockState = { test: 'state' }; const testState = { a: 'simple', test: 'state' };
const mockHistoryEntries = [ let mapped;
{ originalFilename: 'file1.csv', user: 'user1' },
{ originalFilename: 'file2.csv', user: 'user2' },
];
beforeEach(() => { beforeEach(() => {
selectors.grades.bulkManagementHistoryEntries.mockReturnValue( mapped = mapStateToProps(testState);
mockHistoryEntries,
);
}); });
test('bulkManagementHistory from grades.bulkManagementHistoryEntries', () => {
it('maps bulkManagementHistory from selector', () => {
const result = mapStateToProps(mockState);
expect( expect(
selectors.grades.bulkManagementHistoryEntries, mapped.bulkManagementHistory,
).toHaveBeenCalledWith(mockState); ).toEqual(selectors.grades.bulkManagementHistoryEntries(testState));
expect(result.bulkManagementHistory).toBe(mockHistoryEntries);
}); });
}); });
}); });

View File

@@ -19,8 +19,10 @@ const ResultsSummary = ({
text, text,
}) => ( }) => (
<Hyperlink <Hyperlink
destination={lms.urls.bulkGradesUrlByRow(rowId)} href={lms.urls.bulkGradesUrlByRow(rowId)}
destination="www.edx.org"
target="_blank" target="_blank"
rel="noopener noreferrer"
showLaunchIcon={false} showLaunchIcon={false}
> >
<Icon src={Download} className="d-inline-block" /> <Icon src={Download} className="d-inline-block" />

View File

@@ -1,12 +1,21 @@
import React from 'react'; import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { Download } from '@openedx/paragon/icons';
import lms from 'data/services/lms'; import lms from 'data/services/lms';
import { renderWithIntl, screen } from '../../testUtilsExtra';
import ResultsSummary from './ResultsSummary'; import ResultsSummary from './ResultsSummary';
jest.mock('@openedx/paragon', () => ({
Hyperlink: () => 'Hyperlink',
Icon: () => 'Icon',
}));
jest.mock('@openedx/paragon/icons', () => ({
Download: 'DownloadIcon',
}));
jest.mock('data/services/lms', () => ({ jest.mock('data/services/lms', () => ({
urls: { urls: {
bulkGradesUrlByRow: jest.fn((rowId) => (`www.edx.org/${rowId}`)), bulkGradesUrlByRow: jest.fn((rowId) => ({ url: { rowId } })),
}, },
})); }));
@@ -15,21 +24,29 @@ describe('ResultsSummary component', () => {
rowId: 42, rowId: 42,
text: 'texty', text: 'texty',
}; };
let link; let el;
const assertions = [
'safe hyperlink with bulkGradesUrl with course and row id',
'download icon',
'results text',
];
beforeEach(() => { beforeEach(() => {
renderWithIntl(<ResultsSummary {...props} />); el = shallow(<ResultsSummary {...props} />);
link = screen.getByRole('link', { name: props.text }); });
test(`snapshot - ${assertions.join(', ')}`, () => {
expect(el.snapshot).toMatchSnapshot();
}); });
test('Hyperlink has target="_blank" and rel="noopener noreferrer"', () => { test('Hyperlink has target="_blank" and rel="noopener noreferrer"', () => {
expect(link).toHaveAttribute('target', '_blank'); expect(el.instance.props.target).toEqual('_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer'); expect(el.instance.props.rel).toEqual('noopener noreferrer');
}); });
test('Hyperlink has href to bulkGradesUrl', () => { test('Hyperlink has href to bulkGradesUrl', () => {
expect(link).toHaveAttribute('href', 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', () => {
expect(link).toHaveTextContent(props.text); const icon = el.instance.children[0];
const icon = screen.getByRole('img', { hidden: true }); expect(icon.type).toEqual('Icon');
expect(icon).toBeInTheDocument(); expect(icon.props.src).toEqual(Download);
expect(el.instance.children[1].el).toEqual(props.text);
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,24 +1,46 @@
import { render, initializeMocks, screen } from 'testUtilsExtra'; /* eslint-disable import/no-named-as-default */
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { BulkManagementHistoryView } from '.'; import { BulkManagementHistoryView } from '.';
import BulkManagementAlerts from './BulkManagementAlerts';
import HistoryTable from './HistoryTable';
import messages from './messages'; import messages from './messages';
jest.mock('./BulkManagementAlerts', () => jest.fn(() => <div>BulkManagementAlerts</div>)); jest.mock('./BulkManagementAlerts', () => 'BulkManagementAlerts');
jest.mock('./HistoryTable', () => jest.fn(() => <div>HistoryTable</div>)); jest.mock('./HistoryTable', () => 'HistoryTable');
initializeMocks();
describe('BulkManagementHistoryView', () => { describe('BulkManagementHistoryView', () => {
describe('component', () => { describe('component', () => {
let el;
beforeEach(() => { beforeEach(() => {
render(<BulkManagementHistoryView />); el = shallow(<BulkManagementHistoryView />);
}); });
describe('render alerts and heading', () => { describe('snapshot', () => {
it('heading - h4 loaded from messages', () => { const snapshotSegments = [
expect(screen.getByText(messages.heading.defaultMessage)).toBeInTheDocument(); 'heading from messages.BulkManagementHistoryView.heading',
expect(screen.getByText(messages.helpText.defaultMessage)).toBeInTheDocument(); '<BulkManagementAlerts />',
expect(screen.getByText('BulkManagementAlerts')).toBeInTheDocument(); '<HistoryTable />',
expect(screen.getByText('HistoryTable')).toBeInTheDocument(); ];
test(`snapshot - loads ${snapshotSegments.join(', ')}`, () => {
expect(el.snapshot).toMatchSnapshot();
});
test('heading - h4 loaded from messages', () => {
const heading = el.instance.findByType('h4')[0];
const expectedHeading = shallow(
<h4>
<FormattedMessage {...messages.heading} />
</h4>,
);
expect(heading.el.type).toEqual(expectedHeading.type);
expect(heading.el.props).toEqual(expectedHeading.props);
});
test('heading, then alerts, then upload form, then table', () => {
expect(el.instance.children[0].type).toEqual('h4');
expect(el.instance.children[2].type).toEqual(BulkManagementAlerts);
expect(el.instance.children[3].type).toEqual(HistoryTable);
}); });
}); });
}); });

View File

@@ -8,7 +8,7 @@ const messages = defineMessages({
}, },
helpText: { helpText: {
id: 'gradebook.BulkManagementHistoryView', id: 'gradebook.BulkManagementHistoryView',
defaultMessage: 'Below is a log of previous grade imports. To download a CSV of your gradebook and import grades for override, return to the Gradebook. Please note, after importing grades, it may take a few seconds to process the override.', defaultMessage: 'Below is a log of previous grade imports. To download a CSV of your gradebook and import grades for override, return to the Gradebook. Please note, after importing grades, it may take a few seconds to process the override.',
description: 'Bulk Management History View help text', description: 'Bulk Management History View help text',
}, },
successDialog: { successDialog: {

View File

@@ -1,35 +0,0 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { render, screen } from '@testing-library/react';
import Header from '.';
jest.mock('@edx/frontend-platform', () => ({
...jest.requireActual('@edx/frontend-platform'),
getConfig: jest.fn(),
}));
describe('Header', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('has edx link with logo url', () => {
const url = 'www.ourLogo.url';
const baseUrl = 'www.lms.url';
getConfig.mockReturnValue({ LOGO_URL: url, LMS_BASE_URL: baseUrl });
render(
<IntlProvider messages={{}} locale="en">
<Header />
</IntlProvider>,
);
const link = screen.getByRole('link');
const logo = screen.getByAltText('edX logo');
expect(link).toHaveAttribute('href', `${baseUrl}/dashboard`);
expect(logo).toHaveAttribute('src', url);
});
});

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AssignmentFilter component render snapshot 1`] = `
<div
className="student-filters"
>
<SelectGroup
disabled={false}
id="assignment"
label="Assignment"
onChange={[MockFunction]}
options={
[
<option
value=""
>
All
</option>,
<option
value="label1"
>
label1
:
sLabel1
</option>,
<option
value="label2"
>
label2
:
sLabel2
</option>,
<option
value="label3"
>
label3
:
sLabel3
</option>,
<option
value="label4"
>
label4
:
sLabel4
</option>,
]
}
value="test-label"
/>
</div>
`;

View File

@@ -1,9 +1,12 @@
import React from 'react'; import React from 'react';
import { render, screen, initializeMocks } from 'testUtilsExtra'; import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n';
import SelectGroup from '../SelectGroup';
import useAssignmentFilterData from './hooks'; import useAssignmentFilterData from './hooks';
import AssignmentFilter from '.'; import AssignmentFilter from '.';
jest.mock('../SelectGroup', () => 'SelectGroup');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() })); jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
const handleChange = jest.fn(); const handleChange = jest.fn();
@@ -22,16 +25,29 @@ useAssignmentFilterData.mockReturnValue({
const updateQueryParams = jest.fn(); const updateQueryParams = jest.fn();
let el;
describe('AssignmentFilter component', () => { describe('AssignmentFilter component', () => {
beforeAll(() => { beforeAll(() => {
initializeMocks(); el = shallow(<AssignmentFilter updateQueryParams={updateQueryParams} />);
render(<AssignmentFilter updateQueryParams={updateQueryParams} />); });
describe('behavior', () => {
it('initializes hooks', () => {
expect(useAssignmentFilterData).toHaveBeenCalledWith({ updateQueryParams });
expect(useIntl).toHaveBeenCalledWith();
});
}); });
describe('render', () => { describe('render', () => {
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
});
test('filter options', () => { test('filter options', () => {
expect(screen.getByRole('combobox', { name: 'Assignment' })).toBeInTheDocument(); const { options } = el.instance.findByType(SelectGroup)[0].props;
expect(screen.getAllByRole('option')).toHaveLength(assignmentFilterOptions.length + 1); // +1 for the default option expect(options.length).toEqual(5);
expect(screen.getAllByRole('option')[assignmentFilterOptions.length]).toHaveTextContent(assignmentFilterOptions[assignmentFilterOptions.length - 1].label); const testOption = assignmentFilterOptions[0];
const optionProps = options[1].props;
expect(optionProps.value).toEqual(testOption.label);
expect(optionProps.children.join(''))
.toEqual(`${testOption.label}: ${testOption.subsectionLabel}`);
}); });
}); });
}); });

View File

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

View File

@@ -1,15 +1,17 @@
/* eslint-disable import/no-extraneous-dependencies */ import React from 'react';
import { screen } from '@testing-library/react'; import { shallow } from '@edx/react-unit-test-utils';
import userEvent from '@testing-library/user-event'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import PercentGroup from '../PercentGroup';
import useAssignmentGradeFilterData from './hooks'; import useAssignmentGradeFilterData from './hooks';
import AssignmentFilter from '.'; import AssignmentFilter from '.';
import { renderWithIntl } from '../../../testUtilsExtra';
jest.mock('../PercentGroup', () => 'PercentGroup');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() })); jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
const hookData = { const hookData = {
handleSubmit: jest.fn(), handleChange: jest.fn(),
handleSetMax: jest.fn(), handleSetMax: jest.fn(),
handleSetMin: jest.fn(), handleSetMin: jest.fn(),
selectedAssignment: 'test-assignment', selectedAssignment: 'test-assignment',
@@ -20,39 +22,37 @@ useAssignmentGradeFilterData.mockReturnValue(hookData);
const updateQueryParams = jest.fn(); const updateQueryParams = jest.fn();
let el;
describe('AssignmentFilter component', () => { describe('AssignmentFilter component', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<AssignmentFilter updateQueryParams={updateQueryParams} />);
});
describe('behavior', () => { describe('behavior', () => {
it('initializes hooks', () => { it('initializes hooks', () => {
renderWithIntl(<AssignmentFilter updateQueryParams={updateQueryParams} />);
expect(useAssignmentGradeFilterData).toHaveBeenCalledWith({ updateQueryParams }); expect(useAssignmentGradeFilterData).toHaveBeenCalledWith({ updateQueryParams });
expect(useIntl).toHaveBeenCalledWith();
}); });
}); });
describe('render', () => { describe('render', () => {
describe('with selected assignment', () => { describe('with selected assignment', () => {
beforeEach(() => { test('snapshot', () => {
jest.clearAllMocks(); expect(el.snapshot).toMatchSnapshot();
renderWithIntl(<AssignmentFilter updateQueryParams={updateQueryParams} />);
}); });
it('renders a PercentGroup for both Max and Min filters', async () => { it('renders a PercentGroup for both Max and Min filters', () => {
const user = userEvent.setup(); let { props } = el.instance.findByType(PercentGroup)[0];
const minGradeInput = screen.getByRole('spinbutton', { name: /Min Grade/i }); expect(props.value).toEqual(hookData.assignmentGradeMin);
const maxGradeInput = screen.getByRole('spinbutton', { name: /Max Grade/i }); expect(props.disabled).toEqual(false);
expect(minGradeInput).toBeInTheDocument(); expect(props.onChange).toEqual(hookData.handleSetMin);
expect(maxGradeInput).toBeInTheDocument(); props = el.instance.findByType(PercentGroup)[1].props;
expect(minGradeInput).toBeEnabled(); expect(props.value).toEqual(hookData.assignmentGradeMax);
expect(maxGradeInput).toBeEnabled(); expect(props.disabled).toEqual(false);
await user.type(minGradeInput, '25'); expect(props.onChange).toEqual(hookData.handleSetMax);
expect(hookData.handleSetMin).toHaveBeenCalled();
await user.type(maxGradeInput, '50');
expect(hookData.handleSetMax).toHaveBeenCalled();
}); });
it('renders a submit button', async () => { it('renders a submit button', () => {
const user = userEvent.setup(); const { props } = el.instance.findByType(Button)[0];
const submitButton = screen.getByRole('button', { name: /Apply/ }); expect(props.disabled).toEqual(false);
expect(submitButton).toBeInTheDocument(); expect(props.onClick).toEqual(hookData.handleSubmit);
expect(submitButton).not.toHaveAttribute('disabled');
await user.click(submitButton);
expect(hookData.handleSubmit).toHaveBeenCalled();
}); });
}); });
describe('without selected assignment', () => { describe('without selected assignment', () => {
@@ -61,13 +61,16 @@ describe('AssignmentFilter component', () => {
...hookData, ...hookData,
selectedAssignment: null, selectedAssignment: null,
}); });
renderWithIntl(<AssignmentFilter updateQueryParams={updateQueryParams} />); el = shallow(<AssignmentFilter updateQueryParams={updateQueryParams} />);
});
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
}); });
it('disables controls', () => { it('disables controls', () => {
const minGrade = screen.getByRole('spinbutton', { name: /Min Grade/ }); let { props } = el.instance.findByType(PercentGroup)[0];
const maxGrade = screen.getByRole('spinbutton', { name: /Max Grade/ }); expect(props.disabled).toEqual(true);
expect(minGrade).toHaveAttribute('disabled'); props = el.instance.findByType(PercentGroup)[1].props;
expect(maxGrade).toHaveAttribute('disabled'); expect(props.disabled).toEqual(true);
}); });
}); });
}); });

View File

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

View File

@@ -1,10 +1,12 @@
import React from 'react'; import React from 'react';
import { screen } from '@testing-library/react'; import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n';
import SelectGroup from '../SelectGroup';
import useAssignmentFilterTypeData from './hooks'; import useAssignmentFilterTypeData from './hooks';
import AssignmentFilterType from '.'; import AssignmentFilterType from '.';
import { renderWithIntl } from '../../../testUtilsExtra';
jest.mock('../SelectGroup', () => 'SelectGroup');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() })); jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
const handleChange = jest.fn(); const handleChange = jest.fn();
@@ -19,15 +21,27 @@ useAssignmentFilterTypeData.mockReturnValue({
const updateQueryParams = jest.fn(); const updateQueryParams = jest.fn();
let el;
describe('AssignmentFilterType component', () => { describe('AssignmentFilterType component', () => {
beforeAll(() => { beforeAll(() => {
renderWithIntl(<AssignmentFilterType updateQueryParams={updateQueryParams} />); el = shallow(<AssignmentFilterType updateQueryParams={updateQueryParams} />);
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(useAssignmentFilterTypeData).toHaveBeenCalledWith({ updateQueryParams });
expect(useIntl).toHaveBeenCalledWith();
});
}); });
describe('render', () => { describe('render', () => {
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
});
test('filter options', () => { test('filter options', () => {
const options = screen.getAllByRole('option'); const { options } = el.instance.findByType(SelectGroup)[0].props;
expect(options.length).toEqual(5); // 4 types + "All Types" expect(options.length).toEqual(5);
expect(options[1]).toHaveTextContent(testType); const optionProps = options[1].props;
expect(optionProps.value).toEqual(assignmentTypes[0]);
expect(optionProps.children).toEqual(testType);
}); });
}); });
}); });

View File

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

View File

@@ -1,10 +1,13 @@
import React from 'react'; import React from 'react';
import { screen } from '@testing-library/react'; import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import PercentGroup from '../PercentGroup';
import useCourseGradeFilterData from './hooks'; import useCourseGradeFilterData from './hooks';
import CourseFilter from '.'; import CourseFilter from '.';
import { renderWithIntl } from '../../../testUtilsExtra';
jest.mock('../PercentGroup', () => 'PercentGroup');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() })); jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
const hookData = { const hookData = {
@@ -24,37 +27,48 @@ useCourseGradeFilterData.mockReturnValue(hookData);
const updateQueryParams = jest.fn(); const updateQueryParams = jest.fn();
let el;
describe('CourseFilter component', () => { describe('CourseFilter component', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
el = shallow(<CourseFilter updateQueryParams={updateQueryParams} />);
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(useCourseGradeFilterData).toHaveBeenCalledWith({ updateQueryParams });
expect(useIntl).toHaveBeenCalledWith();
});
}); });
describe('render', () => { describe('render', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('with selected assignment', () => { describe('with selected assignment', () => {
beforeEach(() => { test('snapshot', () => {
renderWithIntl(<CourseFilter updateQueryParams={updateQueryParams} />); expect(el.snapshot).toMatchSnapshot();
}); });
it('renders a PercentGroup for both Max and Min filters', () => { it('renders a PercentGroup for both Max and Min filters', () => {
expect(screen.getByRole('spinbutton', { name: 'Min Grade' })).toHaveValue(hookData.min.value); let { props } = el.instance.findByType(PercentGroup)[0];
expect(screen.getByRole('spinbutton', { name: 'Max Grade' })).toHaveValue(hookData.max.value); expect(props.value).toEqual(hookData.min.value);
expect(props.onChange).toEqual(hookData.min.onChange);
props = el.instance.findByType(PercentGroup)[1].props;
expect(props.value).toEqual(hookData.max.value);
expect(props.onChange).toEqual(hookData.max.onChange);
}); });
it('renders a submit button', () => { it('renders a submit button', () => {
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument(); const { props } = el.instance.findByType(Button)[0];
// Expect it to be enabled expect(props.disabled).toEqual(false);
expect(screen.getByRole('button', { name: 'Apply' })).not.toBeDisabled(); expect(props.onClick).toEqual(hookData.handleApplyClick);
}); });
}); });
describe('if disabled', () => { describe('if disabled', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks();
useCourseGradeFilterData.mockReturnValueOnce({ ...hookData, isDisabled: true }); useCourseGradeFilterData.mockReturnValueOnce({ ...hookData, isDisabled: true });
renderWithIntl(<CourseFilter updateQueryParams={updateQueryParams} />); el = shallow(<CourseFilter updateQueryParams={updateQueryParams} />);
});
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
}); });
it('disables submit', () => { it('disables submit', () => {
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled(); const { props } = el.instance.findByType(Button)[0];
expect(props.disabled).toEqual(true);
}); });
}); });
}); });

View File

@@ -1,4 +1,5 @@
import { render, screen, initializeMocks } from 'testUtilsExtra'; import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import PercentGroup from './PercentGroup'; import PercentGroup from './PercentGroup';
@@ -11,7 +12,6 @@ describe('PercentGroup', () => {
}; };
beforeEach(() => { beforeEach(() => {
initializeMocks();
props = { props = {
...props, ...props,
onChange: jest.fn().mockName('props.onChange'), onChange: jest.fn().mockName('props.onChange'),
@@ -19,17 +19,15 @@ describe('PercentGroup', () => {
}); });
describe('Component', () => { describe('Component', () => {
test('is displayed', () => { describe('snapshots', () => {
render(<PercentGroup {...props} />); test('basic snapshot', () => {
expect(screen.getByRole('spinbutton', { name: 'Group Label' })).toBeInTheDocument(); const el = shallow(<PercentGroup {...props} />);
expect(screen.getByText('Group Label')).toBeVisible(); expect(el.snapshot).toMatchSnapshot();
expect(screen.getByText('%')).toBeVisible(); });
}); test('disabled', () => {
test('disabled', () => { const el = shallow(<PercentGroup {...props} disabled />);
render(<PercentGroup {...props} disabled />); expect(el.snapshot).toMatchSnapshot();
expect(screen.getByRole('spinbutton', { name: 'Group Label' })).toBeDisabled(); });
expect(screen.getByText('Group Label')).toBeVisible();
expect(screen.getByText('%')).toBeVisible();
}); });
}); });
}); });

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { shallow } from '@edx/react-unit-test-utils';
import SelectGroup from './SelectGroup'; import SelectGroup from './SelectGroup';
@@ -24,14 +24,15 @@ describe('SelectGroup', () => {
}); });
describe('Component', () => { describe('Component', () => {
test('rendered with all options and label', () => { describe('snapshots', () => {
render(<SelectGroup {...props} />); test('basic snapshot', () => {
expect(screen.getAllByRole('option')).toHaveLength(props.options.length); const el = shallow(<SelectGroup {...props} />);
expect(screen.getByLabelText(props.label)).toBeInTheDocument(); expect(el.snapshot).toMatchSnapshot();
}); });
test('disabled', () => { test('disabled', () => {
render(<SelectGroup {...props} disabled />); const el = shallow(<SelectGroup {...props} disabled />);
expect(screen.getByRole('combobox')).toBeDisabled(); expect(el.snapshot).toMatchSnapshot();
});
}); });
}); });
}); });

View File

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

View File

@@ -1,164 +1,84 @@
import React from 'react'; import React from 'react';
import { render, screen, initializeMocks } from 'testUtilsExtra'; import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n';
import SelectGroup from '../SelectGroup'; import SelectGroup from '../SelectGroup';
import { StudentGroupsFilter } from './index';
import useStudentGroupsFilterData from './hooks'; import useStudentGroupsFilterData from './hooks';
import StudentGroupsFilter from '.';
jest.mock('../SelectGroup', () => jest.fn(() => <div data-testid="select-group">SelectGroup</div>)); jest.mock('../SelectGroup', () => 'SelectGroup');
jest.mock('./hooks', () => jest.fn()); jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
initializeMocks(); const props = {
cohorts: {
describe('StudentGroupsFilter', () => { value: 'test-cohort',
const mockUpdateQueryParams = jest.fn();
const mockTracksData = {
value: 'test-track-value',
entries: [ entries: [
{ value: 'track1', name: 'Track 1' }, { value: 'v1', name: 'n1' },
{ value: 'track2', name: 'Track 2' }, { value: 'v2', name: 'n2' },
], { value: 'v3', name: 'n3' },
handleChange: jest.fn(),
};
const mockCohortsData = {
value: 'test-cohort-value',
entries: [
{ value: 'cohort1', name: 'Cohort 1' },
{ value: 'cohort2', name: 'Cohort 2' },
], ],
handleChange: jest.fn(), handleChange: jest.fn(),
isDisabled: false, isDisabled: false,
}; },
tracks: {
value: 'test-track',
entries: [
{ value: 'v1', name: 'n1' },
{ value: 'v2', name: 'n2' },
{ value: 'v3', name: 'n3' },
{ value: 'v4', name: 'n4' },
],
handleChange: jest.fn(),
},
};
useStudentGroupsFilterData.mockReturnValue(props);
const updateQueryParams = jest.fn();
beforeEach(() => { let el;
describe('StudentGroupsFilter component', () => {
beforeAll(() => {
jest.clearAllMocks(); jest.clearAllMocks();
useStudentGroupsFilterData.mockReturnValue({ el = shallow(<StudentGroupsFilter updateQueryParams={updateQueryParams} />);
tracks: mockTracksData, });
cohorts: mockCohortsData, describe('behavior', () => {
it('initializes hooks', () => {
expect(useStudentGroupsFilterData).toHaveBeenCalledWith({ updateQueryParams });
expect(useIntl).toHaveBeenCalledWith();
}); });
}); });
describe('render', () => {
it('calls useStudentGroupsFilterData hook with updateQueryParams', () => { test('snapshot', () => {
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />); expect(el.snapshot).toMatchSnapshot();
expect(useStudentGroupsFilterData).toHaveBeenCalledWith({
updateQueryParams: mockUpdateQueryParams,
}); });
}); test('track options', () => {
const {
it('renders two SelectGroup components', () => { options,
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />); onChange,
value,
expect(SelectGroup).toHaveBeenCalledTimes(2); } = el.instance.findByType(SelectGroup)[0].props;
expect(screen.getAllByTestId('select-group')).toHaveLength(2); expect(value).toEqual(props.tracks.value);
}); expect(onChange).toEqual(props.tracks.handleChange);
expect(options.length).toEqual(5);
describe('tracks SelectGroup', () => { const testEntry = props.tracks.entries[0];
it('renders tracks SelectGroup with correct props', () => { const optionProps = options[1].props;
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />); expect(optionProps.value).toEqual(testEntry.value);
expect(optionProps.children).toEqual(testEntry.name);
const tracksCall = SelectGroup.mock.calls[0][0];
expect(tracksCall.id).toBe('Tracks');
expect(tracksCall.value).toBe(mockTracksData.value);
expect(tracksCall.onChange).toBe(mockTracksData.handleChange);
}); });
test('cohort options', () => {
it('includes trackAll option in tracks SelectGroup', () => { const {
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />); options,
onChange,
const tracksCall = SelectGroup.mock.calls[0][0]; disabled,
const { options } = tracksCall; value,
} = el.instance.findByType(SelectGroup)[1].props;
expect(options).toHaveLength(3); expect(value).toEqual(props.cohorts.value);
expect(options[0].props.value).toBeDefined(); expect(disabled).toEqual(false);
expect(options[0].props.children).toBeDefined(); expect(onChange).toEqual(props.cohorts.handleChange);
}); expect(options.length).toEqual(4);
const testEntry = props.cohorts.entries[0];
it('includes track entries in tracks SelectGroup options', () => { const optionProps = options[1].props;
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />); expect(optionProps.value).toEqual(testEntry.value);
expect(optionProps.children).toEqual(testEntry.name);
const tracksCall = SelectGroup.mock.calls[0][0];
const { options } = tracksCall;
expect(options[1].props.value).toBe('track1');
expect(options[1].props.children).toBe('Track 1');
expect(options[2].props.value).toBe('track2');
expect(options[2].props.children).toBe('Track 2');
});
});
describe('cohorts SelectGroup', () => {
it('renders cohorts SelectGroup with correct props', () => {
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
const cohortsCall = SelectGroup.mock.calls[1][0];
expect(cohortsCall.id).toBe('Cohorts');
expect(cohortsCall.value).toBe(mockCohortsData.value);
expect(cohortsCall.onChange).toBe(mockCohortsData.handleChange);
expect(cohortsCall.disabled).toBe(mockCohortsData.isDisabled);
});
it('includes cohortAll option in cohorts SelectGroup', () => {
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
const cohortsCall = SelectGroup.mock.calls[1][0];
const { options } = cohortsCall;
expect(options).toHaveLength(3);
expect(options[0].props.value).toBeDefined();
expect(options[0].props.children).toBeDefined();
});
it('includes cohort entries in cohorts SelectGroup options', () => {
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
const cohortsCall = SelectGroup.mock.calls[1][0];
const { options } = cohortsCall;
expect(options[1].props.value).toBe('cohort1');
expect(options[1].props.children).toBe('Cohort 1');
expect(options[2].props.value).toBe('cohort2');
expect(options[2].props.children).toBe('Cohort 2');
});
it('passes disabled state to cohorts SelectGroup', () => {
useStudentGroupsFilterData.mockReturnValue({
tracks: mockTracksData,
cohorts: { ...mockCohortsData, isDisabled: true },
});
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
const cohortsCall = SelectGroup.mock.calls[1][0];
expect(cohortsCall.disabled).toBe(true);
});
});
describe('with empty entries', () => {
it('handles empty tracks entries', () => {
useStudentGroupsFilterData.mockReturnValue({
tracks: { ...mockTracksData, entries: [] },
cohorts: mockCohortsData,
});
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
const tracksCall = SelectGroup.mock.calls[0][0];
expect(tracksCall.options).toHaveLength(1);
});
it('handles empty cohorts entries', () => {
useStudentGroupsFilterData.mockReturnValue({
tracks: mockTracksData,
cohorts: { ...mockCohortsData, entries: [] },
});
render(<StudentGroupsFilter updateQueryParams={mockUpdateQueryParams} />);
const cohortsCall = SelectGroup.mock.calls[1][0];
expect(cohortsCall.options).toHaveLength(1);
}); });
}); });
}); });

View File

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

View File

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

View File

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

View File

@@ -1,30 +1,82 @@
import React from 'react'; import React from 'react';
import { render, screen, initializeMocks } from 'testUtilsExtra'; import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Collapsible } from '@openedx/paragon';
import { formatMessage } from 'testUtils';
import AssignmentTypeFilter from './AssignmentTypeFilter';
import AssignmentFilter from './AssignmentFilter';
import AssignmentGradeFilter from './AssignmentGradeFilter';
import CourseGradeFilter from './CourseGradeFilter';
import StudentGroupsFilter from './StudentGroupsFilter';
import messages from './messages';
import useGradebookFiltersData from './hooks';
import GradebookFilters from '.'; import GradebookFilters from '.';
const updateQueryParams = jest.fn(); jest.mock('./AssignmentTypeFilter', () => 'AssignmentTypeFilter');
jest.mock('./AssignmentFilter', () => 'AssignmentFilter');
jest.mock('./AssignmentGradeFilter', () => 'AssignmentGradeFilter');
jest.mock('./CourseGradeFilter', () => 'CourseGradeFilter');
jest.mock('./StudentGroupsFilter', () => 'StudentGroupsFilter');
initializeMocks(); jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
const hookProps = {
closeMenu: jest.fn().mockName('hook.closeMenu'),
includeCourseTeamMembers: {
value: true,
handleChange: jest.fn().mockName('hook.handleChange'),
},
};
useGradebookFiltersData.mockReturnValue(hookProps);
let el;
const updateQueryParams = jest.fn();
describe('GradebookFilters', () => { describe('GradebookFilters', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
render(<GradebookFilters updateQueryParams={updateQueryParams} />); el = shallow(<GradebookFilters updateQueryParams={updateQueryParams} />);
}); });
describe('All filters render together', () => { describe('behavior', () => {
it('initializes hooks', () => {
expect(useGradebookFiltersData).toHaveBeenCalledWith({ updateQueryParams });
expect(useIntl).toHaveBeenCalledWith();
});
});
describe('render', () => {
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
});
test('Assignment filters', () => { test('Assignment filters', () => {
expect(screen.getByRole('combobox', { name: 'Assignment Types' })).toBeInTheDocument(); expect(el.instance.findByType(Collapsible)[0].children[0]).toMatchObject(shallow(
expect(screen.getByRole('combobox', { name: 'Assignment' })).toBeInTheDocument(); <div>
<AssignmentTypeFilter updateQueryParams={updateQueryParams} />
<AssignmentFilter updateQueryParams={updateQueryParams} />
<AssignmentGradeFilter updateQueryParams={updateQueryParams} />
</div>,
));
}); });
test('CourseGrade filters', () => { test('CourseGrade filters', () => {
expect(screen.getByRole('button', { name: 'Overall Grade' })).toBeInTheDocument(); expect(el.instance.findByType(Collapsible)[1].children[0]).toMatchObject(shallow(
<CourseGradeFilter updateQueryParams={updateQueryParams} />,
));
}); });
test('StudentGroups filters', () => { test('StudentGroups filters', () => {
expect(screen.getByRole('button', { name: 'Student Groups' })).toBeInTheDocument(); expect(el.instance.findByType(Collapsible)[2].children[0]).toMatchObject(shallow(
<StudentGroupsFilter updateQueryParams={updateQueryParams} />,
));
}); });
test('includeCourseTeamMembers', () => { test('includeCourseTeamMembers', () => {
expect(screen.getByRole('button', { name: 'Include Course Team Members' })).toBeInTheDocument(); const checkbox = el.instance.findByType(Collapsible)[3].children[0];
expect(checkbox.props).toEqual({
checked: true,
onChange: hookProps.includeCourseTeamMembers.handleChange,
});
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,300 +1,77 @@
import React from 'react'; import React from 'react';
import { render, screen, initializeMocks } from 'testUtilsExtra'; import { shallow } from '@edx/react-unit-test-utils';
import userEvent from '@testing-library/user-event'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { formatMessage } from 'testUtils';
import { instructorDashboardUrl } from 'data/services/lms/urls'; import { instructorDashboardUrl } from 'data/services/lms/urls';
import { GradebookHeader } from './index';
import useGradebookHeaderData from './hooks'; import useGradebookHeaderData from './hooks';
import messages from './messages'; import GradebookHeader from '.';
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('data/services/lms/urls', () => ({ jest.mock('data/services/lms/urls', () => ({
instructorDashboardUrl: jest.fn(), instructorDashboardUrl: jest.fn(),
})); }));
jest.mock('./hooks', () => jest.fn());
initializeMocks(); instructorDashboardUrl.mockReturnValue('test-dashboard-url');
describe('GradebookHeader', () => { const hookProps = {
const mockHandleToggleViewClick = jest.fn(); areGradesFrozen: false,
canUserViewGradebook: true,
courseId: 'test-course-id',
handleToggleViewClick: jest.fn().mockName('hooks.handleToggleViewClick'),
showBulkManagement: false,
toggleViewMessage: { defaultMessage: 'toggle-view-message' },
};
useGradebookHeaderData.mockReturnValue(hookProps);
beforeEach(() => { let el;
jest.clearAllMocks(); describe('GradebookHeader component', () => {
instructorDashboardUrl.mockReturnValue('https://example.com/dashboard'); beforeAll(() => {
el = shallow(<GradebookHeader />);
}); });
describe('behavior', () => {
describe('basic rendering', () => { it('initializes hooks', () => {
beforeEach(() => { expect(useGradebookHeaderData).toHaveBeenCalledWith();
useGradebookHeaderData.mockReturnValue({ expect(useIntl).toHaveBeenCalledWith();
areGradesFrozen: false,
canUserViewGradebook: true,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: false,
toggleViewMessage: messages.toActivityLog,
});
});
it('renders the main header container', () => {
render(<GradebookHeader />);
const header = screen.getByText('Gradebook').closest('.gradebook-header');
expect(header).toHaveClass('gradebook-header');
});
it('renders back to dashboard link', () => {
render(<GradebookHeader />);
const dashboardLink = screen.getByRole('link');
expect(dashboardLink).toHaveAttribute(
'href',
'https://example.com/dashboard',
);
expect(dashboardLink).toHaveClass('mb-3');
expect(dashboardLink).toHaveTextContent('Back to Dashboard');
});
it('renders gradebook title', () => {
render(<GradebookHeader />);
const title = screen.getByRole('heading', { level: 1 });
expect(title).toHaveTextContent('Gradebook');
});
it('renders course ID subtitle', () => {
render(<GradebookHeader />);
const subtitle = screen.getByRole('heading', { level: 2 });
expect(subtitle).toHaveTextContent('course-v1:TestU+CS101+2024');
expect(subtitle).toHaveClass('text-break');
});
it('renders subtitle row with correct classes', () => {
render(<GradebookHeader />);
const subtitleRow = screen.getByRole('heading', {
level: 2,
}).parentElement;
expect(subtitleRow).toHaveClass(
'subtitle-row',
'd-flex',
'justify-content-between',
'align-items-center',
);
});
it('calls instructorDashboardUrl to get dashboard URL', () => {
render(<GradebookHeader />);
expect(instructorDashboardUrl).toHaveBeenCalled();
});
it('calls useGradebookHeaderData hook', () => {
render(<GradebookHeader />);
expect(useGradebookHeaderData).toHaveBeenCalled();
}); });
}); });
describe('render', () => {
describe('bulk management toggle button', () => { describe('default view', () => {
describe('when showBulkManagement is true', () => { test('shapshot', () => {
expect(el.snapshot).toMatchSnapshot();
});
});
describe('show bulk management', () => {
beforeEach(() => { beforeEach(() => {
useGradebookHeaderData.mockReturnValue({ useGradebookHeaderData.mockReturnValueOnce({ ...hookProps, showBulkManagement: true });
areGradesFrozen: false, el = shallow(<GradebookHeader />);
canUserViewGradebook: true,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: true,
toggleViewMessage: messages.toActivityLog,
});
}); });
test('snapshot: show toggle view message button with handleToggleViewClick method', () => {
it('renders toggle view button', () => { expect(el.snapshot).toMatchSnapshot();
render(<GradebookHeader />); const { onClick } = el.instance.findByType(Button)[0].props;
expect(screen.getByRole('button')).toBeInTheDocument(); expect(onClick).toEqual(hookProps.handleToggleViewClick);
}); expect(el.instance.findByType(Button)[0].children[0].el).toEqual(formatMessage(hookProps.toggleViewMessage));
it('displays correct button text from toggleViewMessage', () => {
render(<GradebookHeader />);
const toggleButton = screen.getByRole('button');
expect(toggleButton).toHaveTextContent('View Bulk Management History');
});
it('calls handleToggleViewClick when button is clicked', async () => {
render(<GradebookHeader />);
const user = userEvent.setup();
const toggleButton = screen.getByRole('button');
await user.click(toggleButton);
expect(mockHandleToggleViewClick).toHaveBeenCalledTimes(1);
});
it('displays correct message from toggleViewMessage', () => {
useGradebookHeaderData.mockReturnValue({
areGradesFrozen: false,
canUserViewGradebook: true,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: true,
toggleViewMessage: messages.toGradesView,
});
render(<GradebookHeader />);
const toggleButton = screen.getByRole('button');
expect(toggleButton).toHaveTextContent('Return to Gradebook');
}); });
}); });
describe('frozen grades', () => {
describe('when showBulkManagement is false', () => {
beforeEach(() => { beforeEach(() => {
useGradebookHeaderData.mockReturnValue({ useGradebookHeaderData.mockReturnValueOnce({ ...hookProps, areGradesFrozen: true });
areGradesFrozen: false, el = shallow(<GradebookHeader />);
canUserViewGradebook: true,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: false,
toggleViewMessage: messages.toActivityLog,
});
}); });
test('snapshot: show frozen warning', () => {
it('does not render toggle view button', () => { expect(el.snapshot).toMatchSnapshot();
render(<GradebookHeader />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
}); });
}); });
}); describe('user cannot view gradebook', () => {
describe('frozen grades warning', () => {
describe('when areGradesFrozen is true', () => {
beforeEach(() => { beforeEach(() => {
useGradebookHeaderData.mockReturnValue({ useGradebookHeaderData.mockReturnValueOnce({ ...hookProps, canUserViewGradebook: false });
areGradesFrozen: true, el = shallow(<GradebookHeader />);
canUserViewGradebook: true,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: false,
toggleViewMessage: messages.toActivityLog,
});
}); });
test('snapshot: show unauthorized warning', () => {
it('renders frozen warning alert', () => { expect(el.snapshot).toMatchSnapshot();
render(<GradebookHeader />);
const alert = screen.getByRole('alert');
expect(alert).toHaveClass('alert', 'alert-warning');
expect(alert).toHaveTextContent(
'The grades for this course are now frozen. Editing of grades is no longer allowed.',
);
}); });
}); });
describe('when areGradesFrozen is false', () => {
beforeEach(() => {
useGradebookHeaderData.mockReturnValue({
areGradesFrozen: false,
canUserViewGradebook: true,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: false,
toggleViewMessage: messages.toActivityLog,
});
});
it('does not render frozen warning alert', () => {
render(<GradebookHeader />);
expect(
screen.queryByText(
'The grades for this course are now frozen. Editing of grades is no longer allowed.',
),
).not.toBeInTheDocument();
});
});
});
describe('unauthorized warning', () => {
describe('when canUserViewGradebook is false', () => {
beforeEach(() => {
useGradebookHeaderData.mockReturnValue({
areGradesFrozen: false,
canUserViewGradebook: false,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: false,
toggleViewMessage: messages.toActivityLog,
});
});
it('renders unauthorized warning alert', () => {
render(<GradebookHeader />);
const alert = screen.getByRole('alert');
expect(alert).toHaveClass('alert', 'alert-warning');
expect(alert).toHaveTextContent(
'You are not authorized to view the gradebook for this course.',
);
});
});
describe('when canUserViewGradebook is true', () => {
beforeEach(() => {
useGradebookHeaderData.mockReturnValue({
areGradesFrozen: false,
canUserViewGradebook: true,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: false,
toggleViewMessage: messages.toActivityLog,
});
});
it('does not render unauthorized warning alert', () => {
render(<GradebookHeader />);
expect(
screen.queryByText(
'You are not authorized to view the gradebook for this course.',
),
).not.toBeInTheDocument();
});
});
});
describe('multiple warnings', () => {
it('renders both frozen and unauthorized warnings when both conditions are true', () => {
useGradebookHeaderData.mockReturnValue({
areGradesFrozen: true,
canUserViewGradebook: false,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: false,
toggleViewMessage: messages.toActivityLog,
});
render(<GradebookHeader />);
const alerts = screen.getAllByRole('alert');
expect(alerts).toHaveLength(2);
expect(
screen.getByText(
'The grades for this course are now frozen. Editing of grades is no longer allowed.',
),
).toBeInTheDocument();
expect(
screen.getByText(
'You are not authorized to view the gradebook for this course.',
),
).toBeInTheDocument();
});
});
describe('complete integration', () => {
it('renders all elements when showBulkManagement is true', () => {
useGradebookHeaderData.mockReturnValue({
areGradesFrozen: false,
canUserViewGradebook: true,
courseId: 'course-v1:TestU+CS101+2024',
handleToggleViewClick: mockHandleToggleViewClick,
showBulkManagement: true,
toggleViewMessage: messages.toActivityLog,
});
render(<GradebookHeader />);
expect(screen.getByRole('link')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
}); });
}); });

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

@@ -1,160 +1,32 @@
import React from 'react'; import React from 'react';
import { render, screen, initializeMocks } from 'testUtilsExtra'; import { shallow } from '@edx/react-unit-test-utils';
import NetworkButton from 'components/NetworkButton';
import ImportGradesButton from '../ImportGradesButton';
import { BulkManagementControls } from './index';
import useBulkManagementControlsData from './hooks'; import useBulkManagementControlsData from './hooks';
import messages from './messages'; import BulkManagementControls from '.';
jest.mock('../ImportGradesButton', () => 'ImportGradesButton');
jest.mock('components/NetworkButton', () => 'NetworkButton');
jest.mock('components/NetworkButton', () => jest.fn(() => <div data-testid="network-button">NetworkButton</div>));
jest.mock('../ImportGradesButton', () => jest.fn(() => (
<div data-testid="import-grades-button">ImportGradesButton</div>
)));
jest.mock('./hooks', () => jest.fn()); jest.mock('./hooks', () => jest.fn());
initializeMocks(); const hookProps = {
show: true,
handleClickExportGrades: jest.fn(),
};
useBulkManagementControlsData.mockReturnValue(hookProps);
describe('BulkManagementControls', () => { describe('BulkManagementControls', () => {
const mockHandleClickExportGrades = jest.fn(); describe('behavior', () => {
shallow(<BulkManagementControls />);
beforeEach(() => { expect(useBulkManagementControlsData).toHaveBeenCalledWith();
jest.clearAllMocks();
}); });
describe('render', () => {
describe('when show is false', () => { test('snapshot - show - network and import buttons', () => {
beforeEach(() => { expect(shallow(<BulkManagementControls />).snapshot).toMatchSnapshot();
useBulkManagementControlsData.mockReturnValue({
show: false,
handleClickExportGrades: mockHandleClickExportGrades,
});
}); });
test('snapshot - empty if show is not truthy', () => {
it('renders nothing when show is false', () => { useBulkManagementControlsData.mockReturnValueOnce({ ...hookProps, show: false });
render(<BulkManagementControls />); expect(shallow(<BulkManagementControls />).isEmptyRender()).toEqual(true);
expect(screen.queryByTestId('network-button')).not.toBeInTheDocument();
expect(
screen.queryByTestId('import-grades-button'),
).not.toBeInTheDocument();
});
it('does not render NetworkButton when show is false', () => {
render(<BulkManagementControls />);
expect(NetworkButton).not.toHaveBeenCalled();
});
it('does not render ImportGradesButton when show is false', () => {
render(<BulkManagementControls />);
expect(ImportGradesButton).not.toHaveBeenCalled();
});
});
describe('when show is true', () => {
beforeEach(() => {
useBulkManagementControlsData.mockReturnValue({
show: true,
handleClickExportGrades: mockHandleClickExportGrades,
});
});
it('renders the container div with correct class when show is true', () => {
render(<BulkManagementControls />);
const containerDiv = screen.getByTestId('network-button').parentElement;
expect(containerDiv).toHaveClass('d-flex');
});
it('renders NetworkButton with correct props', () => {
render(<BulkManagementControls />);
expect(NetworkButton).toHaveBeenCalledWith(
{
label: messages.downloadGradesBtn,
onClick: mockHandleClickExportGrades,
},
{},
);
expect(screen.getByTestId('network-button')).toBeInTheDocument();
});
it('renders ImportGradesButton', () => {
render(<BulkManagementControls />);
expect(ImportGradesButton).toHaveBeenCalledWith({}, {});
expect(screen.getByTestId('import-grades-button')).toBeInTheDocument();
});
it('calls handleClickExportGrades when NetworkButton is clicked', () => {
render(<BulkManagementControls />);
const networkButtonCall = NetworkButton.mock.calls[0][0];
const { onClick } = networkButtonCall;
onClick();
expect(mockHandleClickExportGrades).toHaveBeenCalledTimes(1);
});
it('passes correct label to NetworkButton', () => {
render(<BulkManagementControls />);
const networkButtonCall = NetworkButton.mock.calls[0][0];
expect(networkButtonCall.label).toBe(messages.downloadGradesBtn);
});
it('renders both buttons in the correct order', () => {
render(<BulkManagementControls />);
expect(NetworkButton).toHaveBeenCalled();
expect(ImportGradesButton).toHaveBeenCalled();
const networkButton = screen.getByTestId('network-button');
const importButton = screen.getByTestId('import-grades-button');
expect(networkButton).toBeInTheDocument();
expect(importButton).toBeInTheDocument();
});
});
describe('hook integration', () => {
it('calls useBulkManagementControlsData hook', () => {
useBulkManagementControlsData.mockReturnValue({
show: true,
handleClickExportGrades: mockHandleClickExportGrades,
});
render(<BulkManagementControls />);
expect(useBulkManagementControlsData).toHaveBeenCalledTimes(1);
});
it('uses the show value from hook to determine rendering', () => {
useBulkManagementControlsData.mockReturnValue({
show: false,
handleClickExportGrades: mockHandleClickExportGrades,
});
render(<BulkManagementControls />);
expect(screen.queryByTestId('network-button')).not.toBeInTheDocument();
useBulkManagementControlsData.mockReturnValue({
show: true,
handleClickExportGrades: mockHandleClickExportGrades,
});
render(<BulkManagementControls />);
expect(screen.getByTestId('network-button')).toBeInTheDocument();
});
it('passes handleClickExportGrades from hook to NetworkButton', () => {
const customHandler = jest.fn();
useBulkManagementControlsData.mockReturnValue({
show: true,
handleClickExportGrades: customHandler,
});
render(<BulkManagementControls />);
const networkButtonCall = NetworkButton.mock.calls[0][0];
expect(networkButtonCall.onClick).toBe(customHandler);
}); });
}); });
}); });

View File

@@ -1,100 +1,17 @@
import React from 'react'; import React from 'react';
import { render, screen, initializeMocks } from 'testUtilsExtra'; import { shallow } from '@edx/react-unit-test-utils';
import HistoryHeader from './HistoryHeader'; import HistoryHeader from './HistoryHeader';
initializeMocks();
describe('HistoryHeader', () => { describe('HistoryHeader', () => {
const defaultProps = { const props = {
id: 'test-id', id: 'water',
label: 'Test Label', label: 'Brita',
value: 'Test Value', value: 'hydration',
}; };
describe('Component', () => {
it('renders header with label and value', () => { test('snapshot', () => {
render(<HistoryHeader {...defaultProps} />); expect(shallow(<HistoryHeader {...props} />).snapshot).toMatchSnapshot();
});
expect(screen.getByText('Test Label:')).toBeInTheDocument();
expect(screen.getByText('Test Value')).toBeInTheDocument();
});
it('renders header element with correct classes', () => {
render(<HistoryHeader {...defaultProps} />);
const headerElement = screen.getByText('Test Label:');
expect(headerElement).toHaveClass('grade-history-header');
expect(headerElement).toHaveClass('grade-history-test-id');
});
it('renders with string value', () => {
const props = {
...defaultProps,
value: 'String Value',
};
render(<HistoryHeader {...props} />);
expect(screen.getByText('String Value')).toBeInTheDocument();
});
it('renders with number value', () => {
const props = {
...defaultProps,
value: 85,
};
render(<HistoryHeader {...props} />);
expect(screen.getByText('85')).toBeInTheDocument();
});
it('renders with null value (default prop)', () => {
const props = {
id: 'test-id',
label: 'Test Label',
};
render(<HistoryHeader {...props} />);
expect(screen.getByText('Test Label:')).toBeInTheDocument();
const valueDiv = screen.getByText('Test Label:').nextSibling;
expect(valueDiv).toBeInTheDocument();
expect(valueDiv).toBeEmptyDOMElement();
});
it('renders with React node as label', () => {
const props = {
...defaultProps,
label: <strong>Bold Label</strong>,
};
render(<HistoryHeader {...props} />);
const strongElement = screen.getByText('Bold Label');
expect(strongElement.tagName).toBe('STRONG');
});
it('generates correct class name based on id', () => {
const props = {
...defaultProps,
id: 'assignment-name',
};
render(<HistoryHeader {...props} />);
const headerElement = screen.getByText('Test Label:');
expect(headerElement).toHaveClass('grade-history-assignment-name');
});
it('renders container structure correctly', () => {
render(<HistoryHeader {...defaultProps} />);
const headerElement = screen.getByText('Test Label:');
const valueElement = screen.getByText('Test Value');
expect(headerElement).toBeInTheDocument();
expect(valueElement).toBeInTheDocument();
expect(headerElement).toHaveClass(
'grade-history-header',
'grade-history-test-id',
);
}); });
}); });

View File

@@ -1,8 +1,16 @@
import React from 'react'; import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n';
import { selectors } from 'data/redux/hooks'; import { selectors } from 'data/redux/hooks';
import { render, screen, initializeMocks } from 'testUtilsExtra'; import { formatMessage } from 'testUtils';
import ModalHeaders from './ModalHeaders';
import HistoryHeader from './HistoryHeader';
import ModalHeaders, { HistoryKeys } from './ModalHeaders';
import messages from './messages';
jest.mock('./HistoryHeader', () => 'HistoryHeader');
jest.mock('data/redux/hooks', () => ({ jest.mock('data/redux/hooks', () => ({
selectors: { selectors: {
@@ -21,25 +29,57 @@ const gradeData = {
gradeOriginalEarnedGraded: 'test-original-grade', gradeOriginalEarnedGraded: 'test-original-grade',
}; };
selectors.grades.useGradeData.mockReturnValue(gradeData); selectors.grades.useGradeData.mockReturnValue(gradeData);
initializeMocks();
let el;
describe('ModalHeaders', () => { describe('ModalHeaders', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
render(<ModalHeaders />); 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('render', () => {
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
});
test('assignment header', () => { test('assignment header', () => {
expect(screen.getByText(modalData.assignmentName)).toBeInTheDocument(); const headerProps = el.instance.findByType(HistoryHeader)[0].props;
expect(headerProps).toMatchObject({
id: HistoryKeys.assignment,
label: formatMessage(messages.assignmentHeader),
value: modalData.assignmentName,
});
}); });
test('student header', () => { test('student header', () => {
expect(screen.getByText(modalData.updateUserName)).toBeInTheDocument(); const headerProps = el.instance.findByType(HistoryHeader)[1].props;
expect(headerProps).toMatchObject({
id: HistoryKeys.student,
label: formatMessage(messages.studentHeader),
value: modalData.updateUserName,
});
}); });
test('originalGrade header', () => { test('originalGrade header', () => {
expect(screen.getByText(gradeData.gradeOriginalEarnedGraded)).toBeInTheDocument(); const headerProps = el.instance.findByType(HistoryHeader)[2].props;
expect(headerProps).toMatchObject({
id: HistoryKeys.originalGrade,
label: formatMessage(messages.originalGradeHeader),
value: gradeData.gradeOriginalEarnedGraded,
});
}); });
test('currentGrade header', () => { test('currentGrade header', () => {
expect(screen.getByText(gradeData.gradeOverrideCurrentEarnedGradedOverride)).toBeInTheDocument(); const headerProps = el.instance.findByType(HistoryHeader)[3].props;
expect(headerProps).toMatchObject({
id: HistoryKeys.currentGrade,
label: formatMessage(messages.currentGradeHeader),
value: gradeData.gradeOverrideCurrentEarnedGradedOverride,
});
}); });
}); });
}); });

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="number"
value="test-value"
/>
some-hint-text
</span>
`;

View File

@@ -15,6 +15,7 @@ const useAdjustedGradeInputData = () => {
value, value,
onChange, onChange,
hintText, hintText,
possibleGrade,
}; };
}; };

View File

@@ -2,7 +2,9 @@ import React from 'react';
import { Form } from '@openedx/paragon'; import { Form } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import useAdjustedGradeInputData from './hooks'; import useAdjustedGradeInputData from './hooks';
import messages from '../messages';
/** /**
* <AdjustedGradeInput /> * <AdjustedGradeInput />
@@ -14,16 +16,18 @@ export const AdjustedGradeInput = () => {
value, value,
onChange, onChange,
hintText, hintText,
possibleGrade,
} = useAdjustedGradeInputData(); } = useAdjustedGradeInputData();
const { formatMessage } = useIntl();
return ( return (
<span> <span>
<Form.Control <Form.Control
type="text" type="number"
name="adjustedGradeValue" name="adjustedGradeValue"
value={value} value={value}
onChange={onChange} onChange={onChange}
/> />
{hintText} {value > possibleGrade ? <div style={{ color: 'red' }}>{ formatMessage(messages.adjustedGradeError, { possibleGrade })}</div> : hintText}
</span> </span>
); );
}; };

View File

@@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { shallow } from '@edx/react-unit-test-utils';
import { Form } from '@openedx/paragon';
import useAdjustedGradeInputData from './hooks'; import useAdjustedGradeInputData from './hooks';
import AdjustedGradeInput from '.'; import AdjustedGradeInput from '.';
@@ -13,17 +15,24 @@ const hookProps = {
}; };
useAdjustedGradeInputData.mockReturnValue(hookProps); useAdjustedGradeInputData.mockReturnValue(hookProps);
let el;
describe('AdjustedGradeInput component', () => { describe('AdjustedGradeInput component', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
render(<AdjustedGradeInput />); el = shallow(<AdjustedGradeInput />);
});
describe('behavior', () => {
it('initializes hook data', () => {
expect(useAdjustedGradeInputData).toHaveBeenCalled();
});
}); });
describe('render', () => { describe('render', () => {
test('renders input with correct props', () => { test('snapshot', () => {
const input = screen.getByRole('textbox'); expect(el.snapshot).toMatchSnapshot();
expect(input).toBeInTheDocument(); const control = el.instance.findByType(Form.Control)[0];
expect(input).toHaveValue(hookProps.value); expect(control.props.value).toEqual(hookProps.value);
expect(screen.getByText(hookProps.hintText)).toBeInTheDocument(); expect(control.props.onChange).toEqual(hookProps.onChange);
expect(el.instance.children[1].el).toContain(hookProps.hintText);
}); });
}); });
}); });

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

@@ -16,12 +16,6 @@ jest.mock('data/redux/hooks', () => ({
}, },
})); }));
jest.mock('react', () => ({
...jest.requireActual('react'),
useRef: jest.fn((val) => ({ current: val, useRef: true })),
useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })),
}));
const modalData = { reasonForChange: 'test-reason-for-change' }; const modalData = { reasonForChange: 'test-reason-for-change' };
const setModalState = jest.fn(); const setModalState = jest.fn();
selectors.app.useModalData.mockReturnValue(modalData); selectors.app.useModalData.mockReturnValue(modalData);
@@ -31,7 +25,6 @@ const ref = { current: { focus: jest.fn() }, useRef: true };
React.useRef.mockReturnValue(ref); React.useRef.mockReturnValue(ref);
let out; let out;
describe('useReasonInputData hook', () => { describe('useReasonInputData hook', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();

View File

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

View File

@@ -4,6 +4,8 @@ import { render } from '@testing-library/react';
import useReasonInputData from './hooks'; import useReasonInputData from './hooks';
import ReasonInput, { controlTestId } from '.'; import ReasonInput, { controlTestId } from '.';
jest.unmock('react');
jest.unmock('@openedx/paragon');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() })); jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
const focus = jest.fn(); const focus = jest.fn();

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,4 +1,5 @@
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { gradeOverrideHistoryColumns as columns } from 'data/constants/app'; import { gradeOverrideHistoryColumns as columns } from 'data/constants/app';
import { selectors } from 'data/redux/hooks'; import { selectors } from 'data/redux/hooks';
@@ -15,18 +16,6 @@ jest.mock('data/redux/hooks', () => ({
}, },
})); }));
jest.mock('react', () => ({
...jest.requireActual('react'),
useContext: jest.fn(context => context),
}));
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: jest.fn(() => ({
formatMessage: (message) => message.defaultMessage,
})),
}));
selectors.grades.useHasOverrideErrors.mockReturnValue(false); selectors.grades.useHasOverrideErrors.mockReturnValue(false);
const gradeOverrides = ['some', 'override', 'data']; const gradeOverrides = ['some', 'override', 'data'];
const gradeData = { gradeOverrideHistoryResults: gradeOverrides }; const gradeData = { gradeOverrideHistoryResults: gradeOverrides };
@@ -55,22 +44,22 @@ describe('useOverrideTableData', () => {
describe('columns', () => { describe('columns', () => {
test('date column', () => { test('date column', () => {
const { Header, accessor } = out.columns[0]; const { Header, accessor } = out.columns[0];
expect(Header).toEqual(messages.dateHeader.defaultMessage); expect(Header).toEqual(formatMessage(messages.dateHeader));
expect(accessor).toEqual(columns.date); expect(accessor).toEqual(columns.date);
}); });
test('grader column', () => { test('grader column', () => {
const { Header, accessor } = out.columns[1]; const { Header, accessor } = out.columns[1];
expect(Header).toEqual(messages.graderHeader.defaultMessage); expect(Header).toEqual(formatMessage(messages.graderHeader));
expect(accessor).toEqual(columns.grader); expect(accessor).toEqual(columns.grader);
}); });
test('reason column', () => { test('reason column', () => {
const { Header, accessor } = out.columns[2]; const { Header, accessor } = out.columns[2];
expect(Header).toEqual(messages.reasonHeader.defaultMessage); expect(Header).toEqual(formatMessage(messages.reasonHeader));
expect(accessor).toEqual(columns.reason); expect(accessor).toEqual(columns.reason);
}); });
test('adjustedGrade column', () => { test('adjustedGrade column', () => {
const { Header, accessor } = out.columns[3]; const { Header, accessor } = out.columns[3];
expect(Header).toEqual(messages.adjustedGradeHeader.defaultMessage); expect(Header).toEqual(formatMessage(messages.adjustedGradeHeader));
expect(accessor).toEqual(columns.adjustedGrade); expect(accessor).toEqual(columns.adjustedGrade);
}); });
}); });

View File

@@ -1,61 +1,65 @@
import React from 'react'; import React from 'react';
import { screen } from '@testing-library/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 useOverrideTableData from './hooks';
import OverrideTable from '.'; import OverrideTable from '.';
import { renderWithIntl } from '../../../../testUtilsExtra';
jest.mock('utils', () => ({ jest.mock('utils', () => ({
...jest.requireActual('utils'),
formatDateForDisplay: (date) => ({ formatted: date }), formatDateForDisplay: (date) => ({ formatted: date }),
})); }));
jest.mock('./hooks', () => jest.fn()); jest.mock('./hooks', () => jest.fn());
jest.mock('./AdjustedGradeInput', () => 'AdjustedGradeInput');
jest.mock('./ReasonInput', () => 'ReasonInput');
const hookProps = { const hookProps = {
hide: false, hide: false,
data: [ data: [
{ filename: 'data' }, { test: 'data' },
{ resultsSummary: 'test-data' }, { andOther: 'test-data' },
], ],
columns: [{ columns: 'test-columns',
Header: 'Gradebook',
accessor: 'filename',
},
{
Header: 'Download Summary',
accessor: 'resultsSummary',
}],
}; };
useOverrideTableData.mockReturnValue(hookProps);
let el;
describe('OverrideTable component', () => { describe('OverrideTable component', () => {
beforeEach(() => { beforeEach(() => {
jest jest
.clearAllMocks() .clearAllMocks()
.useFakeTimers('modern') .useFakeTimers('modern')
.setSystemTime(new Date('2000-01-01').getTime()); .setSystemTime(new Date('2000-01-01').getTime());
el = shallow(<OverrideTable />);
}); });
describe('hooks', () => { describe('behavior', () => {
it('initializes hook data', () => { it('initializes hook data', () => {
useOverrideTableData.mockReturnValue(hookProps);
renderWithIntl(<OverrideTable />);
expect(useOverrideTableData).toHaveBeenCalled(); expect(useOverrideTableData).toHaveBeenCalled();
}); });
}); });
describe('behavior', () => { describe('render', () => {
it('null render if hide', () => { test('null render if hide', () => {
useOverrideTableData.mockReturnValue({ ...hookProps, hide: true }); useOverrideTableData.mockReturnValueOnce({ ...hookProps, hide: true });
renderWithIntl(<OverrideTable />); el = shallow(<OverrideTable />);
expect(screen.queryByRole('table')).toBeNull(); expect(el.isEmptyRender()).toEqual(true);
}); });
it('renders table with correct data', () => { test('snapshot', () => {
useOverrideTableData.mockReturnValue(hookProps); expect(el.snapshot).toMatchSnapshot();
renderWithIntl(<OverrideTable />); const table = el.instance.findByType(DataTable)[0];
const table = screen.getByRole('table'); expect(table.props.columns).toEqual(hookProps.columns);
expect(table).toBeInTheDocument(); const data = [...table.props.data];
expect(screen.getByText(hookProps.columns[0].Header)).toBeInTheDocument(); const inputRow = data.pop();
expect(screen.getByText(hookProps.columns[1].Header)).toBeInTheDocument(); const formattedDate = formatDateForDisplay(new Date());
expect(screen.getByText(hookProps.data[0].filename)).toBeInTheDocument(); expect(data).toEqual(hookProps.data);
expect(screen.getByText(hookProps.data[1].resultsSummary)).toBeInTheDocument(); expect(inputRow).toMatchObject({
adjustedGrade: <AdjustedGradeInput />,
date: formattedDate,
reason: <ReasonInput />,
});
}); });
}); });
}); });

View File

@@ -21,6 +21,11 @@ const messages = defineMessages({
defaultMessage: 'Reason', defaultMessage: 'Reason',
description: 'Edit Modal Override Table Reason column header', description: 'Edit Modal Override Table Reason column header',
}, },
adjustedGradeError: {
id: 'gradebook.GradesView.EditModal.Overrides.adjustedGradeError',
defaultMessage: 'The value exceeds the maximum grade: {possibleGrade}',
description: 'Edit Modal Override Adjusted Grade Error',
},
}); });
export default messages; export default messages;

View File

@@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HistoryHeader Component snapshot 1`] = `
<div>
<div
className="grade-history-header grade-history-water"
>
Brita
:
</div>
<div>
hydration
</div>
</div>
`;

View File

@@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ModalHeaders render snapshot 1`] = `
<div>
<HistoryHeader
id="assignment"
label="Assignment"
value="test-assignment-name"
/>
<HistoryHeader
id="student"
label="Student"
value="test-user-name"
/>
<HistoryHeader
id="original-grade"
label="Original Grade"
value="test-original-grade"
/>
<HistoryHeader
id="current-grade"
label="Current Grade"
value="test-current-grade"
/>
</div>
`;

View File

@@ -0,0 +1,93 @@
// 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
disabled={false}
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
disabled={false}
onClick={[MockFunction hooks.handleAdjustedGradeClick]}
variant="primary"
>
Save Grades
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
`;

View File

@@ -12,6 +12,7 @@ import OverrideTable from './OverrideTable';
import ModalHeaders from './ModalHeaders'; import ModalHeaders from './ModalHeaders';
import useEditModalData from './hooks'; import useEditModalData from './hooks';
import messages from './messages'; import messages from './messages';
import useAdjustedGradeInputData from './OverrideTable/AdjustedGradeInput/hooks';
/** /**
* <EditModal /> * <EditModal />
@@ -30,6 +31,10 @@ export const EditModal = () => {
handleAdjustedGradeClick, handleAdjustedGradeClick,
isOpen, isOpen,
} = useEditModalData(); } = useEditModalData();
const {
value,
possibleGrade,
} = useAdjustedGradeInputData();
return ( return (
<ModalDialog <ModalDialog
@@ -57,7 +62,7 @@ export const EditModal = () => {
<ModalDialog.CloseButton variant="tertiary"> <ModalDialog.CloseButton variant="tertiary">
{formatMessage(messages.closeText)} {formatMessage(messages.closeText)}
</ModalDialog.CloseButton> </ModalDialog.CloseButton>
<Button variant="primary" onClick={handleAdjustedGradeClick}> <Button variant="primary" onClick={handleAdjustedGradeClick} disabled={value > possibleGrade}>
{formatMessage(messages.saveGrade)} {formatMessage(messages.saveGrade)}
</Button> </Button>
</ActionRow> </ActionRow>

View File

@@ -1,104 +1,149 @@
import { screen } from '@testing-library/react'; import React from 'react';
import userEvent from '@testing-library/user-event'; import { shallow } from '@edx/react-unit-test-utils';
import {
ActionRow,
ModalDialog,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import ModalHeaders from './ModalHeaders';
import OverrideTable from './OverrideTable';
import useEditModalData from './hooks'; import useEditModalData from './hooks';
import EditModal from '.'; import EditModal from '.';
import messages from './messages'; import messages from './messages';
import { renderWithIntl } from '../../../testUtilsExtra'; import useAdjustedGradeInputData from './OverrideTable/AdjustedGradeInput/hooks';
jest.mock('./hooks', () => jest.fn()); jest.mock('./hooks', () => jest.fn());
jest.mock('./ModalHeaders', () => jest.fn(() => <div>ModalHeaders</div>)); jest.mock('./ModalHeaders', () => 'ModalHeaders');
jest.mock('./OverrideTable', () => jest.fn(() => <div>OverrideTable</div>)); jest.mock('./OverrideTable', () => 'OverrideTable');
jest.mock('./OverrideTable/AdjustedGradeInput/hooks', () => jest.fn());
const hookProps = { const hookProps = {
onClose: jest.fn().mockName('hooks.onClose'), onClose: jest.fn().mockName('hooks.onClose'),
error: 'test-error', error: 'test-error',
handleAdjustedGradeClick: jest.fn().mockName('hooks.handleAdjustedGradeClick'), handleAdjustedGradeClick: jest.fn().mockName('hooks.handleAdjustedGradeClick'),
isOpen: true, isOpen: 'test-is-open',
}; };
useEditModalData.mockReturnValue(hookProps);
const adjustedGradeProps = {
value: 50,
possibleGrade: 100,
};
useAdjustedGradeInputData.mockReturnValue(adjustedGradeProps);
let el;
describe('EditModal component', () => { describe('EditModal component', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
el = shallow(<EditModal />);
}); });
describe('behavior', () => { describe('behavior', () => {
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalled();
});
it('initializes component hooks', () => { it('initializes component hooks', () => {
useEditModalData.mockReturnValue(hookProps);
renderWithIntl(<EditModal />);
expect(useEditModalData).toHaveBeenCalled(); expect(useEditModalData).toHaveBeenCalled();
expect(useAdjustedGradeInputData).toHaveBeenCalled();
}); });
}); });
describe('renders', () => { describe('render', () => {
const testModal = () => { test('modal props', () => {
it('modal properly', () => { const modalProps = el.instance.findByType(ModalDialog)[0].props;
const modal = screen.getByRole('dialog', { title: messages.title.defaultMessage }); expect(modalProps.title).toEqual(formatMessage(messages.title));
expect(modal).toBeInTheDocument(); expect(modalProps.isOpen).toEqual(hookProps.isOpen);
}); expect(modalProps.onClose).toEqual(hookProps.onClose);
it('triggers onClose when closed', async () => { });
const user = userEvent.setup(); const loadBody = () => {
const closeButton = screen.getByRole('button', { name: messages.closeText.defaultMessage }); const body = el.instance.findByType(ModalDialog)[0].children[0];
await user.click(closeButton); const { children } = body.children[0];
expect(hookProps.onClose).toHaveBeenCalled(); return { body, children };
});
}; };
const testBody = () => { const testBody = () => {
it('headers row', () => { test('type', () => {
const headers = screen.getByText('ModalHeaders'); const { body } = loadBody();
expect(headers).toBeInTheDocument(); expect(body.type).toEqual('ModalDialog.Body');
}); });
it('table row', () => { test('headers row', () => {
const table = screen.getByText('OverrideTable'); const { children } = loadBody();
expect(table).toBeInTheDocument(); expect(children[0]).toMatchObject(shallow(<ModalHeaders />));
}); });
it('messages', () => { test('table row', () => {
const visibilityMessage = screen.getByText(messages.visibility.defaultMessage); const { children } = loadBody();
const saveVisibilityMessage = screen.getByText(messages.saveVisibility.defaultMessage); expect(children[2]).toMatchObject(shallow(<OverrideTable />));
expect(visibilityMessage).toBeInTheDocument(); });
expect(saveVisibilityMessage).toBeInTheDocument(); test('messages', () => {
const { children } = loadBody();
expect(children[3].children[0].el).toEqual(formatMessage(messages.visibility));
expect(children[4].children[0].el).toEqual(formatMessage(messages.saveVisibility));
}); });
}; };
const testFooter = () => { const testFooter = () => {
it('adjusted grade button', async () => { let footer;
const user = userEvent.setup(); beforeEach(() => {
const saveGradeButton = screen.getByRole('button', { name: messages.saveGrade.defaultMessage }); footer = el.instance.findByType(ModalDialog)[0].children;
expect(saveGradeButton).toBeInTheDocument();
await user.click(saveGradeButton);
expect(hookProps.handleAdjustedGradeClick).toHaveBeenCalled();
}); });
it('close button', async () => { test('type', () => {
const user = userEvent.setup(); expect(footer[1].type).toEqual('ModalDialog.Footer');
const cancelButton = screen.getByRole('button', { name: messages.closeText.defaultMessage }); });
expect(cancelButton).toBeInTheDocument(); test('contains action row', () => {
await user.click(cancelButton); expect(footer[1].children[0].type).toEqual('ActionRow');
expect(hookProps.onClose).toHaveBeenCalled(); });
test('close button', () => {
const button = footer[1].findByType(ActionRow)[0].children[0];
expect(button.children[0].el).toEqual(formatMessage(messages.closeText));
expect(button.type).toEqual('ModalDialog.CloseButton');
});
test('adjusted grade button enabled', () => {
const button = footer[1].findByType(ActionRow)[0].children[1];
expect(button.children[0].el).toEqual(formatMessage(messages.saveGrade));
expect(button.type).toEqual('Button');
expect(button.props.onClick).toEqual(hookProps.handleAdjustedGradeClick);
expect(button.props.disabled).toEqual(false);
}); });
}; };
describe('without error', () => { describe('without error', () => {
beforeEach(() => { beforeEach(() => {
useEditModalData.mockReturnValueOnce({ ...hookProps, error: undefined }); useEditModalData.mockReturnValueOnce({ ...hookProps, error: undefined });
renderWithIntl(<EditModal />); useAdjustedGradeInputData.mockReturnValueOnce({ value: 50, possibleGrade: 100 });
el = shallow(<EditModal />);
});
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
}); });
testModal();
testBody(); testBody();
testFooter(); testFooter();
test('alert row', () => { test('alert row', () => {
const alert = screen.queryByRole('alert'); const alert = loadBody().children[1];
expect(alert).toBeNull(); expect(alert.type).toEqual('Alert');
expect(alert.props.show).toEqual(false);
}); });
}); });
describe('with error', () => { describe('with error', () => {
beforeEach(() => { test('snapshot', () => {
useEditModalData.mockReturnValue(hookProps); expect(el.snapshot).toMatchSnapshot();
renderWithIntl(<EditModal />);
}); });
testModal();
testBody(); testBody();
test('alert row', () => { test('alert row', () => {
const alert = screen.getByRole('alert'); const alert = loadBody().children[1];
expect(alert).toBeInTheDocument(); expect(alert.type).toEqual('Alert');
expect(alert).toHaveTextContent(hookProps.error); expect(alert.props.show).toEqual(true);
expect(alert.children[0].el).toEqual(hookProps.error);
}); });
testFooter(); testFooter();
}); });
describe('when the adjusted grade button is disabled', () => {
beforeEach(() => {
useAdjustedGradeInputData.mockReturnValueOnce({ value: 101, possibleGrade: 100 });
el = shallow(<EditModal />);
});
test('adjusted grade button is disabled', () => {
const button = el.instance.findByType(ActionRow)[0].children[1];
expect(button.props.disabled).toEqual(true);
});
});
}); });
}); });

View File

@@ -1,12 +1,15 @@
import React from 'react'; import React from 'react';
import { screen } from '@testing-library/react'; import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils'; import { formatMessage } from 'testUtils';
import { Button } from '@openedx/paragon';
import { selectors } from 'data/redux/hooks'; import { selectors } from 'data/redux/hooks';
import userEvent from '@testing-library/user-event';
import FilterBadge from './FilterBadge'; import FilterBadge from './FilterBadge';
import { renderWithIntl } from '../../../testUtilsExtra';
jest.mock('@openedx/paragon', () => ({
Button: () => 'Button',
}));
jest.mock('data/redux/hooks', () => ({ jest.mock('data/redux/hooks', () => ({
selectors: { selectors: {
root: { root: {
@@ -15,12 +18,11 @@ jest.mock('data/redux/hooks', () => ({
}, },
})); }));
const handleClose = jest.fn(); const handleClose = jest.fn(filters => ({ handleClose: filters }));
const filterName = 'test-filter-name'; const filterName = 'test-filter-name';
const hookProps = { const hookProps = {
displayName: { displayName: {
id: 'test.id',
defaultMessage: 'a common name', defaultMessage: 'a common name',
}, },
isDefault: false, isDefault: false,
@@ -30,52 +32,63 @@ const hookProps = {
}; };
selectors.root.useFilterBadgeConfig.mockReturnValue(hookProps); selectors.root.useFilterBadgeConfig.mockReturnValue(hookProps);
let el;
describe('FilterBadge', () => { describe('FilterBadge', () => {
describe('hooks', () => { beforeEach(() => {
beforeEach(() => { el = shallow(<FilterBadge {...{ handleClose, filterName }} />);
renderWithIntl(<FilterBadge {...{ handleClose, filterName }} />); });
describe('behavior', () => {
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalled();
}); });
it('initializes redux hooks', () => { it('initializes redux hooks', () => {
expect(selectors.root.useFilterBadgeConfig).toHaveBeenCalledWith(filterName); expect(selectors.root.useFilterBadgeConfig).toHaveBeenCalledWith(filterName);
}); });
}); });
describe('render', () => { describe('render', () => {
it('empty render if isDefault', () => { const testDisplayName = () => {
test('formatted display name appears on badge', () => {
expect(el.instance.findByTestId('display-name')[0].children[0].el).toEqual(formatMessage(hookProps.displayName));
});
};
const testCloseButton = () => {
test('close button forwards close method', () => {
expect(el.instance.findByType(Button)[0].props.onClick).toEqual(handleClose(hookProps.connectedFilters));
});
};
test('empty render if isDefault', () => {
selectors.root.useFilterBadgeConfig.mockReturnValueOnce({ selectors.root.useFilterBadgeConfig.mockReturnValueOnce({
...hookProps, ...hookProps,
isDefault: true, isDefault: true,
}); });
renderWithIntl(<FilterBadge {...{ handleClose, filterName }} />); el = shallow(<FilterBadge {...{ handleClose, filterName }} />);
expect(screen.queryByText(hookProps.displayName)).toBeNull(); expect(el.isEmptyRender()).toEqual(true);
}); });
describe('hide Value', () => { describe('hide Value', () => {
it('renders display name, value is not shown and close button has correct behavior', async () => { beforeEach(() => {
selectors.root.useFilterBadgeConfig.mockReturnValueOnce({ selectors.root.useFilterBadgeConfig.mockReturnValueOnce({
...hookProps, ...hookProps,
hideValue: true, hideValue: true,
}); });
renderWithIntl(<FilterBadge {...{ handleClose, filterName }} />); el = shallow(<FilterBadge {...{ handleClose, filterName }} />);
const user = userEvent.setup(); });
expect(screen.getByTestId('display-name')).toHaveTextContent(formatMessage(hookProps.displayName)); testDisplayName();
expect(screen.queryByTestId('filter-value')).toHaveTextContent(''); testCloseButton();
const button = screen.getByRole('button', { name: /close/i }); test('snapshot', () => {
await user.click(button); expect(el.snapshot).toMatchSnapshot();
expect(handleClose).toHaveBeenCalledWith(hookProps.connectedFilters); });
test('value is note present in the badge', () => {
expect(el.instance.findByTestId('filter-value')[0].children).toHaveLength(0);
}); });
}); });
describe('do not hide value', () => { describe('do not hide value', () => {
it('renders display name and value, and close button has correct behavior', async () => { testDisplayName();
selectors.root.useFilterBadgeConfig.mockReturnValueOnce({ testCloseButton();
...hookProps, test('snapshot', () => {
hideValue: false, expect(el.snapshot).toMatchSnapshot();
}); });
renderWithIntl(<FilterBadge {...{ handleClose, filterName }} />); test('value is present in the badge', () => {
const user = userEvent.setup(); expect(el.instance.findByTestId('filter-value')[0].children[0].el).toBe(`: ${hookProps.value}`);
expect(screen.getByTestId('display-name')).toHaveTextContent(formatMessage(hookProps.displayName));
expect(screen.getByTestId('filter-value')).toHaveTextContent(`: ${hookProps.value}`);
const button = screen.getByRole('button', { name: /close/i });
await user.click(button);
expect(handleClose).toHaveBeenCalledWith(hookProps.connectedFilters);
}); });
}); });
}); });

View File

@@ -0,0 +1,75 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FilterBadge render do not hide value snapshot 1`] = `
<div>
<span
className="badge badge-info"
>
<span
data-testid="display-name"
>
a common name
</span>
<span
data-testid="filter-value"
>
: a common value
</span>
<Button
aria-label="close"
className="btn-info"
onClick={
{
"handleClose": [
"some",
"filters",
],
}
}
>
<span
aria-hidden="true"
>
×
</span>
</Button>
</span>
<br />
</div>
`;
exports[`FilterBadge render hide Value snapshot 1`] = `
<div>
<span
className="badge badge-info"
>
<span
data-testid="display-name"
>
a common name
</span>
<span
data-testid="filter-value"
/>
<Button
aria-label="close"
className="btn-info"
onClick={
{
"handleClose": [
"some",
"filters",
],
}
}
>
<span
aria-hidden="true"
>
×
</span>
</Button>
</span>
<br />
</div>
`;

View File

@@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FilterBadges component snapshot - has a filterbadge with handleClose for each filter in badgeOrder 1`] = `
<div>
<FilterBadge
filterName="filter1"
handleClose={[MockFunction this.props.handleClose]}
key="filter1"
/>
<FilterBadge
filterName="filter2"
handleClose={[MockFunction this.props.handleClose]}
key="filter2"
/>
<FilterBadge
filterName="filter3"
handleClose={[MockFunction this.props.handleClose]}
key="filter3"
/>
</div>
`;

View File

@@ -1,10 +1,11 @@
/* eslint-disable import/no-extraneous-dependencies */
/* eslint-disable import/no-named-as-default */ /* eslint-disable import/no-named-as-default */
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { shallow } from '@edx/react-unit-test-utils';
import userEvent from '@testing-library/user-event';
import FilterBadges from '.'; import FilterBadges from '.';
import FilterBadge from './FilterBadge';
jest.mock('./FilterBadge', () => 'FilterBadge');
const order = ['filter1', 'filter2', 'filter3']; const order = ['filter1', 'filter2', 'filter3'];
jest.mock('data/constants/filters', () => ({ jest.mock('data/constants/filters', () => ({
@@ -12,24 +13,24 @@ jest.mock('data/constants/filters', () => ({
badgeOrder: order, badgeOrder: order,
})); }));
// eslint-disable-next-line react/button-has-type
jest.mock('./FilterBadge', () => jest.fn(({ filterName, handleClose }) => <button onClick={handleClose}>{filterName}</button>));
const handleClose = jest.fn();
describe('FilterBadges', () => { describe('FilterBadges', () => {
describe('component', () => { describe('component', () => {
it('has a filterbadge with handleClose for each filter in badgeOrder', async () => { let el;
render(<FilterBadges handleClose={handleClose} />); let handleClose;
const user = userEvent.setup(); beforeEach(() => {
const badge1 = screen.getByText(order[0]); handleClose = jest.fn().mockName('this.props.handleClose');
const badge2 = screen.getByText(order[1]); el = shallow(<FilterBadges handleClose={handleClose} />);
const badge3 = screen.getByText(order[2]); });
expect(badge1).toBeInTheDocument(); test('snapshot - has a filterbadge with handleClose for each filter in badgeOrder', () => {
expect(badge2).toBeInTheDocument(); expect(el.snapshot).toMatchSnapshot();
expect(badge3).toBeInTheDocument(); });
await user.click(badge1); test('has a filterbadge with handleClose for each filter in badgeOrder', () => {
expect(handleClose).toHaveBeenCalled(); const badgeProps = el.instance.findByType(FilterBadge).map(badgeEl => badgeEl.props);
// key prop is not rendered by react
expect(badgeProps[0]).toMatchObject({ filterName: order[0], handleClose });
expect(badgeProps[1]).toMatchObject({ filterName: order[1], handleClose });
expect(badgeProps[2]).toMatchObject({ filterName: order[2], handleClose });
expect(badgeProps.length).toEqual(3);
}); });
}); });
}); });

View File

@@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FilterMenuToggle component render snapshot 1`] = `
<Button
className="btn-primary align-self-start"
id="edit-filters-btn"
onClick={[MockFunction hooks.toggleFilterMenu]}
>
<Icon
className="mr-1"
src="FilterAlt"
/>
Edit Filters
</Button>
`;

View File

@@ -1,11 +1,13 @@
import { screen } from '@testing-library/react'; import React from 'react';
import userEvent from '@testing-library/user-event'; import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils'; import { formatMessage } from 'testUtils';
import { thunkActions } from 'data/redux/hooks'; import { thunkActions } from 'data/redux/hooks';
import FilterMenuToggle from '.'; import FilterMenuToggle from '.';
import messages from './messages'; import messages from './messages';
import { renderWithIntl } from '../../../testUtilsExtra';
jest.mock('data/redux/hooks', () => ({ jest.mock('data/redux/hooks', () => ({
thunkActions: { thunkActions: {
@@ -20,23 +22,26 @@ jest.mock('data/redux/hooks', () => ({
const toggleFilterMenu = jest.fn().mockName('hooks.toggleFilterMenu'); const toggleFilterMenu = jest.fn().mockName('hooks.toggleFilterMenu');
thunkActions.app.filterMenu.useToggleMenu.mockReturnValue(toggleFilterMenu); thunkActions.app.filterMenu.useToggleMenu.mockReturnValue(toggleFilterMenu);
let el;
describe('FilterMenuToggle component', () => { describe('FilterMenuToggle component', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
renderWithIntl(<FilterMenuToggle />); el = shallow(<FilterMenuToggle />);
}); });
describe('behavior', () => { describe('behavior', () => {
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalled();
});
it('initializes redux hooks', () => { it('initializes redux hooks', () => {
expect(thunkActions.app.filterMenu.useToggleMenu).toHaveBeenCalled(); expect(thunkActions.app.filterMenu.useToggleMenu).toHaveBeenCalled();
}); });
}); });
describe('renders', () => { describe('render', () => {
it('button and triggers click', async () => { test('snapshot', () => {
const user = userEvent.setup(); expect(el.snapshot).toMatchSnapshot();
const button = screen.getByRole('button', { name: formatMessage(messages.editFilters) }); expect(el.instance.type).toEqual('Button');
expect(button).toBeInTheDocument(); expect(el.instance.props.onClick).toEqual(toggleFilterMenu);
await user.click(button); expect(el.instance.children[2].el).toContain(formatMessage(messages.editFilters));
expect(toggleFilterMenu).toHaveBeenCalled();
}); });
}); });
}); });

View File

@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FilteredUsersLabel component render snapshot 1`] = `
<format-message-function
message={
{
"defaultMessage": "Showing {filteredUsers} of {totalUsers} total learners",
"description": "Users visibility label",
"id": "gradebook.GradesTab.usersVisibilityLabel",
}
}
values={
{
"filteredUsers": <BoldText
text={100}
/>,
"totalUsers": <BoldText
text={123}
/>,
}
}
/>
`;

View File

@@ -1,9 +1,13 @@
import { screen } from '@testing-library/react'; import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { selectors } from 'data/redux/hooks'; import { selectors } from 'data/redux/hooks';
import FilteredUsersLabel from '.'; import FilteredUsersLabel, { BoldText } from '.';
import { renderWithIntl } from '../../../testUtilsExtra'; import messages from './messages';
jest.mock('data/redux/hooks', () => ({ jest.mock('data/redux/hooks', () => ({
selectors: { selectors: {
@@ -19,29 +23,34 @@ const userCounts = {
}; };
selectors.grades.useUserCounts.mockReturnValue(userCounts); selectors.grades.useUserCounts.mockReturnValue(userCounts);
let el;
describe('FilteredUsersLabel component', () => { describe('FilteredUsersLabel component', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
el = shallow(<FilteredUsersLabel />);
}); });
describe('behavior', () => { describe('behavior', () => {
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalled();
});
it('initializes redux hooks', () => { it('initializes redux hooks', () => {
renderWithIntl(<FilteredUsersLabel />);
expect(selectors.grades.useUserCounts).toHaveBeenCalled(); expect(selectors.grades.useUserCounts).toHaveBeenCalled();
}); });
}); });
describe('render', () => { describe('render', () => {
it('null render if totalUsersCount is 0', () => { test('null render if totalUsersCount is 0', () => {
selectors.grades.useUserCounts.mockReturnValueOnce({ selectors.grades.useUserCounts.mockReturnValueOnce({
...userCounts, ...userCounts,
totalUsersCount: 0, totalUsersCount: 0,
}); });
const { container } = renderWithIntl(<FilteredUsersLabel />); expect(shallow(<FilteredUsersLabel />).isEmptyRender()).toEqual(true);
expect(container.firstChild).toBeNull();
}); });
it('renders users count correctly', () => { test('snapshot', () => {
renderWithIntl(<FilteredUsersLabel />); expect(el.snapshot).toMatchSnapshot();
expect(screen.getByText((text) => text.includes(userCounts.filteredUsersCount))).toBeInTheDocument(); expect(el.instance).toMatchObject(shallow(formatMessage(messages.visibilityLabel, {
expect(screen.getByText((text) => text.includes(userCounts.totalUsersCount))).toBeInTheDocument(); filteredUsers: <BoldText text={userCounts.filteredUsersCount} />,
totalUsers: <BoldText text={userCounts.totalUsersCount} />,
})));
}); });
}); });
}); });

View File

@@ -1,9 +1,11 @@
import { render, screen } from '@testing-library/react'; import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import Fields from './Fields'; import Fields from './Fields';
describe('Gradebook Table Fields', () => { describe('Gradebook Table Fields', () => {
describe('Username', () => { describe('Username', () => {
let el;
const username = 'MyNameFromHere'; const username = 'MyNameFromHere';
describe('with external_user_key', () => { describe('with external_user_key', () => {
const props = { const props = {
@@ -11,32 +13,40 @@ describe('Gradebook Table Fields', () => {
userKey: 'My name from another land', userKey: 'My name from another land',
}; };
beforeEach(() => { beforeEach(() => {
render(<Fields.Username {...props} />); el = shallow(<Fields.Username {...props} />);
}); });
it('wraps external user key and username', () => { test('snapshot', () => {
const usernameField = screen.getByText(username); expect(el.snapshot).toMatchSnapshot();
expect(usernameField).toBeInTheDocument(); });
const userKeyField = screen.getByText(props.userKey); test('wraps external user key and username', () => {
expect(userKeyField).toBeInTheDocument(); expect(el.instance.findByType('span')[0].el).toMatchSnapshot();
const content = el.instance.findByType('span')[0].children[0];
expect(content.children[0].children[0].el).toEqual(username);
expect(content.children[1].children[0].el).toEqual(props.userKey);
}); });
}); });
describe('without external_user_key', () => { describe('without external_user_key', () => {
beforeEach(() => { beforeEach(() => {
render(<Fields.Username username={username} />); el = shallow(<Fields.Username username={username} />);
}); });
it('wraps username only', () => { test('snapshot', () => {
const usernameField = screen.getByText(username); expect(el.snapshot).toMatchSnapshot();
expect(usernameField).toBeInTheDocument(); });
test('wraps username only', () => {
const content = el.instance.findByType('span')[0].children[0];
expect(content.children[0].children[0].el).toEqual(username);
expect(content.children).toHaveLength(1);
}); });
}); });
}); });
describe('Text', () => { describe('Text', () => {
const value = 'myTag@place.com'; const value = 'myTag@place.com';
it('wraps entry value', () => { test('snapshot', () => {
render(<Fields.Text value={value} />); expect(shallow(<Fields.Text value={value} />).snapshot).toMatchSnapshot();
const textElement = screen.getByText(value); });
expect(textElement).toBeInTheDocument(); test('wraps entry value', () => {
expect(shallow(<Fields.Text value={value} />).instance.children[0].el).toEqual(value);
}); });
}); });
}); });

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'; import React from 'react';
import userEvent from '@testing-library/user-event'; import { shallow } from '@edx/react-unit-test-utils';
import { selectors, thunkActions } from 'data/redux/hooks'; import { selectors, thunkActions } from 'data/redux/hooks';
import transforms from 'data/redux/transforms'; import transforms from 'data/redux/transforms';
@@ -47,6 +47,7 @@ selectors.grades.useGradeData.mockReturnValue({ gradeFormat });
thunkActions.app.useSetModalStateFromTable.mockReturnValue(setModalState); thunkActions.app.useSetModalStateFromTable.mockReturnValue(setModalState);
transforms.grades.subsectionGrade.mockReturnValue(subsectionGrade); transforms.grades.subsectionGrade.mockReturnValue(subsectionGrade);
let el;
let out; let out;
describe('GradeButton', () => { describe('GradeButton', () => {
beforeEach(() => { beforeEach(() => {
@@ -97,24 +98,23 @@ describe('GradeButton', () => {
describe('frozen grades', () => { describe('frozen grades', () => {
beforeEach(() => { beforeEach(() => {
hookSpy.mockReturnValue({ ...hookProps, areGradesFrozen: true }); hookSpy.mockReturnValue({ ...hookProps, areGradesFrozen: true });
render(<GradeButton {...props} />); el = shallow(<GradeButton {...props} />);
}); });
it('renders only labels', () => { test('snapshot', () => {
const label = screen.getByText(hookProps.label); expect(el.snapshot).toMatchSnapshot();
expect(label).toBeInTheDocument(); expect(el.instance.el).toEqual(hookProps.label);
}); });
}); });
describe('not frozen grades', () => { describe('not frozen grades', () => {
beforeEach(() => { beforeEach(() => {
hookSpy.mockReturnValue(hookProps); hookSpy.mockReturnValue(hookProps);
render(<GradeButton {...props} />); el = shallow(<GradeButton {...props} />);
}); });
it('renders button', async () => { test('snapshot', () => {
const user = userEvent.setup(); expect(el.snapshot).toMatchSnapshot();
const button = screen.getByRole('button', { name: hookProps.label }); expect(el.instance.type).toEqual('Button');
expect(button).toBeInTheDocument(); expect(el.instance.props.onClick).toEqual(hookProps.onClick);
await user.click(button); expect(el.instance.children[0].el).toEqual(hookProps.label);
expect(hookProps.onClick).toHaveBeenCalled();
}); });
}); });
}); });

View File

@@ -1,8 +1,10 @@
import { screen } from '@testing-library/react'; import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { getLocale } from '@edx/frontend-platform/i18n'; import { getLocale } from '@edx/frontend-platform/i18n';
import { OverlayTrigger } from '@openedx/paragon';
import LabelReplacements from './LabelReplacements'; import LabelReplacements from './LabelReplacements';
import messages from './messages';
import { renderWithIntl } from '../../../testUtilsExtra';
const { const {
TotalGradeLabelReplacement, TotalGradeLabelReplacement,
@@ -10,36 +12,52 @@ const {
MastersOnlyLabelReplacement, MastersOnlyLabelReplacement,
} = LabelReplacements; } = LabelReplacements;
jest.mock('@edx/frontend-platform/i18n', () => ({ jest.mock('@openedx/paragon', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'), Icon: () => 'Icon',
getLocale: jest.fn(), OverlayTrigger: () => 'OverlayTrigger',
isRtl: jest.fn(), Tooltip: () => 'Tooltip',
})); }));
describe('LabelReplacements', () => { describe('LabelReplacements', () => {
describe('TotalGradeLabelReplacement', () => { describe('TotalGradeLabelReplacement', () => {
getLocale.mockImplementation(() => 'en'); let el;
renderWithIntl(<TotalGradeLabelReplacement />); beforeEach(() => {
it('displays overlay tooltip', () => { el = shallow(<TotalGradeLabelReplacement />);
const tooltip = screen.getByText(messages.totalGradePercentage.defaultMessage); });
expect(tooltip).toBeInTheDocument(); test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
});
test('displays overlay tooltip', () => {
expect(el.instance.findByType(OverlayTrigger)[0].props.overlay).toMatchSnapshot();
}); });
}); });
describe('UsernameLabelReplacement', () => { describe('UsernameLabelReplacement', () => {
it('renders correctly', () => { test('snapshot', () => {
renderWithIntl(<UsernameLabelReplacement />); expect(shallow(<UsernameLabelReplacement />).snapshot).toMatchSnapshot();
expect(screen.getByText(messages.usernameHeading.defaultMessage)).toBeInTheDocument();
}); });
}); });
describe('MastersOnlyLabelReplacement', () => { describe('MastersOnlyLabelReplacement', () => {
it('renders correctly', () => { test('snapshot', () => {
const message = { const message = {
id: 'id', id: 'id',
defaultMessage: 'defaultMessAge', defaultMessage: 'defaultMessAge',
description: 'desCripTion', description: 'desCripTion',
}; };
renderWithIntl(<MastersOnlyLabelReplacement {...message} />); expect(shallow(<MastersOnlyLabelReplacement {...message} />).snapshot).toMatchSnapshot();
expect(screen.getByText(message.defaultMessage)).toBeInTheDocument();
}); });
}); });
}); });
describe('snapshot', () => {
let el;
test('right to left overlay placement', () => {
getLocale.mockImplementation(() => 'en');
el = shallow(<TotalGradeLabelReplacement />);
expect(el.snapshot).toMatchSnapshot();
});
test('left to right overlay placement', () => {
getLocale.mockImplementation(() => 'ar');
el = shallow(<TotalGradeLabelReplacement />);
expect(el.snapshot).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,75 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Gradebook Table Fields Text snapshot 1`] = `
<span
className="wrap-text-in-cell"
>
myTag@place.com
</span>
`;
exports[`Gradebook Table Fields Username with external_user_key snapshot 1`] = `
<div>
<span
className="wrap-text-in-cell"
>
<div>
<div>
MyNameFromHere
</div>
<div
className="student-key"
>
My name from another land
</div>
</div>
</span>
</div>
`;
exports[`Gradebook Table Fields Username with external_user_key wraps external user key and username 1`] = `
{
"children": [
{
"children": [
{
"children": [
"MyNameFromHere",
],
"props": {},
"type": "div",
},
{
"children": [
"My name from another land",
],
"props": {
"className": "student-key",
},
"type": "div",
},
],
"props": {},
"type": "div",
},
],
"props": {
"className": "wrap-text-in-cell",
},
"type": "span",
}
`;
exports[`Gradebook Table Fields Username without external_user_key snapshot 1`] = `
<div>
<span
className="wrap-text-in-cell"
>
<div>
<div>
MyNameFromHere
</div>
</div>
</span>
</div>
`;

View File

@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradeButton component frozen grades snapshot 1`] = `"test-label"`;
exports[`GradeButton component not frozen grades snapshot 1`] = `
<Button
className="btn-header grade-button"
onClick={[MockFunction hooks.onClick]}
variant="link"
>
test-label
</Button>
`;

View File

@@ -0,0 +1,140 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LabelReplacements MastersOnlyLabelReplacement snapshot 1`] = `
<div>
defaultMessAge
<span
className="font-weight-normal"
>
*
</span>
</div>
`;
exports[`LabelReplacements TotalGradeLabelReplacement displays overlay tooltip 1`] = `
<Tooltip
id="course-grade-tooltip"
>
Total Grade values are always displayed as a percentage
</Tooltip>
`;
exports[`LabelReplacements TotalGradeLabelReplacement snapshot 1`] = `
<div>
<OverlayTrigger
key="left-basic"
overlay={
<Tooltip
id="course-grade-tooltip"
>
Total Grade values are always displayed as a percentage
</Tooltip>
}
placement="left"
trigger={
[
"hover",
"focus",
]
}
>
<div>
Total Grade (%)
<div
id="courseGradeTooltipIcon"
>
<Icon
className="fa fa-info-circle"
screenReaderText="Total Grade values are always displayed as a percentage"
/>
</div>
</div>
</OverlayTrigger>
</div>
`;
exports[`LabelReplacements UsernameLabelReplacement snapshot 1`] = `
<div>
<div>
Username
</div>
<div
className="font-weight-normal student-key"
>
Student Key
<span
className="font-weight-normal"
>
*
</span>
</div>
</div>
`;
exports[`snapshot left to right overlay placement 1`] = `
<div>
<OverlayTrigger
key="left-basic"
overlay={
<Tooltip
id="course-grade-tooltip"
>
Total Grade values are always displayed as a percentage
</Tooltip>
}
placement="right"
trigger={
[
"hover",
"focus",
]
}
>
<div>
Total Grade (%)
<div
id="courseGradeTooltipIcon"
>
<Icon
className="fa fa-info-circle"
screenReaderText="Total Grade values are always displayed as a percentage"
/>
</div>
</div>
</OverlayTrigger>
</div>
`;
exports[`snapshot right to left overlay placement 1`] = `
<div>
<OverlayTrigger
key="left-basic"
overlay={
<Tooltip
id="course-grade-tooltip"
>
Total Grade values are always displayed as a percentage
</Tooltip>
}
placement="left"
trigger={
[
"hover",
"focus",
]
}
>
<div>
Total Grade (%)
<div
id="courseGradeTooltipIcon"
>
<Icon
className="fa fa-info-circle"
screenReaderText="Total Grade values are always displayed as a percentage"
/>
</div>
</div>
</OverlayTrigger>
</div>
`;

View File

@@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradebookTable snapshot 1`] = `
<div
className="gradebook-container"
>
<DataTable
RowStatusComponent={[MockFunction hooks.nullMethod]}
columns={
[
"some",
"columns",
]
}
data={
[
"some",
"data",
]
}
hasFixedColumnWidths={true}
itemCount={3}
rowHeaderColumnKey="username"
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.EmptyTable
content="empty-table-content"
/>
</DataTable>
</div>
`;

View File

@@ -1,32 +1,47 @@
import React from 'react'; import { shallow } from '@edx/react-unit-test-utils';
import { Headings } from 'data/constants/grades';
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { getLocalizedPercentSign } from 'i18n/utils';
import { selectors } from 'data/redux/hooks';
import transforms from 'data/redux/transforms';
import { Headings } from 'data/constants/grades';
import LabelReplacements from './LabelReplacements';
import Fields from './Fields';
import GradeButton from './GradeButton';
import { initializeMocks, render } from '../../../testUtilsExtra';
import * as hooks from './hooks';
import messages from './messages'; import messages from './messages';
let mockUseAllGrades; import useGradebookTableData from './hooks';
let mockUseGetHeadings;
jest.mock('i18n/utils', () => ({
getLocalizedPercentSign: () => '%',
}));
jest.mock('./GradeButton', () => 'GradeButton');
jest.mock('./Fields', () => jest.requireActual('testUtils').mockNestedComponents({
Username: 'Fields.Username',
Text: 'Fields.Text',
}));
jest.mock('./LabelReplacements', () => jest.requireActual('testUtils').mockNestedComponents({
TotalGradeLabelReplacement: 'LabelReplacements.TotalGradeLabelReplacement',
UsernameLabelReplacement: 'LabelReplacements.UsernameLabelReplacement',
MastersOnlyLabelReplacement: 'LabelReplacements.MastersOnlyLabelReplacement',
}));
jest.mock('data/redux/hooks', () => ({ jest.mock('data/redux/hooks', () => ({
selectors: { selectors: {
grades: { useAllGrades: () => mockUseAllGrades() }, grades: { useAllGrades: jest.fn() },
root: { useGetHeadings: () => mockUseGetHeadings() }, root: { useGetHeadings: jest.fn() },
}, },
})); }));
jest.mock('data/redux/transforms', () => ({ jest.mock('data/redux/transforms', () => ({
grades: { roundGrade: jest.fn((val) => val) }, grades: { roundGrade: jest.fn() },
})); }));
jest.mock('i18n/utils', () => ({ getLocalizedPercentSign: () => '%' })); const roundGrade = grade => grade * 20;
jest.mock('./Fields', () => ({ Username: () => null, Text: () => null })); transforms.grades.roundGrade.mockImplementation(roundGrade);
jest.mock('./GradeButton', () => ({ __esModule: true, default: () => null }));
jest.mock('./LabelReplacements', () => ({
TotalGradeLabelReplacement: () => null,
UsernameLabelReplacement: () => null,
MastersOnlyLabelReplacement: () => null,
}));
const subsectionLabels = [ const subsectionLabels = [
'subsectionLabel1', 'subsectionLabel1',
@@ -70,9 +85,7 @@ const allGrades = [
], ],
}, },
]; ];
const testHeading = 'test-heading-value'; const testHeading = 'test-heading-value';
const headings = [ const headings = [
Headings.totalGrade, Headings.totalGrade,
Headings.username, Headings.username,
@@ -80,60 +93,100 @@ const headings = [
Headings.fullName, Headings.fullName,
testHeading, testHeading,
]; ];
selectors.grades.useAllGrades.mockReturnValue(allGrades);
selectors.root.useGetHeadings.mockReturnValue(headings);
describe('useGradebookTableData hook', () => { let out;
beforeAll(() => { describe('useGradebookTableData', () => {
mockUseAllGrades = jest.fn();
mockUseGetHeadings = jest.fn();
});
beforeEach(() => { beforeEach(() => {
mockUseAllGrades.mockReset(); jest.clearAllMocks();
mockUseGetHeadings.mockReset(); out = useGradebookTableData();
}); });
let hookResult; describe('behavior', () => {
it('initializes intl hook', () => {
const TestComponent = () => { expect(useIntl).toHaveBeenCalled();
hookResult = hooks.useGradebookTableData(); });
return null; it('initializes redux hooks', () => {
}; expect(selectors.grades.useAllGrades).toHaveBeenCalled();
expect(selectors.root.useGetHeadings).toHaveBeenCalled();
beforeEach(() => {
initializeMocks();
hookResult = null;
mockUseAllGrades.mockReturnValue([]);
mockUseGetHeadings.mockReturnValue([]);
});
it('returns expected structure with empty data', () => {
render(<TestComponent />);
expect(hookResult).toEqual({
columns: [],
data: [],
grades: [],
nullMethod: expect.any(Function),
emptyContent: expect.any(String),
}); });
}); });
describe('output', () => {
it('nullMethod returns null', () => { describe('columns', () => {
render(<TestComponent />); test('total grade heading produces TotalGradeLabelReplacement label', () => {
expect(hookResult.nullMethod()).toBeNull(); const { Header, accessor } = out.columns[0];
}); expect(accessor).toEqual(headings[0]);
expect(shallow(Header)).toMatchObject(
it('returns expected structure with grades and headings data', () => { shallow(<LabelReplacements.TotalGradeLabelReplacement />),
mockUseAllGrades.mockReturnValue(allGrades); );
mockUseGetHeadings.mockReturnValue(headings); });
render(<TestComponent />); test('username heading produces UsernameLabelReplacement', () => {
expect(hookResult.columns.length).toBe(headings.length); const { Header, accessor } = out.columns[1];
expect(hookResult.columns[0].accessor).toEqual(headings[0]); expect(accessor).toEqual(headings[1]);
expect(hookResult.data.length).toBe(allGrades.length); expect(shallow(Header)).toMatchObject(
expect(hookResult.data[0]).toHaveProperty(Headings.username); shallow(<LabelReplacements.UsernameLabelReplacement />),
expect(hookResult.grades).toEqual(allGrades); );
expect(hookResult.nullMethod()).toBeNull(); });
expect(hookResult.emptyContent).toBe(messages.noResultsFound.defaultMessage); test('email heading replaces with email heading message', () => {
const { Header, accessor } = out.columns[2];
expect(mockUseAllGrades).toHaveBeenCalled(); expect(accessor).toEqual(headings[2]);
expect(mockUseGetHeadings).toHaveBeenCalled(); expect(shallow(Header)).toMatchObject(
shallow(<LabelReplacements.MastersOnlyLabelReplacement {...messages.emailHeading} />),
);
});
test('fullName heading replaces with fullName heading message', () => {
const { Header, accessor } = out.columns[3];
expect(accessor).toEqual(headings[3]);
expect(shallow(Header)).toMatchObject(
shallow(<LabelReplacements.MastersOnlyLabelReplacement {...messages.fullNameHeading} />),
);
});
test('other headings are passed through', () => {
const { Header, accessor } = out.columns[4];
expect(accessor).toEqual(headings[4]);
expect(Header).toEqual(headings[4]);
});
});
describe('data', () => {
test('username field', () => {
allGrades.forEach((entry, index) => {
expect(out.data[index][Headings.username]).toMatchObject(
<Fields.Username username={entry.username} userKey={entry.external_user_key} />,
);
});
});
test('email field', () => {
allGrades.forEach((entry, index) => {
expect(out.data[index][Headings.email]).toMatchObject(
<Fields.Text value={entry.email} />,
);
});
});
test('totalGrade field', () => {
allGrades.forEach((entry, index) => {
expect(out.data[index][Headings.totalGrade]).toEqual(
`${roundGrade(entry.percent * 100)}${getLocalizedPercentSign()}`,
);
});
});
test('section breakdown', () => {
allGrades.forEach((entry, gradeIndex) => {
subsectionLabels.forEach((label, labelIndex) => {
expect(out.data[gradeIndex][label]).toMatchObject(
<GradeButton entry={entry} subsection={entry.section_breakdown[labelIndex]} />,
);
});
});
});
});
it('forwards grades from redux', () => {
expect(out.grades).toEqual(allGrades);
});
test('nullMethod returns null', () => {
expect(out.nullMethod()).toEqual(null);
});
test('emptyContent', () => {
expect(out.emptyContent).toEqual(formatMessage(messages.noResultsFound));
});
}); });
}); });

View File

@@ -1,42 +1,39 @@
import { screen } from '@testing-library/react'; import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { DataTable } from '@openedx/paragon';
import useGradebookTableData from './hooks'; import useGradebookTableData from './hooks';
import GradebookTable from '.'; import GradebookTable from '.';
import { renderWithIntl } from '../../../testUtilsExtra';
jest.mock('./hooks', () => jest.fn()); jest.mock('./hooks', () => jest.fn());
const hookProps = { const hookProps = {
columns: [{ Header: 'Username', accessor: 'username' }, { Header: 'Email', accessor: 'email' }, { Header: 'Total Grade', accessor: 'totalGrade' }], columns: ['some', 'columns'],
data: [{ username: 'instructor', email: 'instructor@example.com', totalGrade: '100' }, { username: 'student', email: 'student@example.com', totalGrade: '90' }], data: ['some', 'data'],
grades: ['a', 'few', 'grades'], grades: ['a', 'few', 'grades'],
nullMethod: jest.fn().mockName('hooks.nullMethod'), nullMethod: jest.fn().mockName('hooks.nullMethod'),
emptyContent: 'empty-table-content', emptyContent: 'empty-table-content',
}; };
useGradebookTableData.mockReturnValue(hookProps);
let el;
describe('GradebookTable', () => { describe('GradebookTable', () => {
it('renders Datatable correctly', () => { beforeEach(() => {
useGradebookTableData.mockReturnValue(hookProps); jest.clearAllMocks();
renderWithIntl(<GradebookTable />); el = shallow(<GradebookTable />);
expect(useGradebookTableData).toHaveBeenCalled();
const headers = screen.getAllByRole('columnheader');
expect(headers).toHaveLength(3);
expect(headers[0]).toHaveTextContent(hookProps.columns[0].Header);
expect(headers[1]).toHaveTextContent(hookProps.columns[1].Header);
expect(headers[2]).toHaveTextContent(hookProps.columns[2].Header);
const rows = screen.getAllByRole('row');
expect(rows).toHaveLength(3);
expect(screen.getByText(hookProps.data[0].username)).toBeInTheDocument();
expect(screen.getByText(hookProps.data[0].email)).toBeInTheDocument();
expect(screen.getByText(hookProps.data[0].totalGrade)).toBeInTheDocument();
}); });
it('renders empty table content when no data is available', () => { test('snapshot', () => {
useGradebookTableData.mockReturnValue({ expect(el.snapshot).toMatchSnapshot();
...hookProps, });
data: [], test('Datatable props', () => {
grades: [], const datatable = el.instance.findByType(DataTable)[0];
}); const { props } = datatable;
renderWithIntl(<GradebookTable />); expect(props.columns).toEqual(hookProps.columns);
expect(screen.getByText(hookProps.emptyContent)).toBeInTheDocument(); expect(props.data).toEqual(hookProps.data);
expect(props.itemCount).toEqual(hookProps.grades.length);
expect(props.RowStatusComponent).toEqual(hookProps.nullMethod);
expect(datatable.children[2].type).toEqual('DataTable.EmptyTable');
expect(datatable.children[2].props.content).toEqual(hookProps.emptyContent);
}); });
}); });

View File

@@ -120,25 +120,25 @@ select#ScoreView.form-control {
&:before { &:before {
border-width: 0.4rem 0.4rem 0.4rem 0; border-width: 0.4rem 0.4rem 0.4rem 0;
border-right-color: var(--pgn-color-black); border-right-color: $black;
} }
} }
#edit-filters-btn { #edit-filters-btn {
@media (--pgn-size-breakpoint-max-width-xs) { @include media-breakpoint-down(xs) {
width: 100%; width: 100%;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
} }
.search-container { .search-container {
@media (--pgn-size-breakpoint-max-width-xs) { @include media-breakpoint-down(xs) {
width: 100%; width: 100%;
} }
} }
.pgn__modal-body-content .pgn__data-table-layout-wrapper { .pgn__modal-body-content .pgn__data-table-layout-wrapper {
@media (--pgn-size-breakpoint-max-width-xs) { @include media-breakpoint-down(sm) {
clear: both; clear: both;
padding: 1rem 0; padding: 1rem 0;
} }

View File

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

View File

@@ -12,11 +12,6 @@ jest.mock('data/redux/hooks', () => ({
}, },
})); }));
jest.mock('react', () => ({
...jest.requireActual('react'),
useRef: jest.fn((val) => ({ current: val, useRef: true })),
}));
let out; let out;
let submitThen; let submitThen;

View File

@@ -1,26 +1,46 @@
import React from 'react'; import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { import { useIntl } from '@edx/frontend-platform/i18n';
render, screen, initializeMocks, import { Form } from '@openedx/paragon';
} from 'testUtilsExtra';
import NetworkButton from 'components/NetworkButton';
import useImportGradesButtonData from './hooks';
import ImportGradesButton from '.'; import ImportGradesButton from '.';
initializeMocks(); jest.mock('components/NetworkButton', () => 'NetworkButton');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
let el;
let props;
describe('ImportGradesButton component', () => { describe('ImportGradesButton component', () => {
beforeEach(() => { beforeAll(() => {
jest.clearAllMocks(); props = {
render(<ImportGradesButton />); fileInputRef: { current: null },
gradeExportUrl: 'test-grade-export-url',
handleClickImportGrades: jest.fn().mockName('props.handleClickImportGrades'),
handleFileInputChange: jest.fn().mockName('props.handleFileInputChange'),
};
useImportGradesButtonData.mockReturnValue(props);
el = shallow(<ImportGradesButton />);
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(useImportGradesButtonData).toHaveBeenCalledWith();
expect(useIntl).toHaveBeenCalledWith();
});
}); });
describe('render', () => { describe('render', () => {
test('Form', async () => { test('snapshot', () => {
const uploader = screen.getByTestId('file-control'); expect(el.snapshot).toMatchSnapshot();
expect(uploader).toBeInTheDocument(); });
test('Form', () => {
expect(el.instance.findByType(Form)[0].snapshot).toMatchSnapshot();
expect(el.instance.findByType(Form)[0].props.action).toEqual(props.gradeExportUrl);
expect(el.instance.findByType(Form.Control)[0].props.onChange).toEqual(props.handleFileInputChange);
}); });
test('import button', () => { test('import button', () => {
expect(screen.getByRole('button', { name: 'Import Grades' })).toBeInTheDocument(); expect(el.instance.findByType(NetworkButton)[0].props.onClick).toEqual(props.handleClickImportGrades);
}); });
}); });
}); });

View File

@@ -1,8 +1,11 @@
import React from 'react'; import React from 'react';
import { render } from '@testing-library/react';
import useImportGradesButtonData from './hooks'; import useImportGradesButtonData from './hooks';
import ImportGradesButton from '.'; import ImportGradesButton from '.';
import { renderWithIntl, screen } from '../../../testUtilsExtra';
jest.unmock('react');
jest.unmock('@openedx/paragon');
jest.mock('components/NetworkButton', () => 'network-button'); jest.mock('components/NetworkButton', () => 'network-button');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() })); jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
@@ -14,10 +17,11 @@ const props = {
}; };
useImportGradesButtonData.mockReturnValue(props); useImportGradesButtonData.mockReturnValue(props);
let el;
describe('ImportGradesButton ref test', () => { describe('ImportGradesButton ref test', () => {
it('loads ref from hook', () => { it('loads ref from hook', () => {
renderWithIntl(<ImportGradesButton />); el = render(<ImportGradesButton />);
const input = screen.getByTestId('file-control'); const input = el.getByTestId('file-control');
expect(input).toEqual(props.fileInputRef.current); expect(input).toEqual(props.fileInputRef.current);
}); });
}); });

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ImportSuccessToast component render snapshot 1`] = `
<Toast
action="test-action"
onClose={[MockFunction hooks.onClose]}
show="test-show"
>
test-description
</Toast>
`;

View File

@@ -19,18 +19,6 @@ jest.mock('data/redux/hooks', () => ({
}, },
})); }));
jest.mock('react', () => ({
...jest.requireActual('react'),
useContext: jest.fn(context => context),
}));
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: jest.fn(() => ({
formatMessage: (message) => message.defaultMessage,
})),
}));
const setView = jest.fn().mockName('hooks.setView'); const setView = jest.fn().mockName('hooks.setView');
const setShowToast = jest.fn().mockName('hooks.setShowImportSuccessToast'); const setShowToast = jest.fn().mockName('hooks.setShowImportSuccessToast');
actions.app.useSetView.mockReturnValue(setView); actions.app.useSetView.mockReturnValue(setView);

View File

@@ -1,88 +1,39 @@
import React from 'react'; import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { render, initializeMocks, screen } from 'testUtilsExtra';
import ImportSuccessToast from '.';
import useImportSuccessToastData from './hooks'; import useImportSuccessToastData from './hooks';
import ImportSuccessToast from '.';
jest.mock('data/redux/hooks', () => ({
actions: {
app: {
useSetView: jest.fn(),
useSetShowImportSuccessToast: jest.fn(),
},
},
selectors: {
app: {
useShowImportSuccessToast: jest.fn(),
},
},
}));
jest.mock('./hooks', () => jest.fn()); jest.mock('./hooks', () => jest.fn());
initializeMocks(); const hookProps = {
action: 'test-action',
onClose: jest.fn().mockName('hooks.onClose'),
show: 'test-show',
description: 'test-description',
};
useImportSuccessToastData.mockReturnValue(hookProps);
describe('ImportSuccessToast', () => { let el;
beforeEach(() => { describe('ImportSuccessToast component', () => {
jest.clearAllMocks(); beforeAll(() => {
el = shallow(<ImportSuccessToast />);
}); });
describe('behavior', () => {
it('renders with show false', () => { it('initializes component hook', () => {
useImportSuccessToastData.mockReturnValue({ expect(useImportSuccessToastData).toHaveBeenCalled();
action: {
label: 'View Activity Log',
onClick: jest.fn(),
},
onClose: jest.fn(),
show: false,
description: 'Import Successful! Grades will be updated momentarily.',
}); });
render(<ImportSuccessToast />);
const toastRoot = document.getElementById('toast-root');
expect(toastRoot).toBeInTheDocument();
expect(toastRoot).toHaveClass('toast-container');
const toastMessage = screen.queryByText('Import Successful! Grades will be updated momentarily.');
expect(toastMessage).toBeNull();
expect(useImportSuccessToastData).toHaveBeenCalled();
}); });
describe('render', () => {
it('renders with show true', () => { test('snapshot', () => {
useImportSuccessToastData.mockReturnValue({ expect(el.snapshot).toMatchSnapshot();
action: {
label: 'View Activity Log',
onClick: jest.fn(),
},
onClose: jest.fn(),
show: true,
description: 'Import Successful! Grades will be updated momentarily.',
}); });
test('Toast', () => {
render(<ImportSuccessToast />); expect(el.instance.type).toEqual('Toast');
const toastMessage = screen.getByText('Import Successful! Grades will be updated momentarily.'); expect(el.instance.props.action).toEqual(hookProps.action);
expect(toastMessage).toBeInTheDocument(); expect(el.instance.props.onClose).toEqual(hookProps.onClose);
expect(useImportSuccessToastData).toHaveBeenCalled(); expect(el.instance.props.show).toEqual(hookProps.show);
}); expect(el.instance.children[0].el).toEqual(hookProps.description);
it('passes correct props to Toast component', () => {
const mockOnClose = jest.fn();
const mockOnClick = jest.fn();
useImportSuccessToastData.mockReturnValue({
action: {
label: 'View Activity Log',
onClick: mockOnClick,
},
onClose: mockOnClose,
show: true,
description: 'Import Successful! Grades will be updated momentarily.',
}); });
const { container } = render(<ImportSuccessToast />);
expect(container).toBeInTheDocument();
expect(useImportSuccessToastData).toHaveBeenCalled();
}); });
}); });

View File

@@ -0,0 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`InterventionsReport component output snapshot 1`] = `
<div>
<h4
className="mt-0"
>
Interventions Report
</h4>
<div
className="d-flex justify-content-between align-items-center"
>
<div
className="intervention-report-description"
>
Need to find students who may be falling behind? Download the interventions report to obtain engagement metrics such as section attempts and visits.
</div>
<NetworkButton
label={
{
"defaultMessage": "Download Interventions",
"description": "The labeled button to download the Intervention report from the Grades View",
"id": "gradebook.GradesView.InterventionsReport.downloadBtn",
}
}
onClick={[MockFunction]}
/>
</div>
</div>
`;

View File

@@ -1,73 +1,42 @@
import React from 'react'; import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { render, screen, initializeMocks } from 'testUtilsExtra'; import { useIntl } from '@edx/frontend-platform/i18n';
import NetworkButton from 'components/NetworkButton';
import messages from './messages';
import useInterventionsReportData from './hooks';
import InterventionsReport from '.'; import InterventionsReport from '.';
jest.mock('components/NetworkButton', () => 'NetworkButton'); jest.mock('components/NetworkButton', () => 'NetworkButton');
jest.mock('./hooks', () => jest.fn()); jest.mock('./hooks', () => jest.fn());
const useInterventionsReportData = require('./hooks'); const hookProps = { show: true, handleClick: jest.fn() };
useInterventionsReportData.mockReturnValue(hookProps);
initializeMocks(); let el;
describe('InterventionsReport component', () => {
describe('InterventionsReport', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); el = shallow(<InterventionsReport />);
}); });
describe('behavior', () => {
it('renders without errors when show is true', () => { it('initializes hooks', () => {
useInterventionsReportData.mockReturnValue({ expect(useInterventionsReportData).toHaveBeenCalledWith();
show: true, expect(useIntl).toHaveBeenCalledWith();
handleClick: jest.fn(),
}); });
render(<InterventionsReport />);
expect(screen.getByRole('heading', { level: 4 })).toBeInTheDocument();
expect(useInterventionsReportData).toHaveBeenCalled();
}); });
describe('output', () => {
it('renders nothing when show is false', () => { it('does now render if show is false', () => {
useInterventionsReportData.mockReturnValue({ useInterventionsReportData.mockReturnValueOnce({ ...hookProps, show: false });
show: false, el = shallow(<InterventionsReport />);
handleClick: jest.fn(), expect(el.isEmptyRender()).toEqual(true);
}); });
test('snapshot', () => {
render(<InterventionsReport />); expect(el.snapshot).toMatchSnapshot();
expect(screen.queryByRole('heading')).not.toBeInTheDocument(); const btnProps = el.instance.findByType(NetworkButton)[0].props;
expect(screen.queryByText('Interventions Report')).not.toBeInTheDocument(); expect(btnProps.label).toEqual(messages.downloadBtn);
expect(useInterventionsReportData).toHaveBeenCalled(); expect(btnProps.onClick).toEqual(hookProps.handleClick);
});
it('calls useInterventionsReportData hook', () => {
useInterventionsReportData.mockReturnValue({
show: true,
handleClick: jest.fn(),
}); });
render(<InterventionsReport />);
expect(useInterventionsReportData).toHaveBeenCalled();
});
it('renders with correct content when show is true', () => {
const mockReportData = {
show: true,
handleClick: jest.fn(),
};
useInterventionsReportData.mockReturnValue(mockReportData);
render(<InterventionsReport />);
expect(screen.getByText('Interventions Report')).toBeInTheDocument();
expect(
screen.getByText(/Need to find students who may be falling behind/),
).toBeInTheDocument();
const networkButton = document.querySelector('networkbutton');
expect(networkButton).toBeInTheDocument();
expect(useInterventionsReportData).toHaveBeenCalled();
}); });
}); });

View File

@@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PageButtons component render snapshot 1`] = `
<div
className="d-flex justify-content-center"
style={
{
"paddingBottom": "20px",
}
}
>
<Button
disabled="prev-disabled"
onClick={[MockFunction hooks.prev.onClick]}
style={
{
"margin": "20px",
}
}
variant="outline-primary"
>
prev-text
</Button>
<Button
disabled="next-disabled"
onClick={[MockFunction hooks.next.onClick]}
style={
{
"margin": "20px",
}
}
variant="outline-primary"
>
next-text
</Button>
</div>
`;

View File

@@ -15,18 +15,6 @@ jest.mock('data/redux/hooks', () => ({
}, },
})); }));
jest.mock('react', () => ({
...jest.requireActual('react'),
useContext: jest.fn(context => context),
}));
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: jest.fn(() => ({
formatMessage: (message) => message.defaultMessage,
})),
}));
const gradeData = { nextPage: 'test-next-page', prevPage: 'test-prev-page' }; const gradeData = { nextPage: 'test-next-page', prevPage: 'test-prev-page' };
selectors.grades.useGradeData.mockReturnValue(gradeData); selectors.grades.useGradeData.mockReturnValue(gradeData);

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { initializeMocks, render, screen } from 'testUtilsExtra'; import { Button } from '@openedx/paragon';
import usePageButtonsData from './hooks'; import usePageButtonsData from './hooks';
import PageButtons from '.'; import PageButtons from '.';
@@ -9,53 +10,44 @@ jest.mock('./hooks', () => jest.fn());
const hookProps = { const hookProps = {
prev: { prev: {
disabled: false, disabled: 'prev-disabled',
onClick: jest.fn().mockName('hooks.prev.onClick'), onClick: jest.fn().mockName('hooks.prev.onClick'),
text: 'prev-text', text: 'prev-text',
}, },
next: { next: {
disabled: false, disabled: 'next-disabled',
onClick: jest.fn().mockName('hooks.next.onClick'), onClick: jest.fn().mockName('hooks.next.onClick'),
text: 'next-text', text: 'next-text',
}, },
}; };
usePageButtonsData.mockReturnValue(hookProps);
let el;
describe('PageButtons component', () => { describe('PageButtons component', () => {
beforeAll(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
initializeMocks(); el = shallow(<PageButtons />);
}); });
describe('renders enabled buttons', () => { describe('behavior', () => {
beforeEach(() => { it('initializes component hooks', () => {
usePageButtonsData.mockReturnValue(hookProps); expect(usePageButtonsData).toHaveBeenCalled();
render(<PageButtons />);
});
test('prev button enabled', () => {
expect(screen.getByText(hookProps.prev.text)).toBeInTheDocument();
expect(screen.getByText(hookProps.next.text)).toBeEnabled();
});
test('next button enabled', () => {
expect(screen.getByText(hookProps.next.text)).toBeInTheDocument();
expect(screen.getByText(hookProps.prev.text)).toBeEnabled();
}); });
}); });
describe('render', () => {
describe('renders disabled buttons', () => { test('snapshot', () => {
beforeAll(() => { expect(el.snapshot).toMatchSnapshot();
hookProps.prev.disabled = true;
hookProps.next.disabled = true;
}); });
beforeEach(() => { test('prev button', () => {
usePageButtonsData.mockReturnValue(hookProps); const button = el.instance.findByType(Button)[0];
render(<PageButtons />); expect(button.props.disabled).toEqual(hookProps.prev.disabled);
expect(button.props.onClick).toEqual(hookProps.prev.onClick);
expect(button.children[0].el).toEqual(hookProps.prev.text);
}); });
test('prev button disabled', () => { test('next button', () => {
expect(screen.getByText(hookProps.next.text)).toBeInTheDocument(); const button = el.instance.findByType(Button)[1];
expect(screen.getByText(hookProps.prev.text)).toBeDisabled(); expect(button.props.disabled).toEqual(hookProps.next.disabled);
}); expect(button.props.onClick).toEqual(hookProps.next.onClick);
test('next button disabled', () => { expect(button.children[0].el).toEqual(hookProps.next.text);
expect(screen.getByText(hookProps.prev.text)).toBeInTheDocument();
expect(screen.getByText(hookProps.next.text)).toBeDisabled();
}); });
}); });
}); });

View File

@@ -0,0 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ScoreViewInput component render snapshot 1`] = `
<Form.Group
controlId="ScoreView"
>
<Form.Label>
Score View
:
</Form.Label>
<Form.Control
as="select"
onChange={[MockFunction hooks.toggleGradeFormat]}
value="test-grade-format"
>
<option
value="percent"
>
Percent
</option>
<option
value="absolute"
>
Absolute
</option>
</Form.Control>
</Form.Group>
`;

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