Compare commits

..

6 Commits

Author SHA1 Message Date
Leangseu Kim
54c6c57b42 feat: upgraded to node v18, added .nvmrc and updated workflows 2023-06-09 09:19:12 +02:00
Adolfo R. Brandes
6722dbdce5 Merge pull request #185 from arbrandes/runtime-config-palm 2023-06-05 16:40:57 +01:00
Adolfo R. Brandes
fed06641df refactor: use getConfig 2023-05-31 12:46:23 -03:00
Adolfo R. Brandes
689b8b48f0 feat: Support runtime configuration
frontend-platform supports runtime configuration since 2.5.0 (see the PR
that introduced it[1], but it requires MFE cooperation.  This implements
just that: by avoiding making configuration values constant, it should
now be possible to change them after initialization.

Almost all changes here relate to the `LMS_BASE_URL` setting, which in
most places was treated as a constant.

[1] openedx/frontend-platform#335
2023-05-31 12:46:23 -03:00
Adolfo R. Brandes
258f4377d8 Merge pull request #181 from raccoongang/palm/fix-location-id 2023-05-15 10:37:48 -03:00
Eugene Dyudyunov
dcdc96778c fix: BadOraLocationResponse error
Refactor the locationId constant for the subdirectory-based
deployments support.

Exclude the MFE's `PUBLIC_PATH` from the constant.

The `window.location.pathname` example:
```
<PUBLIC_PATH>block-v1:oragrading+oragrading+oragrading+type@openassessment+block@ee217e897a954c1faa3b29317da0f2e7
```
Where the `PUBLIC_PATH` could be:
- `'/'` - for subdomain-based deployments (default)
- `'/mfe-specifix-public-path/'` - for subdirectory-based deployments
2023-05-12 15:37:12 +03:00
243 changed files with 51074 additions and 18539 deletions

3
.env
View File

@@ -32,6 +32,3 @@ ENTERPRISE_MARKETING_UTM_CAMPAIGN=''
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=''
APP_ID=''
MFE_CONFIG_API_URL=''
ACCOUNT_SETTINGS_URL=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -7,6 +7,7 @@ LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGO_POWERED_BY_OPEN_EDX_URL_SVG=https://edx-cdn.org/v3/stage/open-edx-tag.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
@@ -37,6 +38,3 @@ ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
APP_ID=''
MFE_CONFIG_API_URL=''
ACCOUNT_SETTINGS_URL=http://localhost:1997
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -7,6 +7,7 @@ LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGO_POWERED_BY_OPEN_EDX_URL_SVG=https://edx-cdn.org/v3/stage/open-edx-tag.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
@@ -35,5 +36,3 @@ ENTERPRISE_MARKETING_URL='http://example.com'
ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
ACCOUNT_SETTINGS_URL=http://localhost:1997
PARAGON_THEME_URLS={}

View File

@@ -1,5 +1,4 @@
// eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@openedx/frontend-build');
const { createConfig } = require('@edx/frontend-build');
const config = createConfig('eslint', {
rules: {
@@ -7,28 +6,19 @@ const config = createConfig('eslint', {
'import/no-named-as-default-member': 'off',
'import/no-import-module-exports': 'off',
'import/no-self-import': 'off',
'spaced-comment': ['error', 'always', { block: { exceptions: ['*'] } }],
'spaced-comment': ['error', 'always', { 'block': { 'exceptions': ['*'] } }],
'react-hooks/rules-of-hooks': 'off',
'react/forbid-prop-types': ['error', { forbid: ['any', 'array'] }], // arguable object proptype is use when I do not care about the shape of the object
"react/forbid-prop-types": ["error", { "forbid": ["any", "array"] }], // arguable object proptype is use when I do not care about the shape of the object
'no-import-assign': 'off',
'no-promise-executor-return': 'off',
'import/no-cycle': 'off',
},
overrides: [
{
files: ['**/*.test.{js,jsx,ts,tsx}'],
rules: {
'react/prop-types': 'off',
},
},
],
});
config.settings = {
'import/resolver': {
"import/resolver": {
node: {
paths: ['src', 'node_modules'],
extensions: ['.js', '.jsx'],
paths: ["src", "node_modules"],
extensions: [".js", ".jsx"],
},
},
};

6
.github/CODEOWNERS vendored Normal file
View File

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

View File

@@ -10,18 +10,17 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Nodejs
uses: actions/setup-node@v4
uses: actions/setup-node@v3
# Because of node 18 bug (https://github.com/nodejs/node/issues/47563), Pinning node version 18.15 until the next release of node
with:
node-version-file: '.nvmrc'
node-version: 18.15
- name: Install dependencies
run: npm ci
@@ -39,10 +38,7 @@ jobs:
run: npm run build
- name: Run Coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
uses: codecov/codecov-action@v3
- name: Send failure notification
if: ${{ failure() }}

View File

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

32
.github/workflows/npm-publish.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Release CI
on:
push:
tags:
- "*"
jobs:
release:
name: Release
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: 12
- name: Install dependencies
run: npm ci
- name: Create Build
run: npm run build
- name: Release Package
env:
GITHUB_TOKEN: ${{ secrets.SEMANTIC_RELEASE_GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.SEMANTIC_RELEASE_NPM_TOKEN }}
run: npm semantic-release

2
.gitignore vendored
View File

@@ -25,5 +25,3 @@ module.config.js
### transifex ###
src/i18n/transifex_input.json
temp
src/i18n/messages

4
.husky/pre-push Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint

2
.nvmrc
View File

@@ -1 +1 @@
24
18.15

27
.releaserc Normal file
View File

@@ -0,0 +1,27 @@
{
"branch": "master",
"tagFormat": "v${version}",
"verifyConditions": [
"@semantic-release/npm",
{
"path": "@semantic-release/github",
"assets": {
"path": "dist/*"
}
}
],
"analyzeCommits": "@semantic-release/commit-analyzer",
"generateNotes": "@semantic-release/release-notes-generator",
"prepare": "@semantic-release/npm",
"publish": [
"@semantic-release/npm",
{
"path": "@semantic-release/github",
"assets": {
"path": "dist/*"
}
}
],
"success": [],
"fail": []
}

9
.tx/config Normal file
View File

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

View File

@@ -2,13 +2,15 @@ npm-install-%: ## install specified % npm package
npm install $* --save-dev
git add package.json
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_resource = frontend-app-ora-grading
transifex_langs = "ar,fr,es_419,zh_CN,fr_CA,it_IT,pt_PT,de_DE,uk,ru,hi"
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-formatjs
transifex_temp = ./temp/babel-plugin-react-intl
NPM_TESTS=build i18n_extract lint test
@@ -40,18 +42,20 @@ detect_changed_source_translations:
# Checking for changed translations...
git diff --exit-code $(i18n)
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull $(ATLAS_OPTIONS) \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-platform/src/i18n/messages:frontend-platform \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-app-ora-grading/src/i18n/messages:frontend-app-ora-grading
# Pushes translations to Transifex. You must run make extract_translations first.
push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
$(intl_imports) frontend-component-footer frontend-component-header frontend-platform paragon frontend-app-ora-grading
# Pulls translations from Transifex.
pull_translations:
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
# This target is used by CI.
validate-no-uncommitted-package-lock-changes:

21
README.md Normal file
View File

@@ -0,0 +1,21 @@
# frontend-app-ora-grading
The ORA Staff Grading App is a microfrontend (MFE) staff grading experience for Open Response Assessments (ORAs). This experience was designed to streamline the grading process and enable richer previews of submission content.
When enabled, ORAs with a staff grading step will link to this new MFE when clicking "Grade Available Responses" from the ORA or link in the instructor dashboard.
## Quickstart
To start the MFE and enable the feature in LMS:
1. Start the MFE with `npm run start`. Take a note of the path/port (defaults to `http://localhost:1993`).
2. Add the route root to `edx-platform` settings: In `edx-platform/lms/envs/private.py` or similar, add `ORA_GRADING_MICROFRONTEND_URL = 'http://localhost:1993'`
3. Enable the feature: In Django Admin go to django-waffle > Flags and add/enable a new flag called `openresponseassessment.enhanced_staff_grader`.
From there, visit the new experience by going to the Instructor Dashboard > Open Responses or an ORA with a Staff Graded Step and click a link to begin grading.
## Resources
See the [ORA Staff Grading](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/open_response_assessments/ORA_Staff_Grading.html#ora-staff-grading) section on ReadTheDocs for usage information.

View File

@@ -1,235 +0,0 @@
frontend-app-ora-grading
#############################
|license-badge| |status-badge| |ci-badge| |codecov-badge|
Purpose
*******
The ORA Staff Grading App is a micro-frontend (MFE) staff grading experience
for Open Response Assessments (ORAs). This experience was designed to
streamline the grading process and enable richer previews of submission content
and, eventually, replace on-platform grading workflows for ORA.
When enabled, ORAs with a staff grading step will link to this new MFE when
clicking "Grade Available Responses" from the ORA or link in the instructor
dashboard.
The ORA Staff Grader depends on the `lms/djangoapps/ora_staff_grader
<https://github.com/openedx/edx-platform/tree/master/lms/djangoapps/ora_staff_grader>`_
app in ``edx-platform``.
Getting Started
***************
Prerequisites
=============
`Tutor`_ is currently recommended as a development environment for your
new MFE. Please refer
to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development
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>`_.
Developing
==========
Cloning and Startup
--------------
First, clone the repo, install code prerequisites, and start the app.
.. code-block::
1. Clone your new repo:
``git clone git@github.com:openedx/frontend-app-ora-grading.git``
2. Use the version of Node specified in the ``.nvmrc`` file.
The current version of the micro-frontend build scripts supports the version of Node found in ``.nvmrc``.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
3. Install npm dependencies:
``cd frontend-app-ora-grading && npm install``
4. Update the application port to use for local development:
Default port is 1993. If this does not work for you, update the line
`PORT=1993` to your port in all .env.* files
5. Start the dev server:
``npm start``
The app will, by default, run on `http://localhost:1993` unless otherwise
specified in ``.env.development:PORT`` and ``.env.development:BASE_URL``.
Next, enable the ORA Grading micro-frontend in `edx-platform`
#. Add the path to the ORA Grading app in `edx-platform`:
#. Go to your environment settings (e.g. `edx-platform/lms/envs/private.py`)
#. Add the environment variable, ``ORA_GRADING_MICROFRONTEND_URL`` pointing
to the ORA Grading app location (e.g. ``http://localhost:1993``).
#. Start / restart the ``edx-platform`` ``lms``.
#. Enable the ORA Grading feature in Django Admin.
#. Go to Django Admin (`{lms-root}/admin`)
#. Navigate to ``django-waffle`` > ``Flags`` and click ``add/enable a new
flag``.
#. Add a new flag called ``openresponseassessment.enhanced_staff_grader``
and enable it.
From there, visit an Open Response Assessment with a Staff Graded Step and
click the "View and grade responses" button to begin grading in the ORA Staff
Grader experience.
Making Changes
--------------
Get / install the latest code:
.. code-block::
# Grab the latest code
git checkout master
git pull
# Install/update the dev requirements
npm install
Before committing:
.. code-block::
# Make a new branch for your changes
git checkout -b <your_github_username>/<short_description>
# Using your favorite editor, edit the code to make your change.
# Run your new tests
npm test
# Commit all your changes
git commit ...
git push
# Open a PR and ask for review.
Deploying
=========
This component follows the standard deploy process for MFEs. For details, see
the `MFE production deployment guide`_
.. _MFE production deployment guide: https://openedx.github.io/frontend-platform/#production-deployment-strategy
Internationalization
====================
Please see refer to the `frontend-platform i18n howto`_ for documentation on
internationalization.
.. _frontend-platform i18n howto: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
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-ora-grading/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
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.
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/
People
******
The assigned maintainers for this component and other project details may be
found in `Backstage`_. Backstage pulls this data from the ``catalog-info.yaml``
file in this repo.
.. _Backstage: https://open-edx-backstage.herokuapp.com/catalog/default/component/frontend-app-ora-grading
Reporting Security Issues
*************************
Please do not report security issues in public, and email security@openedx.org instead.
.. |license-badge| image:: https://img.shields.io/github/license/openedx/frontend-app-ora-grading.svg
:target: https://github.com/openedx/frontend-app-ora-grading/blob/master/LICENSE
:alt: License
.. |status-badge| image:: https://img.shields.io/badge/Status-Maintained-brightgreen
.. |ci-badge| image:: https://github.com/openedx/frontend-app-ora-grading/actions/workflows/ci.yml/badge.svg
:target: https://github.com/openedx/frontend-app-ora-grading/actions/workflows/ci.yml
:alt: Continuous Integration
.. |codecov-badge| image:: https://codecov.io/github/openedx/frontend-app-ora-grading/coverage.svg?branch=master
:target: https://codecov.io/github/openedx/frontend-app-ora-grading?branch=master
:alt: Codecov

View File

@@ -1,21 +0,0 @@
# This file records information about this repo. Its use is described in OEP-55:
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: 'frontend-app-ora-grading'
description: "Frontend grading experience for Open Response Assessments (ORAs)"
links:
- url: "https://ora-grading.edx.org"
title: "Production Site"
icon: "Web"
- url: "https://ora-grading.stage.edx.org"
title: "Stage Site"
icon: "Web"
annotations:
openedx.org/release: "master"
spec:
owner: "user:codewithemad"
type: 'website'
lifecycle: 'production'

View File

@@ -26,3 +26,4 @@ There are only two requirements for a good `make target` name
What `make validate-no-uncommitted-package-lock-changes` does is `git diff`s for any `package-lock.json` file changes in your project.
This is important because `npm` uses the pinned dependencies in your `package-lock.json` file to build the `node_modules` directory. However, the dependencies defined within the `package.json` file can be modified manually, for example, to become misaligned with the dependencies defined within the `package-lock.json`. So when `npm install` executes, the `package-lock.json` file will be updated to mirror the modified `package.json` changes.

View File

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

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}

58053
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,19 +6,16 @@
"type": "git",
"url": "git+https://github.com/edx/frontend-app-ora-grading.git"
},
"browserslist": [
"extends @edx/browserslist-config"
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"lint": "fedx-scripts eslint --ext .jsx,.js src/",
"lint-fix": "fedx-scripts eslint --fix --ext .jsx,.js src/",
"semantic-release": "semantic-release",
"start": "fedx-scripts webpack-dev-server --progress",
"dev": "PUBLIC_PATH=/ora-grading/ 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",
"watch-tests": "jest --watch"
"watch-tests": "jest --watch",
"prepare": "husky install"
},
"author": "edX",
"license": "AGPL-3.0",
@@ -27,66 +24,70 @@
"access": "public"
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^8.0.0",
"@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.6.0",
"@edx/brand": "npm:@edx/brand-edx.org@^2.0.3",
"@edx/frontend-component-footer": "^11.1.1",
"@edx/frontend-component-header": "^3.1.1",
"@edx/frontend-platform": "^2.5.1",
"@edx/paragon": "^19.9.0",
"@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-brands-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.2.0",
"@openedx/paragon": "^23.4.5",
"@fortawesome/react-fontawesome": "^0.1.15",
"@redux-beacon/segment": "^1.1.0",
"@redux-devtools/extension": "3.0.0",
"@reduxjs/toolkit": "^1.6.1",
"@testing-library/user-event": "^14.0.0",
"@testing-library/user-event": "^13.5.0",
"@zip.js/zip.js": "^2.4.6",
"axios": "^0.28.0",
"axios": "^0.21.4",
"classnames": "^2.3.1",
"core-js": "3.35.1",
"core-js": "3.16.2",
"dompurify": "^2.3.1",
"email-prop-type": "^3.0.1",
"enzyme": "^3.11.0",
"enzyme-to-json": "^3.6.2",
"file-saver": "^2.0.5",
"filesize": "^8.0.6",
"font-awesome": "4.7.0",
"history": "5.3.0",
"history": "5.0.1",
"html-react-parser": "^1.3.0",
"lodash": "^4.17.21",
"moment": "^2.29.3",
"prop-types": "15.8.1",
"query-string": "7.1.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"prop-types": "15.7.2",
"query-string": "7.0.1",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"react-helmet": "^6.1.0",
"react-intl": "6.4.7",
"react-pdf": "^7.0.0",
"react-redux": "^7.2.9",
"react-router": "6.21.3",
"react-router-dom": "6.21.3",
"react-intl": "^5.20.9",
"react-pdf": "^5.5.0",
"react-redux": "^7.2.4",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-router-redux": "^5.0.0-alpha.9",
"redux": "4.2.1",
"redux": "4.1.1",
"redux-beacon": "^2.1.0",
"redux-devtools-extension": "2.13.9",
"redux-logger": "3.0.6",
"redux-thunk": "2.4.2",
"regenerator-runtime": "^0.14.0",
"redux-thunk": "2.3.0",
"regenerator-runtime": "^0.13.9",
"reselect": "^4.0.0",
"util": "^0.12.4",
"whatwg-fetch": "^3.6.2"
},
"devDependencies": {
"@edx/browserslist-config": "^1.3.0",
"@openedx/frontend-build": "^14.6.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@edx/frontend-build": "12.4",
"@edx/reactifex": "^2.1.1",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.1.0",
"axios-mock-adapter": "^1.20.0",
"enzyme-adapter-react-16": "^1.15.6",
"fetch-mock": "^9.11.0",
"husky": "^7.0.0",
"identity-obj-proxy": "^3.0.0",
"jest": "29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest": "27.0.6",
"jest-expect-message": "^1.0.2",
"react-dev-utils": "^12.0.1",
"react-test-renderer": "^18.3.1",
"redux-mock-store": "^1.5.5"
"react-test-renderer": "^16.14.0",
"reactifex": "1.1.1",
"redux-mock-store": "^1.5.4",
"semantic-release": "^19.0.3"
}
}

View File

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

View File

@@ -3,12 +3,13 @@ import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { BrowserRouter as Router } from 'react-router-dom';
import { FooterSlot } from '@edx/frontend-component-footer';
import Footer from '@edx/frontend-component-footer';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import { selectors } from 'data/redux';
import DemoWarning from 'containers/DemoWarning';
import CTA from 'containers/CTA';
import ListView from 'containers/ListView';
import './App.scss';
@@ -22,13 +23,13 @@ export const App = ({ courseMetadata, isEnabled }) => (
courseTitle={courseMetadata.title}
courseNumber={courseMetadata.number}
courseOrg={courseMetadata.org}
data-testid="header"
/>
{!isEnabled && <DemoWarning />}
<main data-testid="main">
<CTA />
<main>
<ListView />
</main>
<FooterSlot />
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
</div>
</Router>
);

View File

@@ -1,13 +1,15 @@
// frontend-app-*/src/index.scss
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
@import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables";
@import "~@edx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
$fa-font-path: "~font-awesome/fonts";
@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-footer/dist/_footer";
@import "~@edx/frontend-component-header/dist/index";
#root {
display: flex;
@@ -46,22 +48,7 @@ $input-focus-box-shadow: var(--pgn-elevation-form-input-base); // hack to get up
right: 1rem !important;
}
}
.confirm-modal .pgn__modal-body {
overflow: hidden;
}
.pgn__modal-body-content {
& img {
object-fit: contain;
max-width: 100%;
height: auto;
}
& blockquote > p {
border-left: 2px solid var(--pgn-color-gray-200);
margin-left: 1.5rem;
padding-left: 1rem;
}
}
}

View File

@@ -1,101 +1,77 @@
import { screen } from '@testing-library/react';
import { selectors } from 'data/redux';
import React from 'react';
import { shallow } from 'enzyme';
import { renderWithIntl } from './testUtils';
import { App, mapStateToProps } from './App';
import Footer from '@edx/frontend-component-footer';
import { LearningHeader as Header } from '@edx/frontend-component-header';
// we want to scope these tests to the App component, so we mock some child components to reduce complexity
import ListView from 'containers/ListView';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
getLoginRedirectUrl: jest.fn(),
}));
jest.mock('@edx/frontend-component-footer', () => ({
FooterSlot: () => <div data-testid="footer">Footer</div>,
}));
jest.mock('containers/ListView', () => function ListView() {
return <div data-testid="list-view">List View</div>;
});
jest.mock('containers/DemoWarning', () => function DemoWarning() {
return <div role="alert" data-testid="demo-warning">Demo Warning</div>;
});
jest.mock('@edx/frontend-component-header', () => ({
LearningHeader: ({ courseTitle, courseNumber, courseOrg }) => (
<div data-testid="header">
Header - {courseTitle} {courseNumber} {courseOrg}
</div>
),
}));
import { App } from './App';
jest.mock('data/redux', () => ({
selectors: {
app: {
courseMetadata: jest.fn((state) => state.courseMetadata || {
org: 'test-org',
number: 'test-101',
title: 'Test Course',
}),
isEnabled: jest.fn((state) => (state.isEnabled !== undefined ? state.isEnabled : true)),
app: {
selectors: {
courseMetadata: (state) => ({ courseMetadata: state }),
isEnabled: (state) => ({ isEnabled: state }),
},
},
}));
describe('App component', () => {
const defaultProps = {
jest.mock('@edx/frontend-component-header', () => ({
LearningHeader: 'Header',
}));
jest.mock('@edx/frontend-component-footer', () => 'Footer');
jest.mock('containers/DemoWarning', () => 'DemoWarning');
jest.mock('containers/CTA', () => 'CTA');
jest.mock('containers/ListView', () => 'ListView');
jest.mock('components/Head', () => 'Head');
const logo = 'fakeLogo.png';
let el;
let router;
describe('App router component', () => {
const props = {
courseMetadata: {
org: 'test-org',
number: 'test-101',
title: 'Test Course',
org: 'course-org',
number: 'course-number',
title: 'course-title',
},
isEnabled: true,
};
beforeEach(() => {
jest.clearAllMocks();
test('snapshot: enabled', () => {
expect(shallow(<App {...props} />)).toMatchSnapshot();
});
it('renders header with course metadata', () => {
renderWithIntl(<App {...defaultProps} />);
const org = screen.getByText((text) => text.includes('test-org'));
expect(org).toBeInTheDocument();
const title = screen.getByText((content) => content.includes('Test Course'));
expect(title).toBeInTheDocument();
test('snapshot: disabled (show demo warning)', () => {
expect(shallow(<App {...props} isEnabled={false} />)).toMatchSnapshot();
});
describe('component', () => {
beforeEach(() => {
process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG = logo;
el = shallow(<App {...props} />);
router = el.childAt(0);
});
describe('Router', () => {
test('Routing - ListView is only route', () => {
expect(router.find('main')).toEqual(shallow(
<main><ListView /></main>,
));
});
});
test('Footer logo drawn from env variable', () => {
expect(router.find(Footer).props().logo).toEqual(logo);
});
it('renders main content', () => {
renderWithIntl(<App {...defaultProps} />);
const main = screen.getByTestId('main');
expect(main).toBeInTheDocument();
});
it('does not render demo warning when enabled', () => {
renderWithIntl(<App {...defaultProps} />);
const demoWarning = screen.queryByRole('alert');
expect(demoWarning).not.toBeInTheDocument();
});
it('renders demo warning when disabled', () => {
renderWithIntl(<App {...defaultProps} isEnabled={false} />);
const demoWarning = screen.getByRole('alert');
expect(demoWarning).toBeInTheDocument();
});
describe('mapStateToProps', () => {
it('maps state properties correctly', () => {
const testState = { arbitraryState: 'some data' };
const mapped = mapStateToProps(testState);
expect(selectors.app.courseMetadata).toHaveBeenCalledWith(testState);
expect(selectors.app.isEnabled).toHaveBeenCalledWith(testState);
expect(mapped.courseMetadata).toEqual(selectors.app.courseMetadata(testState));
expect(mapped.isEnabled).toEqual(selectors.app.isEnabled(testState));
test('Header to use courseMetadata props', () => {
const {
courseTitle,
courseNumber,
courseOrg,
} = router.find(Header).props();
expect(courseTitle).toEqual(props.courseMetadata.title);
expect(courseNumber).toEqual(props.courseMetadata.number);
expect(courseOrg).toEqual(props.courseMetadata.org);
});
});
});

View File

@@ -0,0 +1,42 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`App router component snapshot: disabled (show demo warning) 1`] = `
<BrowserRouter>
<div>
<Head />
<Header
courseNumber="course-number"
courseOrg="course-org"
courseTitle="course-title"
/>
<DemoWarning />
<CTA />
<main>
<ListView />
</main>
<Footer
logo="https://edx-cdn.org/v3/stage/open-edx-tag.svg"
/>
</div>
</BrowserRouter>
`;
exports[`App router component snapshot: enabled 1`] = `
<BrowserRouter>
<div>
<Head />
<Header
courseNumber="course-number"
courseOrg="course-org"
courseTitle="course-title"
/>
<CTA />
<main>
<ListView />
</main>
<Footer
logo="https://edx-cdn.org/v3/stage/open-edx-tag.svg"
/>
</div>
</BrowserRouter>
`;

View File

@@ -0,0 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app registry subscribe: APP_INIT_ERROR. snapshot: displays an ErrorPage to root element 1`] = `
<ErrorPage
message="test-error-message"
/>
`;
exports[`app registry subscribe: APP_READY. links App to root element 1`] = `
<IntlProvider
defaultFormats={Object {}}
defaultLocale="en"
fallbackOnEmptyString={true}
formats={Object {}}
locale="en"
messages={Object {}}
onError={[Function]}
onWarn={[Function]}
textComponent={Symbol(react.fragment)}
>
<AppProvider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<App />
</AppProvider>
</IntlProvider>
`;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { AlertModal, ActionRow, Button } from '@openedx/paragon';
import { AlertModal, ActionRow, Button } from '@edx/paragon';
import { nullMethod } from 'hooks';
export const ConfirmModal = ({

View File

@@ -1,6 +1,5 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { shallow } from 'enzyme';
import { ConfirmModal } from './ConfirmModal';
describe('ConfirmModal', () => {
@@ -13,48 +12,10 @@ describe('ConfirmModal', () => {
onCancel: jest.fn().mockName('this.props.onCancel'),
onConfirm: jest.fn().mockName('this.props.onConfirm'),
};
beforeEach(() => {
jest.clearAllMocks();
test('snapshot: closed', () => {
expect(shallow(<ConfirmModal {...props} />)).toMatchSnapshot();
});
it('should not render content when modal is closed', () => {
render(
<IntlProvider locale="en">
<ConfirmModal {...props} />
</IntlProvider>,
);
expect(screen.queryByText(props.content)).toBeNull();
});
it('should display content when modal is open', () => {
render(
<IntlProvider locale="en">
<ConfirmModal {...props} isOpen />
</IntlProvider>,
);
expect(screen.getByText(props.content)).toBeInTheDocument();
});
it('should call onCancel when cancel button is clicked', async () => {
render(
<IntlProvider locale="en">
<ConfirmModal {...props} isOpen />
</IntlProvider>,
);
const user = userEvent.setup();
await user.click(screen.getByText(props.cancelText));
expect(props.onCancel).toHaveBeenCalledTimes(1);
});
it('should call onConfirm when confirm button is clicked', async () => {
render(
<IntlProvider locale="en">
<ConfirmModal {...props} isOpen />
</IntlProvider>,
);
const user = userEvent.setup();
await user.click(screen.getByText(props.confirmText));
expect(props.onConfirm).toHaveBeenCalledTimes(1);
test('snapshot: open', () => {
expect(shallow(<ConfirmModal {...props} isOpen />)).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DemoAlert component snapshot 1`] = `
<AlertModal
footerNode={
<ActionRow>
<Button
onClick={[MockFunction props.onClose]}
variant="primary"
>
Confirm
</Button>
</ActionRow>
}
isOpen={true}
onClose={[MockFunction props.onClose]}
title="Demo submit prevented"
>
<p>
Grade submission is disabled in the Demo mode of the new ORA Staff Grader.
</p>
</AlertModal>
`;

View File

@@ -1,40 +1,39 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
ActionRow,
AlertModal,
Button,
} from '@openedx/paragon';
} from '@edx/paragon';
import messages from './messages';
export const DemoAlert = ({
intl: { formatMessage },
isOpen,
onClose,
}) => {
const { formatMessage } = useIntl();
return (
<AlertModal
title={formatMessage(messages.title)}
isOpen={isOpen}
onClose={onClose}
footerNode={(
<ActionRow>
<Button variant="primary" onClick={onClose}>
{formatMessage(messages.confirm)}
</Button>
</ActionRow>
}) => (
<AlertModal
title={formatMessage(messages.title)}
isOpen={isOpen}
onClose={onClose}
footerNode={(
<ActionRow>
<Button variant="primary" onClick={onClose}>
{formatMessage(messages.confirm)}
</Button>
</ActionRow>
)}
>
<p>{formatMessage(messages.warningMessage)}</p>
</AlertModal>
);
};
>
<p>{formatMessage(messages.warningMessage)}</p>
</AlertModal>
);
DemoAlert.propTypes = {
intl: intlShape.isRequired,
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
};
export default DemoAlert;
export default injectIntl(DemoAlert);

View File

@@ -1,32 +1,16 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithIntl } from '../../testUtils';
import React from 'react';
import { shallow } from 'enzyme';
import messages from './messages';
import { formatMessage } from 'testUtils';
import { DemoAlert } from '.';
describe('DemoAlert component', () => {
const props = {
isOpen: true,
onClose: jest.fn().mockName('props.onClose'),
};
it('does not render when isOpen is false', () => {
renderWithIntl(<DemoAlert {...props} isOpen={false} />);
expect(screen.queryByText(messages.title.defaultMessage)).toBeNull();
});
it('renders with correct title and message when isOpen is true', () => {
renderWithIntl(<DemoAlert {...props} />);
expect(screen.getByText(messages.title.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.warningMessage.defaultMessage)).toBeInTheDocument();
});
it('calls onClose when confirmation button is clicked', async () => {
renderWithIntl(<DemoAlert {...props} />);
const user = userEvent.setup();
const confirmButton = screen.getByText(messages.confirm.defaultMessage);
await user.click(confirmButton);
expect(props.onClose).toHaveBeenCalled();
test('snapshot', () => {
const props = {
intl: { formatMessage },
isOpen: true,
onClose: jest.fn().mockName('props.onClose'),
};
expect(shallow(<DemoAlert {...props} />)).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,89 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FilePopoverContent component snapshot default 1`] = `
<Fragment>
<div
className="help-popover-option"
>
<strong>
<FormattedMessage
defaultMessage="File Name"
description="Popover title for file name"
id="ora-grading.FilePopoverContent.filePopoverNameTitle"
/>
</strong>
<br />
some file name
</div>
<div
className="help-popover-option"
>
<strong>
<FormattedMessage
defaultMessage="File Description"
description="Popover title for file description"
id="ora-grading.FilePopoverCellContent.filePopoverDescriptionTitle"
/>
</strong>
<br />
long descriptive text...
</div>
<div
className="help-popover-option"
>
<strong>
<FormattedMessage
defaultMessage="File Size"
description="Popover title for file size"
id="ora-grading.FilePopoverCellContent.fileSizeTitle"
/>
</strong>
<br />
filesize(6000)
</div>
</Fragment>
`;
exports[`FilePopoverContent component snapshot invalid size 1`] = `
<Fragment>
<div
className="help-popover-option"
>
<strong>
<FormattedMessage
defaultMessage="File Name"
description="Popover title for file name"
id="ora-grading.FilePopoverContent.filePopoverNameTitle"
/>
</strong>
<br />
some file name
</div>
<div
className="help-popover-option"
>
<strong>
<FormattedMessage
defaultMessage="File Description"
description="Popover title for file description"
id="ora-grading.FilePopoverCellContent.filePopoverDescriptionTitle"
/>
</strong>
<br />
long descriptive text...
</div>
<div
className="help-popover-option"
>
<strong>
<FormattedMessage
defaultMessage="File Size"
description="Popover title for file size"
id="ora-grading.FilePopoverCellContent.fileSizeTitle"
/>
</strong>
<br />
Unknown
</div>
</Fragment>
`;

View File

@@ -1,7 +1,7 @@
import { screen } from '@testing-library/react';
import filesize from 'filesize';
import { renderWithIntl } from '../../testUtils';
import React from 'react';
import { shallow } from 'enzyme';
import filesize from 'filesize';
import FilePopoverContent from '.';
jest.mock('filesize', () => (size) => `filesize(${size})`);
@@ -14,26 +14,25 @@ describe('FilePopoverContent', () => {
downloadURL: 'this-url-is.working',
size: 6000,
};
let el;
beforeEach(() => {
el = shallow(<FilePopoverContent {...props} />);
});
describe('snapshot', () => {
test('default', () => expect(el).toMatchSnapshot());
test('invalid size', () => {
el.setProps({
size: null,
});
expect(el).toMatchSnapshot();
});
});
describe('behavior', () => {
it('renders file name correctly', () => {
renderWithIntl(<FilePopoverContent {...props} />);
expect(screen.getByText(props.name)).toBeInTheDocument();
});
it('renders file description correctly', () => {
renderWithIntl(<FilePopoverContent {...props} />);
expect(screen.getByText(props.description)).toBeInTheDocument();
});
it('renders file size correctly', () => {
renderWithIntl(<FilePopoverContent {...props} />);
expect(screen.getByText(filesize(props.size))).toBeInTheDocument();
});
it('renders "Unknown" when size is null', () => {
renderWithIntl(<FilePopoverContent {...props} size={null} />);
expect(screen.getByText('Unknown')).toBeInTheDocument();
test('content', () => {
expect(el.text()).toContain(props.name);
expect(el.text()).toContain(props.description);
expect(el.text()).toContain(filesize(props.size));
});
});
});

View File

@@ -1,8 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert, Button } from '@openedx/paragon';
import { Info } from '@openedx/paragon/icons';
import { Alert, Button } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
const messageShape = PropTypes.shape({

View File

@@ -1,6 +1,8 @@
import { screen } from '@testing-library/react';
import { renderWithIntl } from '../../../testUtils';
import React from 'react';
import { shallow } from 'enzyme';
import ErrorBanner from './ErrorBanner';
import messages from '../messages';
describe('Error Banner component', () => {
@@ -23,29 +25,35 @@ describe('Error Banner component', () => {
children,
};
describe('behavior', () => {
it('renders children content', () => {
renderWithIntl(<ErrorBanner {...props} />);
const childText = screen.getByText('Abitary Child');
expect(childText).toBeInTheDocument();
let el;
beforeEach(() => {
el = shallow(<ErrorBanner {...props} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
describe('component', () => {
test('children node', () => {
expect(el.containsMatchingElement(children)).toEqual(true);
});
it('renders the correct number of action buttons', () => {
renderWithIntl(<ErrorBanner {...props} />);
const buttons = screen.getAllByText(messages.retryButton.defaultMessage);
expect(buttons).toHaveLength(2);
test('verify actions', () => {
const actions = el.find('Alert').prop('actions');
expect(actions).toHaveLength(props.actions.length);
actions.forEach((action, index) => {
expect(action.type).toEqual('Button');
expect(action.props.onClick).toEqual(props.actions[index].onClick);
// action message
expect(action.props.children.props).toEqual(props.actions[index].message);
});
});
it('renders error heading with correct message', () => {
renderWithIntl(<ErrorBanner {...props} />);
const heading = screen.getAllByText(messages.unknownError.defaultMessage)[0];
expect(heading).toBeInTheDocument();
});
it('renders with danger variant', () => {
renderWithIntl(<ErrorBanner {...props} />);
const alert = screen.getByRole('alert');
expect(alert).toHaveClass('alert-danger');
test('verify heading', () => {
const heading = el.find('FormattedMessage');
expect(heading.props()).toEqual(props.headingMessage);
});
});
});

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Alert, Spinner } from '@openedx/paragon';
import { Alert, Spinner } from '@edx/paragon';
export const LoadingBanner = () => (
<Alert variant="info">

View File

@@ -1,19 +1,11 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { shallow } from 'enzyme';
import LoadingBanner from './LoadingBanner';
describe('Loading Banner component', () => {
describe('behavior', () => {
it('renders an info alert', () => {
render(<LoadingBanner />);
const alert = screen.getByRole('alert');
expect(alert).toHaveClass('alert-info');
});
it('renders a spinner', () => {
const { container } = render(<LoadingBanner />);
const spinner = container.querySelector('.pgn__spinner');
expect(spinner).toBeInTheDocument();
expect(spinner).toHaveClass('spinner-border');
});
test('snapshot', () => {
const el = shallow(<LoadingBanner />);
expect(el).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,42 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Error Banner component snapshot 1`] = `
<Alert
actions={
Array [
<Button
onClick={[MockFunction action1.onClick]}
variant="outline-primary"
>
<FormattedMessage
defaultMessage="Retry"
description="Retry button for error in file renderer"
id="ora-grading.ResponseDisplay.FileRenderer.retryButton"
/>
</Button>,
<Button
onClick={[MockFunction action2.onClick]}
variant="outline-primary"
>
<FormattedMessage
defaultMessage="Retry"
description="Retry button for error in file renderer"
id="ora-grading.ResponseDisplay.FileRenderer.retryButton"
/>
</Button>,
]
}
variant="danger"
>
<Alert.Heading>
<FormattedMessage
defaultMessage="Unknown errors"
description="Unknown errors message"
id="ora-grading.ResponseDisplay.FileRenderer.unknownError"
/>
</Alert.Heading>
<p>
Abitary Child
</p>
</Alert>
`;

View File

@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Loading Banner component snapshot 1`] = `
<Alert
variant="info"
>
<Spinner
animation="border"
className="d-flex m-auto"
/>
</Alert>
`;

View File

@@ -1,40 +1,21 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { shallow } from 'enzyme';
import ImageRenderer from './ImageRenderer';
describe('Image Renderer Component', () => {
const props = {
url: 'some_url.jpg',
fileName: 'test-image.jpg',
onError: jest.fn().mockName('this.props.onError'),
onSuccess: jest.fn().mockName('this.props.onSuccess'),
};
it('renders an image with the correct src and alt attributes', () => {
render(<ImageRenderer {...props} />);
const imgElement = screen.getByRole('img');
expect(imgElement).toBeInTheDocument();
expect(imgElement).toHaveAttribute('src', props.url);
expect(imgElement).toHaveAttribute('alt', props.fileName);
expect(imgElement).toHaveClass('image-renderer');
props.onError = jest.fn().mockName('this.props.onError');
props.onSuccess = jest.fn().mockName('this.props.onSuccess');
let el;
beforeEach(() => {
el = shallow(<ImageRenderer {...props} />);
});
it('calls onSuccess when image loads successfully', () => {
render(<ImageRenderer {...props} />);
const imgElement = screen.getByRole('img');
imgElement.dispatchEvent(new Event('load'));
expect(props.onSuccess).toHaveBeenCalled();
});
it('calls onError when image fails to load', () => {
render(<ImageRenderer {...props} />);
const imgElement = screen.getByRole('img');
imgElement.dispatchEvent(new Event('error'));
expect(props.onError).toHaveBeenCalled();
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
});

View File

@@ -1,15 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Document, Page, pdfjs } from 'react-pdf';
import { pdfjs, Document, Page } from 'react-pdf';
import {
Icon, Form, ActionRow, IconButton,
} from '@openedx/paragon';
import { ChevronLeft, ChevronRight } from '@openedx/paragon/icons';
} from '@edx/paragon';
import { ChevronLeft, ChevronRight } from '@edx/paragon/icons';
import pdfjsWorker from 'react-pdf/dist/esm/pdf.worker.entry';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import { rendererHooks } from './pdfHooks';
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`;
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker;
/**
* <PDFRenderer />

View File

@@ -1,22 +1,16 @@
import { Document, Page } from 'react-pdf';
import { render } from '@testing-library/react';
import PropTypes from 'prop-types';
import React from 'react';
import { shallow } from 'enzyme';
import PDFRenderer from './PDFRenderer';
import * as hooks from './pdfHooks';
jest.mock('react-pdf', () => ({
pdfjs: { GlobalWorkerOptions: {} },
Document: jest.fn(),
Page: jest.fn(),
Document: () => 'Document',
Page: () => 'Page',
}));
Document.mockImplementation((props) => <div data-testid="pdf-document">{props.children}</div>);
Document.propTypes = {
children: PropTypes.node,
};
Page.mockImplementation(() => <div data-testid="pdf-page">Page Content</div>);
jest.mock('./pdfHooks', () => ({
rendererHooks: jest.fn(),
}));
@@ -39,45 +33,25 @@ describe('PDF Renderer Component', () => {
onNextPageButtonClick: jest.fn().mockName('hooks.onNextPageButtonClick'),
onPrevPageButtonClick: jest.fn().mockName('hooks.onPrevPageButtonClick'),
hasNext: true,
hasPrev: false,
hasPref: false,
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('rendering', () => {
it('should render the PDF document with navigation controls', () => {
describe('snapshots', () => {
test('first page, prev is disabled', () => {
hooks.rendererHooks.mockReturnValue(hookProps);
const { getByTestId, getAllByText, container } = render(<PDFRenderer {...props} />);
expect(getByTestId('pdf-document')).toBeInTheDocument();
expect(getByTestId('pdf-page')).toBeInTheDocument();
expect(container.querySelector('input[type="number"]')).toBeInTheDocument();
expect(getAllByText(/Page/).length).toBeGreaterThan(0);
expect(getAllByText(`of ${hookProps.numPages}`).length).toBeGreaterThan(0);
expect(shallow(<PDFRenderer {...props} />)).toMatchSnapshot();
});
it('should have disabled previous button when on the first page', () => {
hooks.rendererHooks.mockReturnValue({
...hookProps,
hasPrev: false,
});
const { container } = render(<PDFRenderer {...props} />);
const prevButton = container.querySelector('button[aria-label="previous pdf page"]');
expect(prevButton).toBeDisabled();
});
it('should have disabled next button when on the last page', () => {
test('on last page, next is disabled', () => {
hooks.rendererHooks.mockReturnValue({
...hookProps,
pageNumber: hookProps.numPages,
hasNext: false,
hasPrev: true,
});
const { container } = render(<PDFRenderer {...props} />);
const nextButton = container.querySelector('button[aria-label="next pdf page"]');
expect(nextButton).toBeDisabled();
expect(shallow(<PDFRenderer {...props} />)).toMatchSnapshot();
});
});
});

View File

@@ -1,38 +1,23 @@
import { render } from '@testing-library/react';
import React from 'react';
import { shallow } from 'enzyme';
import TXTRenderer from './TXTRenderer';
jest.mock('./textHooks', () => {
const mockRendererHooks = jest.fn().mockReturnValue({ content: 'test-content' });
const content = 'test-content';
return {
rendererHooks: mockRendererHooks,
content,
rendererHooks: (args) => ({ content, rendererHooks: args }),
};
});
const textHooks = require('./textHooks');
describe('TXT Renderer Component', () => {
const props = {
url: 'some_url.txt',
onError: jest.fn().mockName('this.props.onError'),
onSuccess: jest.fn().mockName('this.props.onSuccess'),
};
beforeEach(() => {
textHooks.rendererHooks.mockClear();
});
it('renders the text content in a pre element', () => {
const { getByText, container } = render(<TXTRenderer {...props} />);
expect(getByText('test-content')).toBeInTheDocument();
expect(container.querySelector('pre')).toHaveClass('txt-renderer');
});
it('passes the correct props to rendererHooks', () => {
render(<TXTRenderer {...props} />);
expect(textHooks.rendererHooks).toHaveBeenCalledWith({
url: props.url,
onError: props.onError,
onSuccess: props.onSuccess,
});
test('snapshot', () => {
expect(shallow(<TXTRenderer {...props} />)).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Image Renderer Component snapshot 1`] = `
<img
alt=""
className="image-renderer"
onError={[MockFunction this.props.onError]}
onLoad={[MockFunction this.props.onSuccess]}
src="some_url.jpg"
/>
`;

View File

@@ -0,0 +1,137 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PDF Renderer Component snapshots first page, prev is disabled 1`] = `
<div
className="pdf-renderer"
>
<Document
file="some_url.pdf"
onLoadError={[MockFunction hooks.onDocumentLoadError]}
onLoadSuccess={[MockFunction hooks.onDocumentLoadSuccess]}
>
<div
className="page-wrapper"
style={
Object {
"height": 200,
}
}
>
<Page
onLoadSuccess={[MockFunction hooks.onLoadPageSuccess]}
pageNumber={1}
/>
</div>
</Document>
<ActionRow
className="d-flex justify-content-center m-0"
>
<IconButton
alt="previous pdf page"
disabled={true}
iconAs="Icon"
onClick={[MockFunction hooks.onPrevPageButtonClick]}
size="inline"
src={[MockFunction icons.ChevronLeft]}
/>
<Form.Group
className="d-flex align-items-center m-0"
>
<Form.Label
isInline={true}
>
Page
</Form.Label>
<Form.Control
max={10}
min={0}
onChange={[MockFunction hooks.onInputPageChange]}
type="number"
value={1}
/>
<Form.Label
isInline={true}
>
of
10
</Form.Label>
</Form.Group>
<IconButton
alt="next pdf page"
disabled={false}
iconAs="Icon"
onClick={[MockFunction hooks.onNextPageButtonClick]}
size="inline"
src={[MockFunction icons.ChevronRight]}
/>
</ActionRow>
</div>
`;
exports[`PDF Renderer Component snapshots on last page, next is disabled 1`] = `
<div
className="pdf-renderer"
>
<Document
file="some_url.pdf"
onLoadError={[MockFunction hooks.onDocumentLoadError]}
onLoadSuccess={[MockFunction hooks.onDocumentLoadSuccess]}
>
<div
className="page-wrapper"
style={
Object {
"height": 200,
}
}
>
<Page
onLoadSuccess={[MockFunction hooks.onLoadPageSuccess]}
pageNumber={10}
/>
</div>
</Document>
<ActionRow
className="d-flex justify-content-center m-0"
>
<IconButton
alt="previous pdf page"
disabled={false}
iconAs="Icon"
onClick={[MockFunction hooks.onPrevPageButtonClick]}
size="inline"
src={[MockFunction icons.ChevronLeft]}
/>
<Form.Group
className="d-flex align-items-center m-0"
>
<Form.Label
isInline={true}
>
Page
</Form.Label>
<Form.Control
max={10}
min={0}
onChange={[MockFunction hooks.onInputPageChange]}
type="number"
value={10}
/>
<Form.Label
isInline={true}
>
of
10
</Form.Label>
</Form.Group>
<IconButton
alt="next pdf page"
disabled={true}
iconAs="Icon"
onClick={[MockFunction hooks.onNextPageButtonClick]}
size="inline"
src={[MockFunction icons.ChevronRight]}
/>
</ActionRow>
</div>
`;

View File

@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TXT Renderer Component snapshot 1`] = `
<pre
className="txt-renderer"
>
test-content
</pre>
`;

View File

@@ -1,11 +1,16 @@
import { useState, useRef } from 'react';
import { pdfjs } from 'react-pdf';
import pdfjsWorker from 'react-pdf/dist/esm/pdf.worker.entry';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import { ErrorStatuses } from 'data/constants/requests';
import { StrictDict } from 'utils';
import * as module from './pdfHooks';
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker;
export const errors = StrictDict({
missingPDF: 'MissingPDFException',
});

View File

@@ -12,11 +12,6 @@ jest.mock('react-pdf', () => ({
Page: () => 'Page',
}));
jest.mock('react', () => ({
...jest.requireActual('react'),
useRef: jest.fn((val) => ({ current: val, useRef: true })),
}));
const state = new MockUseState(hooks);
const hookKeys = keyStore(hooks);

View File

@@ -18,7 +18,7 @@ export const fetchFile = async ({
onSuccess();
setContent(data);
})
.catch((e) => onError(e.response?.status));
.catch((e) => onError(e.response.status));
export const rendererHooks = ({ url, onError, onSuccess }) => {
const [content, setContent] = module.state.content('');

View File

@@ -10,11 +10,6 @@ jest.mock('axios', () => ({
get: jest.fn(),
}));
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })),
}));
const hookKeys = keyStore(hooks);
const state = new MockUseState(hooks);
@@ -86,12 +81,13 @@ describe('Text file preview hooks', () => {
});
});
describe('onError', () => {
it('calls get on the passed url when it changes', async () => {
it('calls get on the passed url when it changes', async (done) => {
axios.get.mockReturnValueOnce(Promise.reject(
{ response: { status: testValue } },
));
await hooks.fetchFile({ ...props, setContent: state.setState.content });
expect(props.onError).toHaveBeenCalledWith(testValue);
done();
});
});
});

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Card, Collapsible } from '@openedx/paragon';
import { Card, Collapsible } from '@edx/paragon';
import FilePopoverContent from 'components/FilePopoverContent';
import FileInfo from './FileInfo';
@@ -17,7 +17,7 @@ export const FileCard = ({ file, children }) => (
defaultOpen
title={<h3 className="file-card-title">{file.name}</h3>}
>
<div className="preview-panel" data-testid="preview-panel">
<div className="preview-panel">
<FileInfo><FilePopoverContent {...file} /></FileInfo>
{children}
</div>

View File

@@ -1,5 +1,7 @@
@import "@edx/paragon/scss/core/core";
.file-card {
margin: var(--pgn-spacing-spacer-1) 0;
margin: map-get($spacers, 1) 0;
.file-card-title {
text-overflow: ellipsis;
@@ -24,8 +26,8 @@
white-space: pre-wrap;
}
@media (--pgn-size-breakpoint-max-width-sm) {
@include media-breakpoint-down(sm) {
.file-card-title {
width: calc(var(--pgn-size-container-max-width-sm)/2);
width: map-get($container-max-widths, "sm")/2;
}
}
}

View File

@@ -1,4 +1,10 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { shallow } from 'enzyme';
import { Collapsible } from '@edx/paragon';
import FilePopoverContent from 'components/FilePopoverContent';
import FileInfo from './FileInfo';
import FileCard from './FileCard';
jest.mock('components/FilePopoverContent', () => 'FilePopoverContent');
@@ -13,27 +19,24 @@ describe('File Preview Card component', () => {
},
};
const children = (<h1>some children</h1>);
let el;
beforeEach(() => {
el = shallow(<FileCard {...props}>{children}</FileCard>);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
describe('Component', () => {
it('renders with the file name in the title', () => {
render(<FileCard {...props}>{children}</FileCard>);
expect(screen.getByText(props.file.name)).toBeInTheDocument();
expect(screen.getByText(props.file.name)).toHaveClass('file-card-title');
test('collapsible title is name header', () => {
const title = el.find(Collapsible).prop('title');
expect(title).toEqual(<h3 className="file-card-title">{props.file.name}</h3>);
});
it('renders the preview panel with file info', () => {
render(<FileCard {...props}>{children}</FileCard>);
const previewPanel = screen.getByTestId('preview-panel');
expect(previewPanel).toBeInTheDocument();
expect(document.querySelector('FileInfo')).toBeInTheDocument();
expect(document.querySelector('FilePopoverContent')).toBeInTheDocument();
});
it('renders children in the preview panel', () => {
render(<FileCard {...props}>{children}</FileCard>);
const previewPanel = screen.getByTestId('preview-panel');
expect(previewPanel).toBeInTheDocument();
expect(screen.getByText('some children')).toBeInTheDocument();
test('forwards children into preview-panel', () => {
const previewPanelChildren = el.find('.preview-panel').children();
expect(previewPanelChildren.at(0).equals(
<FileInfo><FilePopoverContent file={props.file} /></FileInfo>,
));
expect(previewPanelChildren.at(1).equals(children)).toEqual(true);
});
});
});

View File

@@ -5,8 +5,8 @@ import {
Button,
OverlayTrigger,
Popover,
} from '@openedx/paragon';
import { InfoOutline } from '@openedx/paragon/icons';
} from '@edx/paragon';
import { InfoOutline } from '@edx/paragon/icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { nullMethod } from 'hooks';
import messages from './messages';
@@ -26,7 +26,7 @@ export const FileInfo = ({ onClick, children }) => (
)}
>
<Button
size="sm"
size="small"
variant="tertiary"
onClick={onClick}
iconAfter={InfoOutline}

View File

@@ -1,29 +1,25 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithIntl } from '../../testUtils';
import React from 'react';
import { shallow } from 'enzyme';
import { Popover } from '@edx/paragon';
import FileInfo from './FileInfo';
import messages from './messages';
describe('FileInfo component', () => {
describe('File Preview Card component', () => {
const children = (<h1>some Children</h1>);
const props = { onClick: jest.fn().mockName('this.props.onClick') };
let el;
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<FileInfo {...props}>{children}</FileInfo>);
});
describe('Component rendering', () => {
it('renders the FileInfo button with correct text', () => {
renderWithIntl(<FileInfo {...props}>{children}</FileInfo>);
expect(screen.getByText(messages.fileInfo.defaultMessage)).toBeInTheDocument();
});
it('calls onClick when button is clicked', async () => {
renderWithIntl(<FileInfo {...props}>{children}</FileInfo>);
const user = userEvent.setup();
await user.click(screen.getByText(messages.fileInfo.defaultMessage));
expect(props.onClick).toHaveBeenCalledTimes(1);
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
describe('Component', () => {
test('overlay with passed children', () => {
const { overlay } = el.at(0).props();
expect(overlay.type).toEqual(Popover);
expect(overlay.props.children).toEqual(<Popover.Content>{children}</Popover.Content>);
});
});
});

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import FileCard from './FileCard';
import { ErrorBanner, LoadingBanner } from './Banners';
@@ -12,8 +12,8 @@ import { renderHooks } from './hooks';
*/
export const FileRenderer = ({
file,
intl,
}) => {
const intl = useIntl();
const {
Renderer,
isLoading,
@@ -39,6 +39,8 @@ FileRenderer.propTypes = {
name: PropTypes.string,
downloadUrl: PropTypes.string,
}).isRequired,
// injected
intl: intlShape.isRequired,
};
export default FileRenderer;
export default injectIntl(FileRenderer);

View File

@@ -1,79 +1,53 @@
import { screen } from '@testing-library/react';
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import { keyStore } from 'utils';
import { ErrorStatuses } from 'data/constants/requests';
import { renderWithIntl } from '../../testUtils';
import { FileRenderer } from './FileRenderer';
import * as hooks from './hooks';
jest.mock('./FileCard', () => 'FileCard');
jest.mock('./Banners', () => ({
ErrorBanner: () => 'ErrorBanner',
LoadingBanner: () => 'LoadingBanner',
}));
const hookKeys = keyStore(hooks);
const props = {
file: {
downloadUrl: 'file download url',
name: 'filename.txt',
description: 'A text file',
},
intl: { formatMessage },
};
describe('FileRenderer', () => {
describe('component', () => {
it('renders loading banner when isLoading is true', () => {
const hookProps = {
Renderer: () => <div data-testid="mock-renderer">Renderer Component</div>,
isLoading: true,
errorStatus: null,
error: null,
rendererProps: { prop: 'hooks.rendererProps' },
};
jest.spyOn(hooks, hookKeys.renderHooks).mockReturnValueOnce(hookProps);
renderWithIntl(<FileRenderer {...props} />);
expect(screen.getByText('filename.txt')).toBeInTheDocument();
expect(screen.getByTestId('mock-renderer')).toBeInTheDocument();
const spinner = document.querySelector('.spinner-border');
expect(spinner).toBeInTheDocument();
});
it('renders error banner when there is an error status', () => {
const errorProps = {
headingMessage: { id: 'error.heading', defaultMessage: 'Error Heading' },
children: 'Error Message',
actions: [{ id: 'retry', onClick: jest.fn(), message: { id: 'retry', defaultMessage: 'Retry' } }],
};
const hookProps = {
Renderer: () => <div data-testid="mock-renderer">Renderer Component</div>,
isLoading: false,
errorStatus: ErrorStatuses.serverError,
error: errorProps,
rendererProps: { prop: 'hooks.rendererProps' },
};
jest.spyOn(hooks, hookKeys.renderHooks).mockReturnValueOnce(hookProps);
renderWithIntl(<FileRenderer {...props} />);
expect(screen.getByText('filename.txt')).toBeInTheDocument();
expect(screen.getByText('Error Message')).toBeInTheDocument();
expect(document.querySelector('.alert-heading')).toBeInTheDocument();
expect(document.querySelector('.btn.btn-outline-primary')).toBeInTheDocument();
});
it('renders renderer component when not loading and no error', () => {
const hookProps = {
Renderer: () => <div data-testid="mock-renderer">Renderer Component</div>,
isLoading: false,
errorStatus: null,
error: null,
rendererProps: { prop: 'hooks.rendererProps' },
};
jest.spyOn(hooks, hookKeys.renderHooks).mockReturnValueOnce(hookProps);
renderWithIntl(<FileRenderer {...props} />);
expect(screen.getByText('filename.txt')).toBeInTheDocument();
expect(screen.getByTestId('mock-renderer')).toBeInTheDocument();
expect(screen.getByText('Renderer Component')).toBeInTheDocument();
const spinner = document.querySelector('.spinner-border');
expect(spinner).not.toBeInTheDocument();
describe('snapshot', () => {
test('isLoading, no Error', () => {
const hookProps = {
Renderer: () => 'Renderer',
isloading: true,
errorStatus: null,
error: null,
rendererProps: { prop: 'hooks.rendererProps' },
};
jest.spyOn(hooks, hookKeys.renderHooks).mockReturnValueOnce(hookProps);
expect(shallow(<FileRenderer {...props} />)).toMatchSnapshot();
});
test('is not loading, with error', () => {
const hookProps = {
Renderer: () => 'Renderer',
isloading: false,
errorStatus: ErrorStatuses.serverError,
error: { prop: 'hooks.errorProps' },
rendererProps: { prop: 'hooks.rendererProps' },
};
jest.spyOn(hooks, hookKeys.renderHooks).mockReturnValueOnce(hookProps);
expect(shallow(<FileRenderer {...props} />)).toMatchSnapshot();
});
});
});
});

View File

@@ -0,0 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`File Preview Card component snapshot 1`] = `
<Card
className="file-card"
key="test-file-name.pdf"
>
<Collapsible
className="file-collapsible"
defaultOpen={true}
title={
<h3
className="file-card-title"
>
test-file-name.pdf
</h3>
}
>
<div
className="preview-panel"
>
<FileInfo>
<FilePopoverContent
description="test-file description"
downloadUrl="destination/test-file-name.pdf"
name="test-file-name.pdf"
/>
</FileInfo>
<h1>
some children
</h1>
</div>
</Collapsible>
</Card>
`;

View File

@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`File Preview Card component snapshot 1`] = `
<OverlayTrigger
flip={true}
overlay={
<Popover
className="overlay-help-popover"
id="file-popover"
>
<Popover.Content>
<h1>
some Children
</h1>
</Popover.Content>
</Popover>
}
placement="right-end"
trigger="focus"
>
<Button
iconAfter={[MockFunction icons.InfoOutline]}
onClick={[MockFunction this.props.onClick]}
size="small"
variant="tertiary"
>
<FormattedMessage
defaultMessage="File info"
description="Popover trigger button text for file preview card"
id="ora-grading.InfoPopover.fileInfo"
/>
</Button>
</OverlayTrigger>
`;

View File

@@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FileRenderer component snapshot is not loading, with error 1`] = `
<FileCard
file={
Object {
"downloadUrl": "file download url",
"name": "filename.txt",
}
}
key="file download url"
>
<ErrorBanner
prop="hooks.errorProps"
/>
</FileCard>
`;
exports[`FileRenderer component snapshot isLoading, no Error 1`] = `
<FileCard
file={
Object {
"downloadUrl": "file download url",
"name": "filename.txt",
}
}
key="file download url"
>
<Renderer
prop="hooks.rendererProps"
/>
</FileCard>
`;

View File

@@ -79,7 +79,7 @@ export const renderHooks = ({
message: messages.retryButton,
};
const error = {
headingMessage: errorMessage,
headerMessage: errorMessage,
children: intl.formatMessage(errorMessage),
actions: [errorAction],
};

View File

@@ -55,7 +55,7 @@ describe('FilePreview hooks', () => {
});
describe('error', () => {
it('loads message from current error status, if valid, else from serverError', () => {
expect(hook.error.headingMessage).toEqual(
expect(hook.error.headerMessage).toEqual(
hooks.ERROR_STATUSES[ErrorStatuses.serverError],
);
expect(hook.error.children).toEqual(
@@ -63,7 +63,7 @@ describe('FilePreview hooks', () => {
);
state.mockVal(state.keys.errorStatus, ErrorStatuses.notFound);
hook = hooks.renderHooks({ intl: { formatMessage }, file });
expect(hook.error.headingMessage).toEqual(
expect(hook.error.headerMessage).toEqual(
hooks.ERROR_STATUSES[ErrorStatuses.notFound],
);
expect(hook.error.children).toEqual(

View File

@@ -0,0 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Head snapshot 1`] = `
<Helmet>
<title>
ORA staff grading | site-name
</title>
<link
href="favicon-url"
rel="shortcut icon"
type="image/x-icon"
/>
</Helmet>
`;

View File

@@ -1,45 +1,25 @@
import { render } from '@testing-library/react';
import { Helmet } from 'react-helmet';
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { shallow } from 'enzyme';
import Head from '.';
jest.mock('@edx/frontend-platform/i18n', () => ({
useIntl: () => ({
formatMessage: (message, values) => {
if (message.defaultMessage && values) {
return message.defaultMessage.replace('{siteName}', values.siteName);
}
return message.defaultMessage || message.id;
},
}),
defineMessages: (messages) => messages,
}));
jest.mock('react-helmet', () => ({
Helmet: jest.fn(),
Helmet: 'Helmet',
}));
Helmet.mockImplementation(({ children }) => <div data-testid="helmet-mock">{children}</div>);
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn().mockReturnValue({
getConfig: () => ({
SITE_NAME: 'site-name',
FAVICON_URL: 'favicon-url',
}),
}));
describe('Head', () => {
it('should render page title with site name from config', () => {
const { container } = render(<Head />);
const titleElement = container.querySelector('title');
expect(titleElement).toBeInTheDocument();
expect(titleElement.textContent).toContain('ORA staff grading | site-name');
});
it('snapshot', () => {
const el = shallow(<Head />);
expect(el).toMatchSnapshot();
it('should render favicon link with URL from config', () => {
const { container } = render(<Head />);
const faviconLink = container.querySelector('link[rel="shortcut icon"]');
expect(faviconLink).toBeInTheDocument();
expect(faviconLink.getAttribute('href')).toEqual('favicon-url');
expect(faviconLink.getAttribute('type')).toEqual('image/x-icon');
expect(el.find('title').text()).toContain(getConfig().SITE_NAME);
expect(el.find('link').prop('href')).toEqual(getConfig().FAVICON_URL);
});
});

View File

@@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Info Popover Component snapshot 1`] = `
<OverlayTrigger
flip={true}
overlay={
<Popover
className="overlay-help-popover"
id="info-popover"
>
<Popover.Content>
<div>
Children component
</div>
</Popover.Content>
</Popover>
}
placement="right-end"
trigger="focus"
>
<IconButton
alt="Display more info"
className="esg-help-icon"
iconAs="Icon"
onClick={[MockFunction this.props.onClick]}
src={[MockFunction icons.InfoOutline]}
/>
</OverlayTrigger>
`;

View File

@@ -6,9 +6,9 @@ import {
Popover,
Icon,
IconButton,
} from '@openedx/paragon';
import { InfoOutline } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
} from '@edx/paragon';
import { InfoOutline } from '@edx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { nullMethod } from 'hooks';
@@ -17,35 +17,26 @@ import messages from './messages';
/**
* <InfoPopover />
*/
export const InfoPopover = (
{
onClick,
children,
},
) => {
const intl = useIntl();
return (
<OverlayTrigger
trigger="focus"
placement="left-end"
flip
overlay={(
<Popover id="info-popover" className="overlay-help-popover">
<Popover.Content>{children}</Popover.Content>
</Popover>
)}
>
<IconButton
className="esg-help-icon"
data-testid="esg-help-icon"
src={InfoOutline}
alt={intl.formatMessage(messages.altText)}
iconAs={Icon}
onClick={onClick}
/>
</OverlayTrigger>
);
};
export const InfoPopover = ({ onClick, children, intl }) => (
<OverlayTrigger
trigger="focus"
placement="right-end"
flip
overlay={(
<Popover id="info-popover" className="overlay-help-popover">
<Popover.Content>{children}</Popover.Content>
</Popover>
)}
>
<IconButton
className="esg-help-icon"
src={InfoOutline}
alt={intl.formatMessage(messages.altText)}
iconAs={Icon}
onClick={onClick}
/>
</OverlayTrigger>
);
InfoPopover.defaultProps = {
onClick: nullMethod,
@@ -56,6 +47,7 @@ InfoPopover.propTypes = {
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
intl: intlShape.isRequired,
};
export default InfoPopover;
export default injectIntl(InfoPopover);

View File

@@ -1,31 +1,23 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithIntl } from '../../testUtils';
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import { InfoPopover } from '.';
describe('Info Popover Component', () => {
const child = <div>Children component</div>;
const onClick = jest.fn().mockName('this.props.onClick');
let el;
beforeEach(() => {
el = shallow(<InfoPopover onClick={onClick} intl={{ formatMessage }}>{child}</InfoPopover>);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
describe('Component', () => {
it('renders the help icon button', () => {
renderWithIntl(
<InfoPopover onClick={onClick}>
{child}
</InfoPopover>,
);
expect(screen.getByTestId('esg-help-icon')).toBeInTheDocument();
});
it('calls onClick when the help icon is clicked', async () => {
renderWithIntl(
<InfoPopover onClick={onClick}>
{child}
</InfoPopover>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('esg-help-icon'));
expect(onClick).toHaveBeenCalled();
test('Test component render', () => {
expect(el.length).toEqual(1);
expect(el.find('.esg-help-icon').length).toEqual(1);
});
});
});

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Spinner } from '@openedx/paragon';
import { Spinner } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
/**

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Badge } from '@openedx/paragon';
import { Badge } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { StrictDict } from 'utils';

View File

@@ -1,36 +1,34 @@
import { screen } from '@testing-library/react';
import React from 'react';
import { shallow } from 'enzyme';
import { gradingStatuses } from 'data/services/lms/constants';
import messages from '../data/services/lms/messages';
import { renderWithIntl } from '../testUtils';
import { StatusBadge } from './StatusBadge';
const className = 'test-className';
describe('StatusBadge component', () => {
const render = (status) => shallow(<StatusBadge className={className} status={status} />);
describe('behavior', () => {
it('does not render if status does not have configured variant', () => {
const { container } = renderWithIntl(<StatusBadge className={className} status="arbitrary" />);
expect(container.firstChild).toBeNull();
const el = render('arbitrary');
expect(el).toMatchSnapshot();
expect(el.isEmptyRender()).toEqual(true);
});
describe('status rendering: loads badge with configured variant and message', () => {
it('`ungraded` shows primary button variant and message', () => {
renderWithIntl(<StatusBadge className={className} status={gradingStatuses.ungraded} />);
const badge = screen.getByText(messages.ungraded.defaultMessage);
expect(badge).toHaveClass('badge-primary');
describe('status snapshots: loads badge with configured variant and message.', () => {
test('`ungraded` shows primary button variant and message', () => {
const el = render(gradingStatuses.ungraded);
expect(el).toMatchSnapshot();
});
it('`locked` shows light button variant and message', () => {
renderWithIntl(<StatusBadge className={className} status={gradingStatuses.locked} />);
const badge = screen.getByText(messages.locked.defaultMessage);
expect(badge).toHaveClass('badge-light');
test('`locked` shows light button variant and message', () => {
const el = render(gradingStatuses.locked);
expect(el).toMatchSnapshot();
});
it('`graded` shows success button variant and message', () => {
renderWithIntl(<StatusBadge className={className} status={gradingStatuses.graded} />);
const badge = screen.getByText(messages.graded.defaultMessage);
expect(badge).toHaveClass('badge-success');
test('`graded` shows success button variant and message', () => {
const el = render(gradingStatuses.graded);
expect(el).toMatchSnapshot();
});
it('`inProgress` shows warning button variant and message', () => {
renderWithIntl(<StatusBadge className={className} status={gradingStatuses.inProgress} />);
const badge = screen.getByText(messages['in-progress'].defaultMessage);
expect(badge).toHaveClass('badge-warning');
test('`inProgress` shows warning button variant and message', () => {
const el = render(gradingStatuses.inProgress);
expect(el).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,59 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ConfirmModal snapshot: closed 1`] = `
<AlertModal
className="confirm-modal"
footerNode={
<ActionRow>
<Button
onClick={[MockFunction this.props.onCancel]}
variant="tertiary"
>
test-cancel-text
</Button>
<Button
onClick={[MockFunction this.props.onConfirm]}
variant="primary"
>
test-confirm-text
</Button>
</ActionRow>
}
isOpen={false}
onClose={[MockFunction hooks.nullMethod]}
title="test-title"
>
<p>
test-content
</p>
</AlertModal>
`;
exports[`ConfirmModal snapshot: open 1`] = `
<AlertModal
className="confirm-modal"
footerNode={
<ActionRow>
<Button
onClick={[MockFunction this.props.onCancel]}
variant="tertiary"
>
test-cancel-text
</Button>
<Button
onClick={[MockFunction this.props.onConfirm]}
variant="primary"
>
test-confirm-text
</Button>
</ActionRow>
}
isOpen={true}
onClose={[MockFunction hooks.nullMethod]}
title="test-title"
>
<p>
test-content
</p>
</AlertModal>
`;

View File

@@ -0,0 +1,55 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StatusBadge component behavior does not render if status does not have configured variant 1`] = `""`;
exports[`StatusBadge component behavior status snapshots: loads badge with configured variant and message. \`graded\` shows success button variant and message 1`] = `
<Badge
className="test-className"
variant="success"
>
<FormattedMessage
defaultMessage="Grading Completed"
description="Grading status label for graded submission"
id="ora-grading.lms-api.gradingStatusDisplay.graded"
/>
</Badge>
`;
exports[`StatusBadge component behavior status snapshots: loads badge with configured variant and message. \`inProgress\` shows warning button variant and message 1`] = `
<Badge
className="test-className"
variant="warning"
>
<FormattedMessage
defaultMessage="You are currently grading this response"
description="Grading status label for in-progress submission"
id="ora-grading.lms-api.gradingStatusDisplay.inProgress"
/>
</Badge>
`;
exports[`StatusBadge component behavior status snapshots: loads badge with configured variant and message. \`locked\` shows light button variant and message 1`] = `
<Badge
className="test-className"
variant="light"
>
<FormattedMessage
defaultMessage="Currently being graded by someone else"
description="Grading status label for locked submission"
id="ora-grading.lms-api.gradingStatusDisplay.locked"
/>
</Badge>
`;
exports[`StatusBadge component behavior status snapshots: loads badge with configured variant and message. \`ungraded\` shows primary button variant and message 1`] = `
<Badge
className="test-className"
variant="primary"
>
<FormattedMessage
defaultMessage="Ungraded"
description="Grading status label for ungraded submission"
id="ora-grading.lms-api.gradingStatusDisplay.ungraded"
/>
</Badge>
`;

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { shallow } from 'enzyme';
import { CTA } from '.';
describe('CTA component', () => {
test('snapshots', () => {
const el = shallow(<CTA hide />);
expect(el).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CTA component snapshots 1`] = `
<PageBanner>
<span>
<FormattedMessage
defaultMessage="Thanks for using the new ORA staff grading experience. "
description="Thank user for using ora and ask for feed back"
id="ora-grading.CTA.feedbackMessage"
/>
<Hyperlink
destination="https://docs.google.com/forms/d/1Hu1rgJcCHl5_EtDb5Up3hiZ40sSUtkZQfRHJ3fWOvfQ/edit"
isInline={true}
showLaunchIcon={false}
target="_blank"
variant="muted"
>
<FormattedMessage
defaultMessage="Provide some feedback"
description="placeholder for the feedback anchor link"
id="ora-grading.CTA.linkMessage"
/>
</Hyperlink>
<FormattedMessage
defaultMessage=" and let us know what you think!"
description="inform user to provide feedback"
id="ora-grading.CTA.letUsKnowMessage"
/>
</span>
</PageBanner>
`;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { PageBanner, Hyperlink } from '@edx/paragon';
import messages from './messages';
/**
* <CTA />
*/
export const CTA = () => (
<PageBanner>
<span>
<FormattedMessage {...messages.ctaFeedbackMessage} />
<Hyperlink
isInline
variant="muted"
destination="https://docs.google.com/forms/d/1Hu1rgJcCHl5_EtDb5Up3hiZ40sSUtkZQfRHJ3fWOvfQ/edit"
target="_blank"
showLaunchIcon={false}
>
<FormattedMessage {...messages.ctaLinkMessage} />
</Hyperlink>
<FormattedMessage {...messages.ctaLetUsKnowMessage} />
</span>
</PageBanner>
);
export default CTA;

View File

@@ -0,0 +1,23 @@
/* eslint-disable quotes */
import { defineMessages } from '@edx/frontend-platform/i18n';
import { StrictDict } from 'utils';
const messages = defineMessages({
ctaFeedbackMessage: {
id: 'ora-grading.CTA.feedbackMessage',
defaultMessage: 'Thanks for using the new ORA staff grading experience. ',
description: 'Thank user for using ora and ask for feed back',
},
ctaLinkMessage: {
id: 'ora-grading.CTA.linkMessage',
defaultMessage: 'Provide some feedback',
description: 'placeholder for the feedback anchor link',
},
ctaLetUsKnowMessage: {
id: 'ora-grading.CTA.letUsKnowMessage',
defaultMessage: ' and let us know what you think!',
description: 'inform user to provide feedback',
},
});
export default StrictDict(messages);

View File

@@ -2,8 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Form } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Form } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { feedbackRequirement } from 'data/services/lms/constants';
import { actions, selectors } from 'data/redux';
@@ -12,56 +12,59 @@ import messages from './messages';
/**
* <CriterionFeedback />
*/
export const CriterionFeedback = ({
orderNum,
isGrading,
config,
setValue,
value,
isInvalid,
}) => {
const intl = useIntl();
const onChange = (event) => {
setValue({
value: event.target.value,
orderNum,
});
};
const translate = (msg) => intl.formatMessage(msg);
const getCommentMessage = () => {
let commentMessage = translate(isGrading ? messages.addComments : messages.comments);
if (config === feedbackRequirement.optional) {
commentMessage += ` ${translate(messages.optional)}`;
}
return commentMessage;
};
if (config === feedbackRequirement.disabled) {
return null;
export class CriterionFeedback extends React.Component {
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this);
}
return (
<Form.Group isInvalid={isInvalid}>
<Form.Control
as="textarea"
className="criterion-feedback feedback-input"
data-testid="criterion-feedback-input"
floatingLabel={getCommentMessage()}
value={value}
onChange={onChange}
disabled={!isGrading}
/>
{isInvalid && (
<Form.Control.Feedback type="invalid" className="feedback-error-msg" data-testid="criterion-feedback-error-msg">
{translate(messages.criterionFeedbackError)}
</Form.Control.Feedback>
)}
</Form.Group>
);
};
onChange(event) {
this.props.setValue({
value: event.target.value,
orderNum: this.props.orderNum,
});
}
get commentMessage() {
const { config, isGrading } = this.props;
let commentMessage = this.translate(isGrading ? messages.addComments : messages.comments);
if (config === feedbackRequirement.optional) {
commentMessage += ` ${this.translate(messages.optional)}`;
}
return commentMessage;
}
translate = (msg) => this.props.intl.formatMessage(msg);
render() {
const {
config,
isGrading,
value,
isInvalid,
} = this.props;
if (config === feedbackRequirement.disabled) {
return null;
}
return (
<Form.Group isInvalid={this.feedbackIsInvalid}>
<Form.Control
as="textarea"
className="criterion-feedback feedback-input"
floatingLabel={this.commentMessage}
value={value}
onChange={this.onChange}
disabled={!isGrading}
/>
{isInvalid && (
<Form.Control.Feedback type="invalid" className="feedback-error-msg">
{this.translate(messages.criterionFeedbackError)}
</Form.Control.Feedback>
)}
</Form.Group>
);
}
}
CriterionFeedback.defaultProps = {
value: '',
@@ -70,6 +73,8 @@ CriterionFeedback.defaultProps = {
CriterionFeedback.propTypes = {
orderNum: PropTypes.number.isRequired,
isGrading: PropTypes.bool.isRequired,
// injected
intl: intlShape.isRequired,
// redux
config: PropTypes.string.isRequired,
setValue: PropTypes.func.isRequired,
@@ -87,4 +92,6 @@ export const mapDispatchToProps = {
setValue: actions.grading.setCriterionFeedback,
};
export default connect(mapStateToProps, mapDispatchToProps)(CriterionFeedback);
export default injectIntl(
connect(mapStateToProps, mapDispatchToProps)(CriterionFeedback),
);

View File

@@ -1,18 +1,18 @@
import React from 'react';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { shallow } from 'enzyme';
import { actions, selectors } from 'data/redux';
import {
feedbackRequirement,
gradeStatuses,
} from 'data/services/lms/constants';
import { formatMessage } from 'testUtils';
import {
CriterionFeedback,
mapStateToProps,
mapDispatchToProps,
} from './CriterionFeedback';
import { renderWithIntl } from '../../testUtils';
import messages from './messages';
jest.mock('data/redux/app/selectors', () => ({
rubric: {
@@ -36,6 +36,7 @@ jest.mock('data/redux/grading/selectors', () => ({
describe('Criterion Feedback', () => {
const props = {
intl: { formatMessage },
orderNum: 1,
config: 'config string',
isGrading: true,
@@ -44,72 +45,141 @@ describe('Criterion Feedback', () => {
setValue: jest.fn().mockName('this.props.setValue'),
isInvalid: false,
};
let el;
beforeEach(() => {
el = shallow(<CriterionFeedback {...props} />);
el.instance().onChange = jest.fn().mockName('this.onChange');
});
describe('snapshot', () => {
test('is grading', () => {
expect(el.instance().render()).toMatchSnapshot();
});
test('is graded', () => {
el.setProps({
isGrading: false,
gradeStatus: gradeStatuses.graded,
});
expect(el.instance().render()).toMatchSnapshot();
});
test('feedback value is invalid', () => {
el.setProps({
isInvalid: true,
});
expect(el.instance().render()).toMatchSnapshot();
});
Object.values(feedbackRequirement).forEach((requirement) => {
test(`feedback is configured to ${requirement}`, () => {
el.setProps({
config: requirement,
});
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('component', () => {
describe('render', () => {
it('shows a non-disabled input when grading', () => {
renderWithIntl(<CriterionFeedback {...props} />);
const input = screen.getByTestId('criterion-feedback-input');
expect(input).toBeInTheDocument();
expect(input).not.toBeDisabled();
expect(input).toHaveValue(props.value);
test('is grading (the feedback input is not disabled)', () => {
expect(el.isEmptyRender()).toEqual(false);
expect(el.instance().props.value).toEqual(props.value);
const controlEl = el.find('.feedback-input');
expect(controlEl.prop('disabled')).toEqual(false);
expect(controlEl.prop('value')).toEqual(props.value);
});
it('shows a disabled input when not grading', () => {
renderWithIntl(
<CriterionFeedback {...props} isGrading={false} gradeStatus={gradeStatuses.graded} />,
);
const input = screen.getByTestId('criterion-feedback-input');
expect(input).toBeInTheDocument();
expect(input).toBeDisabled();
expect(input).toHaveValue(props.value);
test('is graded (the input is disabled)', () => {
el.setProps({
isGrading: false,
gradeStatus: gradeStatuses.graded,
});
expect(el.instance().props.value).toEqual(props.value);
const controlEl = el.find('.feedback-input');
expect(controlEl.prop('disabled')).toEqual(true);
expect(controlEl.prop('value')).toEqual(props.value);
});
it('displays an error message when feedback is invalid', () => {
renderWithIntl(<CriterionFeedback {...props} isInvalid />);
expect(screen.getByTestId('criterion-feedback-error-msg')).toBeInTheDocument();
test('is having invalid feedback (feedback get render)', () => {
el.setProps({
isInvalid: true,
});
const feedbackErrorEl = el.find('.feedback-error-msg');
expect(el.instance().props.isInvalid).toEqual(true);
expect(feedbackErrorEl).toBeDefined();
});
it('does not render anything when config is set to disabled', () => {
const { container } = renderWithIntl(
<CriterionFeedback {...props} config={feedbackRequirement.disabled} />,
);
expect(container.firstChild).toBeNull();
test('is configure to disabled (the input does not get render)', () => {
el.setProps({
config: feedbackRequirement.disabled,
});
expect(el.isEmptyRender()).toEqual(true);
});
});
describe('behavior', () => {
it('calls setValue when input value changes', async () => {
renderWithIntl(<CriterionFeedback {...props} />);
const user = userEvent.setup();
const input = screen.getByTestId('criterion-feedback-input');
await user.clear(input);
expect(props.setValue).toHaveBeenCalledWith({
value: '',
orderNum: props.orderNum,
test('onChange call set value', () => {
el = shallow(<CriterionFeedback {...props} />);
el.instance().onChange({
target: {
value: 'some value',
},
});
expect(props.setValue).toBeCalledTimes(1);
});
});
describe('getter commentMessage', () => {
test('is grading', () => {
el.setProps({ config: feedbackRequirement.optional, isGrading: true });
expect(el.instance().commentMessage).toContain(
messages.optional.defaultMessage,
);
el.setProps({ config: feedbackRequirement.required });
expect(el.instance().commentMessage).not.toContain(
messages.optional.defaultMessage,
);
expect(el.instance().commentMessage).toContain(
messages.addComments.defaultMessage,
);
});
test('is not grading', () => {
el.setProps({ config: feedbackRequirement.optional, isGrading: false });
expect(el.instance().commentMessage).toContain(
messages.optional.defaultMessage,
);
el.setProps({ config: feedbackRequirement.required });
expect(el.instance().commentMessage).not.toContain(
messages.optional.defaultMessage,
);
expect(el.instance().commentMessage).toContain(
messages.comments.defaultMessage,
);
});
});
});
describe('mapStateToProps', () => {
const testState = { arbitraryState: 'some data' };
const testState = { abitaryState: 'some data' };
const ownProps = { orderNum: props.orderNum };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState, ownProps);
});
it('gets config from selectors.app.rubric.criterionFeedbackConfig', () => {
test('selectors.app.rubric.criterionFeedbackConfig', () => {
expect(mapped.config).toEqual(
selectors.app.rubric.criterionFeedbackConfig(testState, ownProps),
);
});
it('gets value from selectors.grading.selected.criterionFeedback', () => {
test('selector.grading.selected.criterionFeedback', () => {
expect(mapped.value).toEqual(
selectors.grading.selected.criterionFeedback(testState, ownProps),
);
});
it('gets isInvalid from selectors.grading.validation.criterionFeedbackIsInvalid', () => {
test('selector.grading.validation.criterionFeedbackIsInvalid', () => {
expect(mapped.isInvalid).toEqual(
selectors.grading.validation.criterionFeedbackIsInvalid(
testState,
@@ -120,7 +190,7 @@ describe('Criterion Feedback', () => {
});
describe('mapDispatchToProps', () => {
it('maps actions.grading.setCriterionFeedback to setValue prop', () => {
test('maps actions.grading.setCriterionFeedback to setValue prop', () => {
expect(mapDispatchToProps.setValue).toEqual(
actions.grading.setCriterionFeedback,
);

View File

@@ -2,8 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Form } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Form } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { actions, selectors } from 'data/redux';
import messages from './messages';
@@ -11,46 +11,50 @@ import messages from './messages';
/**
* <RadioCriterion />
*/
export const RadioCriterion = ({
orderNum,
isGrading,
config,
data,
setCriterionOption,
isInvalid,
}) => {
const intl = useIntl();
export class RadioCriterion extends React.Component {
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this);
}
const onChange = (event) => {
setCriterionOption({
orderNum,
onChange(event) {
this.props.setCriterionOption({
orderNum: this.props.orderNum,
value: event.target.value,
});
};
}
return (
<Form.RadioSet name={config.name} value={data}>
{config.options.map((option) => (
<Form.Radio
className="criteria-option align-items-center"
key={option.name}
value={option.name}
description={intl.formatMessage(messages.optionPoints, { points: option.points })}
onChange={onChange}
disabled={!isGrading}
style={{ flexShrink: 0 }}
>
{option.label}
</Form.Radio>
))}
{isInvalid && (
<Form.Control.Feedback type="invalid" className="feedback-error-msg">
{intl.formatMessage(messages.rubricSelectedError)}
</Form.Control.Feedback>
)}
</Form.RadioSet>
);
};
render() {
const {
config,
data,
intl,
isGrading,
isInvalid,
} = this.props;
return (
<Form.RadioSet name={config.name} value={data}>
{config.options.map((option) => (
<Form.Radio
className="criteria-option"
key={option.name}
value={option.name}
description={intl.formatMessage(messages.optionPoints, { points: option.points })}
onChange={this.onChange}
disabled={!isGrading}
>
{option.label}
</Form.Radio>
))}
{isInvalid && (
<Form.Control.Feedback type="invalid" className="feedback-error-msg">
{intl.formatMessage(messages.rubricSelectedError)}
</Form.Control.Feedback>
)}
</Form.RadioSet>
);
}
}
RadioCriterion.defaultProps = {
data: {
@@ -62,6 +66,8 @@ RadioCriterion.defaultProps = {
RadioCriterion.propTypes = {
orderNum: PropTypes.number.isRequired,
isGrading: PropTypes.bool.isRequired,
// injected
intl: intlShape.isRequired,
// redux
config: PropTypes.shape({
prompt: PropTypes.string,
@@ -92,4 +98,4 @@ export const mapDispatchToProps = {
setCriterionOption: actions.grading.setCriterionOption,
};
export default connect(mapStateToProps, mapDispatchToProps)(RadioCriterion);
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(RadioCriterion));

View File

@@ -1,12 +1,13 @@
import { screen } from '@testing-library/react';
import React from 'react';
import { shallow } from 'enzyme';
import { actions, selectors } from 'data/redux';
import { formatMessage } from 'testUtils';
import {
RadioCriterion,
mapDispatchToProps,
mapStateToProps,
} from './RadioCriterion';
import { renderWithIntl } from '../../testUtils';
jest.mock('data/redux/app/selectors', () => ({
rubric: {
@@ -30,6 +31,7 @@ jest.mock('data/redux/grading/selectors', () => ({
describe('Radio Criterion Container', () => {
const props = {
intl: { formatMessage },
orderNum: 1,
isGrading: true,
config: {
@@ -38,14 +40,14 @@ describe('Radio Criterion Container', () => {
feedback: 'feedback mock',
options: [
{
explanation: 'explanation',
explanation: 'explaination',
feedback: 'option feedback',
label: 'this label',
name: 'option name',
points: 1,
},
{
explanation: 'explanation 2',
explanation: 'explaination 2',
feedback: 'option feedback 2',
label: 'this label 2',
name: 'option name 2',
@@ -53,70 +55,98 @@ describe('Radio Criterion Container', () => {
},
],
},
data: 'option name',
data: 'selected radio option',
setCriterionOption: jest.fn().mockName('this.props.setCriterionOption'),
isInvalid: false,
};
describe('component rendering', () => {
it('should render radio buttons that are enabled when in grading mode', () => {
const { container } = renderWithIntl(<RadioCriterion {...props} />);
let el;
beforeEach(() => {
el = shallow(<RadioCriterion {...props} />);
el.instance().onChange = jest.fn().mockName('this.onChange');
});
describe('snapshot', () => {
test('is grading', () => {
expect(el.instance().render()).toMatchSnapshot();
});
const radioButtons = container.querySelectorAll('input[type="radio"]');
expect(radioButtons.length).toEqual(props.config.options.length);
test('is not grading', () => {
el.setProps({
isGrading: false,
});
expect(el.instance().render()).toMatchSnapshot();
});
radioButtons.forEach(button => {
expect(button).not.toBeDisabled();
test('radio contain invalid response', () => {
el.setProps({
isInvalid: true,
});
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('component', () => {
describe('rendering', () => {
test('is grading (all options are not disabled)', () => {
expect(el.isEmptyRender()).toEqual(false);
const optionsEl = el.find('.criteria-option');
expect(optionsEl.length).toEqual(props.config.options.length);
optionsEl.forEach((optionEl) => expect(optionEl.prop('disabled')).toEqual(false));
});
test('is not grading (all options are disabled)', () => {
el.setProps({
isGrading: false,
});
expect(el.isEmptyRender()).toEqual(false);
const optionsEl = el.find('.criteria-option');
expect(optionsEl.length).toEqual(props.config.options.length);
optionsEl.forEach((optionEl) => expect(optionEl.prop('disabled')).toEqual(true));
});
test('radio contain invalid response (error response get render)', () => {
el.setProps({
isInvalid: true,
});
expect(el.isEmptyRender()).toEqual(false);
const radioErrorEl = el.find('.feedback-error-msg');
expect(el.instance().props.isInvalid).toEqual(true);
expect(radioErrorEl).toBeDefined();
});
});
it('should render radio buttons that are disabled when not in grading mode', () => {
renderWithIntl(<RadioCriterion {...props} isGrading={false} />);
const radioButtons = screen.queryAllByRole('radio');
expect(radioButtons.length).toEqual(props.config.options.length);
radioButtons.forEach(button => {
expect(button).toBeDisabled();
describe('behavior', () => {
test('onChange call set crition option', () => {
el = shallow(<RadioCriterion {...props} />);
el.instance().onChange({
target: {
value: 'some value',
},
});
expect(props.setCriterionOption).toBeCalledTimes(1);
});
});
it('should render an error message when the criterion is invalid', () => {
const { container } = renderWithIntl(<RadioCriterion {...props} isInvalid />);
const errorMessage = container.querySelector('.feedback-error-msg');
expect(errorMessage).toBeInTheDocument();
});
it('should not render an error message when the criterion is valid', () => {
const { container } = renderWithIntl(<RadioCriterion {...props} />);
const errorMessage = container.querySelector('.feedback-error-msg');
expect(errorMessage).not.toBeInTheDocument();
});
});
describe('mapStateToProps', () => {
const testState = { arbitrary: 'some data' };
const testState = { arbitary: 'some data' };
const ownProps = { orderNum: props.orderNum };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState, ownProps);
});
it('should properly map config from rubric criterion config selector', () => {
test('selectors.app.rubric.criterionConfig', () => {
expect(mapped.config).toEqual(
selectors.app.rubric.criterionConfig(testState, ownProps),
);
});
it('should properly map data from selected criterion option selector', () => {
test('selectors.grading.selected.criterionSelectedOption', () => {
expect(mapped.data).toEqual(
selectors.grading.selected.criterionSelectedOption(testState, ownProps),
);
});
it('should properly map isInvalid from criterion validation selector', () => {
test('selectors.grading.validation.criterionSelectedOptionIsInvalid', () => {
expect(mapped.isInvalid).toEqual(
selectors.grading.validation.criterionSelectedOptionIsInvalid(testState, ownProps),
);
@@ -124,7 +154,7 @@ describe('Radio Criterion Container', () => {
});
describe('mapDispatchToProps', () => {
it('should map setCriterionOption action to props', () => {
test('maps actions.grading.setCriterionFeedback to setValue prop', () => {
expect(mapDispatchToProps.setCriterionOption).toEqual(
actions.grading.setCriterionOption,
);

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Form, FormControlFeedback } from '@openedx/paragon';
import { Form, FormControlFeedback } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { selectors } from 'data/redux';
@@ -14,10 +14,10 @@ import messages from './messages';
export const ReviewCriterion = ({ config }) => (
<div className="review-criterion">
{config.options.map((option) => (
<div key={option.name} className="criteria-option" data-testid="criteria-option">
<div key={option.name} className="criteria-option">
<div>
<Form.Label className="option-label" data-testid="option-label">{option.label}</Form.Label>
<FormControlFeedback className="option-points" data-testid="option-points">
<Form.Label className="option-label">{option.label}</Form.Label>
<FormControlFeedback className="option-points">
<FormattedMessage {...messages.optionPoints} values={{ points: option.points }} />
</FormControlFeedback>
</div>

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { screen } from '@testing-library/react';
import { shallow } from 'enzyme';
import { selectors } from 'data/redux';
import { renderWithIntl } from '../../testUtils';
import { ReviewCriterion, mapStateToProps } from './ReviewCriterion';
import messages from './messages';
jest.mock('data/redux/app/selectors', () => ({
rubric: {
@@ -20,7 +20,7 @@ jest.mock('data/redux/grading/selectors', () => ({
},
}));
describe('Review Criterion Container', () => {
describe('Review Crition Container', () => {
const props = {
orderNum: 1,
config: {
@@ -29,14 +29,14 @@ describe('Review Criterion Container', () => {
feedback: 'feedback mock',
options: [
{
explanation: 'explanation',
explanation: 'explaination',
feedback: 'option feedback',
label: 'this label',
name: 'option name',
points: 1,
},
{
explanation: 'explanation 2',
explanation: 'explaination 2',
feedback: 'option feedback 2',
label: 'this label 2',
name: 'option name 2',
@@ -50,40 +50,47 @@ describe('Review Criterion Container', () => {
},
};
let el;
beforeEach(() => {
el = shallow(<ReviewCriterion {...props} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
describe('component', () => {
it('renders all criteria options with correct labels and points', () => {
renderWithIntl(<ReviewCriterion {...props} />);
const optionsElements = screen.getAllByTestId('criteria-option');
expect(optionsElements.length).toEqual(props.config.options.length);
props.config.options.forEach((option, index) => {
const optionElement = optionsElements[index];
const labelElement = optionElement.querySelector('[data-testid="option-label"]');
const pointsElement = optionElement.querySelector('[data-testid="option-points"]');
expect(labelElement.textContent).toEqual(option.label);
expect(pointsElement.textContent).toEqual(`${props.config.options[index].points} points`);
test('rendering (everything show up)', () => {
expect(el.isEmptyRender()).toEqual(false);
const optionsEl = el.find('.criteria-option');
expect(optionsEl.length).toEqual(props.config.options.length);
optionsEl.forEach((optionEl, i) => {
const option = props.config.options[i];
expect(optionEl.key()).toEqual(option.name);
expect(optionEl.find('.option-label').childAt(0).text()).toEqual(
option.label,
);
expect(optionEl.find('.option-points').childAt(0).props()).toEqual({
...messages.optionPoints,
values: { points: option.points },
});
});
});
});
describe('mapStateToProps', () => {
const testState = { arbitrary: 'some data' };
const testState = { arbitary: 'some data' };
const ownProps = { orderNum: props.orderNum };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState, ownProps);
});
it('should map criterion config from state', () => {
test('selectors.app.rubric.criterionConfig', () => {
expect(mapped.config).toEqual(
selectors.app.rubric.criterionConfig(testState, ownProps),
);
});
it('should map criterion grade data from state', () => {
test('selectors.grading.selected.criterionGradeData', () => {
expect(mapped.data).toEqual(
selectors.grading.selected.criterionGradeData(testState, ownProps),
);

View File

@@ -0,0 +1,74 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Criterion Feedback snapshot feedback is configured to disabled 1`] = `null`;
exports[`Criterion Feedback snapshot feedback is configured to optional 1`] = `
<Form.Group>
<Form.Control
as="textarea"
className="criterion-feedback feedback-input"
disabled={false}
floatingLabel="Add comments (Optional)"
onChange={[MockFunction this.onChange]}
value="criterion value"
/>
</Form.Group>
`;
exports[`Criterion Feedback snapshot feedback is configured to required 1`] = `
<Form.Group>
<Form.Control
as="textarea"
className="criterion-feedback feedback-input"
disabled={false}
floatingLabel="Add comments"
onChange={[MockFunction this.onChange]}
value="criterion value"
/>
</Form.Group>
`;
exports[`Criterion Feedback snapshot feedback value is invalid 1`] = `
<Form.Group>
<Form.Control
as="textarea"
className="criterion-feedback feedback-input"
disabled={false}
floatingLabel="Add comments"
onChange={[MockFunction this.onChange]}
value="criterion value"
/>
<Form.Control.Feedback
className="feedback-error-msg"
type="invalid"
>
The feedback is required
</Form.Control.Feedback>
</Form.Group>
`;
exports[`Criterion Feedback snapshot is graded 1`] = `
<Form.Group>
<Form.Control
as="textarea"
className="criterion-feedback feedback-input"
disabled={true}
floatingLabel="Comments"
onChange={[MockFunction this.onChange]}
value="criterion value"
/>
</Form.Group>
`;
exports[`Criterion Feedback snapshot is grading 1`] = `
<Form.Group>
<Form.Control
as="textarea"
className="criterion-feedback feedback-input"
disabled={false}
floatingLabel="Add comments"
onChange={[MockFunction this.onChange]}
value="criterion value"
/>
</Form.Group>
`;

View File

@@ -0,0 +1,85 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Radio Criterion Container snapshot is grading 1`] = `
<Form.RadioSet
name="random name"
value="selected radio option"
>
<Form.Radio
className="criteria-option"
description="1 points"
disabled={false}
onChange={[MockFunction this.onChange]}
value="option name"
>
this label
</Form.Radio>
<Form.Radio
className="criteria-option"
description="2 points"
disabled={false}
onChange={[MockFunction this.onChange]}
value="option name 2"
>
this label 2
</Form.Radio>
</Form.RadioSet>
`;
exports[`Radio Criterion Container snapshot is not grading 1`] = `
<Form.RadioSet
name="random name"
value="selected radio option"
>
<Form.Radio
className="criteria-option"
description="1 points"
disabled={true}
onChange={[MockFunction this.onChange]}
value="option name"
>
this label
</Form.Radio>
<Form.Radio
className="criteria-option"
description="2 points"
disabled={true}
onChange={[MockFunction this.onChange]}
value="option name 2"
>
this label 2
</Form.Radio>
</Form.RadioSet>
`;
exports[`Radio Criterion Container snapshot radio contain invalid response 1`] = `
<Form.RadioSet
name="random name"
value="selected radio option"
>
<Form.Radio
className="criteria-option"
description="1 points"
disabled={false}
onChange={[MockFunction this.onChange]}
value="option name"
>
this label
</Form.Radio>
<Form.Radio
className="criteria-option"
description="2 points"
disabled={false}
onChange={[MockFunction this.onChange]}
value="option name 2"
>
this label 2
</Form.Radio>
<Form.Control.Feedback
className="feedback-error-msg"
type="invalid"
>
Rubric selection is required
</Form.Control.Feedback>
</Form.RadioSet>
`;

View File

@@ -0,0 +1,60 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Review Crition Container snapshot 1`] = `
<div
className="review-criterion"
>
<div
className="criteria-option"
key="option name"
>
<div>
<Form.Label
className="option-label"
>
this label
</Form.Label>
<FormControlFeedback
className="option-points"
>
<FormattedMessage
defaultMessage="{points} points"
description="criterion option point value display"
id="ora-grading.RadioCriterion.optionPoints"
values={
Object {
"points": 1,
}
}
/>
</FormControlFeedback>
</div>
</div>
<div
className="criteria-option"
key="option name 2"
>
<div>
<Form.Label
className="option-label"
>
this label 2
</Form.Label>
<FormControlFeedback
className="option-points"
>
<FormattedMessage
defaultMessage="{points} points"
description="criterion option point value display"
id="ora-grading.RadioCriterion.optionPoints"
values={
Object {
"points": 2,
}
}
/>
</FormControlFeedback>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,144 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Criterion Container snapshot is graded and is not grading 1`] = `
<Form.Group>
<Form.Label
className="criteria-label"
>
<span
className="criteria-title"
>
prompt
</span>
<InfoPopover>
<div
className="help-popover-option"
key="option name"
>
<strong>
this label
</strong>
<br />
explaination
</div>
<div
className="help-popover-option"
key="option name 2"
>
<strong>
this label 2
</strong>
<br />
explaination 2
</div>
</InfoPopover>
</Form.Label>
<div
className="rubric-criteria"
>
<RadioCriterion
isGrading={false}
orderNum={1}
/>
</div>
<CriterionFeedback
isGrading={false}
orderNum={1}
/>
</Form.Group>
`;
exports[`Criterion Container snapshot is ungraded and is grading 1`] = `
<Form.Group>
<Form.Label
className="criteria-label"
>
<span
className="criteria-title"
>
prompt
</span>
<InfoPopover>
<div
className="help-popover-option"
key="option name"
>
<strong>
this label
</strong>
<br />
explaination
</div>
<div
className="help-popover-option"
key="option name 2"
>
<strong>
this label 2
</strong>
<br />
explaination 2
</div>
</InfoPopover>
</Form.Label>
<div
className="rubric-criteria"
>
<RadioCriterion
isGrading={true}
orderNum={1}
/>
</div>
<CriterionFeedback
isGrading={true}
orderNum={1}
/>
</Form.Group>
`;
exports[`Criterion Container snapshot is ungraded and is not grading 1`] = `
<Form.Group>
<Form.Label
className="criteria-label"
>
<span
className="criteria-title"
>
prompt
</span>
<InfoPopover>
<div
className="help-popover-option"
key="option name"
>
<strong>
this label
</strong>
<br />
explaination
</div>
<div
className="help-popover-option"
key="option name 2"
>
<strong>
this label 2
</strong>
<br />
explaination 2
</div>
</InfoPopover>
</Form.Label>
<div
className="rubric-criteria"
>
<ReviewCriterion
orderNum={1}
/>
</div>
<CriterionFeedback
isGrading={false}
orderNum={1}
/>
</Form.Group>
`;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Form } from '@openedx/paragon';
import { Form } from '@edx/paragon';
import { selectors } from 'data/redux';
import { gradeStatuses } from 'data/services/lms/constants';
@@ -25,7 +25,7 @@ export const CriterionContainer = (props) => {
<span className="criteria-title">{config.prompt}</span>
<InfoPopover>
{config.options.map((option) => (
<div key={option.name} className="help-popover-option" data-testid="help-popover-option">
<div key={option.name} className="help-popover-option">
<strong>{option.label}</strong>
<br />
{option.explanation}
@@ -33,7 +33,7 @@ export const CriterionContainer = (props) => {
))}
</InfoPopover>
</Form.Label>
<div className="rubric-criteria" data-testid="rubric-criteria">
<div className="rubric-criteria">
{isGrading || gradeStatus === gradeStatuses.graded ? (
<RadioCriterion orderNum={orderNum} isGrading={isGrading} />
) : (

View File

@@ -1,50 +1,15 @@
import { render, screen } from '@testing-library/react';
import PropTypes from 'prop-types';
import React from 'react';
import { shallow } from 'enzyme';
import { selectors } from 'data/redux';
import { gradeStatuses } from 'data/services/lms/constants';
import { CriterionContainer, mapStateToProps } from '.';
const MockRadioCriterion = ({ orderNum, isGrading }) => (
<div data-testid="radio-criterion-component">
RadioCriterion Component (orderNum={orderNum}, isGrading={String(isGrading)})
</div>
);
MockRadioCriterion.propTypes = {
orderNum: PropTypes.number.isRequired,
isGrading: PropTypes.bool.isRequired,
};
const MockReviewCriterion = ({ orderNum }) => (
<div data-testid="review-criterion-component">
ReviewCriterion Component (orderNum={orderNum})
</div>
);
MockReviewCriterion.propTypes = {
orderNum: PropTypes.number.isRequired,
};
const MockCriterionFeedback = ({ orderNum, isGrading }) => (
<div data-testid="criterion-feedback-component">
CriterionFeedback Component (orderNum={orderNum}, isGrading={String(isGrading)})
</div>
);
MockCriterionFeedback.propTypes = {
orderNum: PropTypes.number.isRequired,
isGrading: PropTypes.bool.isRequired,
};
const MockInfoPopover = ({ children }) => (
<div data-testid="info-popover">{children}</div>
);
MockInfoPopover.propTypes = {
children: PropTypes.node.isRequired,
};
jest.mock('components/InfoPopover', () => 'InfoPopover');
jest.mock('./RadioCriterion', () => 'RadioCriterion');
jest.mock('./CriterionFeedback', () => 'CriterionFeedback');
jest.mock('./ReviewCriterion', () => 'ReviewCriterion');
jest.mock('data/redux/app/selectors', () => ({
rubric: {
@@ -53,18 +18,12 @@ jest.mock('data/redux/app/selectors', () => ({
})),
},
}));
jest.mock('data/redux/grading/selectors', () => ({
selected: {
gradeStatus: jest.fn((...args) => ({ selectedGradeStatus: args })),
},
}));
jest.mock('./RadioCriterion', () => jest.fn((props) => MockRadioCriterion(props)));
jest.mock('./ReviewCriterion', () => jest.fn((props) => MockReviewCriterion(props)));
jest.mock('./CriterionFeedback', () => jest.fn((props) => MockCriterionFeedback(props)));
jest.mock('components/InfoPopover', () => jest.fn((props) => MockInfoPopover(props)));
describe('Criterion Container', () => {
const props = {
isGrading: true,
@@ -75,14 +34,14 @@ describe('Criterion Container', () => {
feedback: 'feedback mock',
options: [
{
explanation: 'explanation',
explanation: 'explaination',
feedback: 'option feedback',
label: 'this label',
name: 'option name',
points: 2,
},
{
explanation: 'explanation 2',
explanation: 'explaination 2',
feedback: 'option feedback 2',
label: 'this label 2',
name: 'option name 2',
@@ -92,62 +51,80 @@ describe('Criterion Container', () => {
},
gradeStatus: gradeStatuses.ungraded,
};
let el;
beforeEach(() => {
el = shallow(<CriterionContainer {...props} />);
});
describe('component rendering', () => {
it('displays the criterion prompt', () => {
render(<CriterionContainer {...props} />);
expect(screen.getByText('prompt')).toBeInTheDocument();
describe('snapshot', () => {
test('is ungraded and is grading', () => {
expect(el).toMatchSnapshot();
});
it('displays all option explanations in the info popover', () => {
render(<CriterionContainer {...props} />);
const infoPopover = screen.getByTestId('info-popover');
expect(infoPopover).toHaveTextContent('explanation');
expect(infoPopover).toHaveTextContent('explanation 2');
expect(infoPopover).toHaveTextContent('this label');
expect(infoPopover).toHaveTextContent('this label 2');
test('is ungraded and is not grading', () => {
el.setProps({
isGrading: false,
});
expect(el).toMatchSnapshot();
});
it('renders RadioCriterion when is ungraded and is grading', () => {
render(<CriterionContainer {...props} />);
expect(screen.getByTestId('radio-criterion-component')).toBeInTheDocument();
expect(screen.queryByTestId('review-criterion-component')).not.toBeInTheDocument();
test('is graded and is not grading', () => {
el.setProps({
isGrading: false,
gradeStatus: gradeStatuses.graded,
});
expect(el).toMatchSnapshot();
});
});
describe('component', () => {
test('rendering and all of the option show up', () => {
expect(el.isEmptyRender()).toEqual(false);
const optionsEl = el.find('.help-popover-option');
expect(optionsEl.length).toEqual(props.config.options.length);
optionsEl.forEach((optionEl, i) => {
expect(optionEl.key()).toEqual(props.config.options[i].name);
expect(optionEl.text()).toContain(props.config.options[i].explanation);
});
});
it('renders ReviewCriterion when is ungraded and is not grading', () => {
render(<CriterionContainer {...props} isGrading={false} />);
expect(screen.getByTestId('review-criterion-component')).toBeInTheDocument();
expect(screen.queryByTestId('radio-criterion-component')).not.toBeInTheDocument();
test('is ungraded and is grading (Radio criterion get render)', () => {
const rubricCritera = el.find('.rubric-criteria');
expect(rubricCritera.children(0).name()).toEqual('RadioCriterion');
});
it('renders RadioCriterion when is graded and is not grading', () => {
render(<CriterionContainer {...props} isGrading={false} gradeStatus={gradeStatuses.graded} />);
expect(screen.getByTestId('radio-criterion-component')).toBeInTheDocument();
expect(screen.queryByTestId('review-criterion-component')).not.toBeInTheDocument();
test('is ungraded and is not grading (Review criterion get render)', () => {
el.setProps({
isGrading: false,
});
const rubricCritera = el.find('.rubric-criteria');
expect(rubricCritera.children(0).name()).toEqual('ReviewCriterion');
});
it('renders CriterionFeedback component', () => {
render(<CriterionContainer {...props} />);
expect(screen.getByTestId('criterion-feedback-component')).toBeInTheDocument();
test('is graded and is not grading (Radio criterion get render)', () => {
el.setProps({
isGrading: false,
gradeStatus: gradeStatuses.graded,
});
const rubricCritera = el.find('.rubric-criteria');
expect(rubricCritera.children(0).name()).toEqual('RadioCriterion');
});
});
describe('mapStateToProps', () => {
const testState = { arbitraryState: 'some data' };
const testState = { abitaryState: 'some data' };
const ownProps = { orderNum: props.orderNum };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState, ownProps);
});
it('maps rubric criterion config to props', () => {
test('selectors.app.rubric.criterionConfig', () => {
expect(mapped.config).toEqual(
selectors.app.rubric.criterionConfig(testState, ownProps),
);
});
it('maps grading status to props', () => {
test('selectors.grading.selected.gradeStatus', () => {
expect(mapped.gradeStatus).toEqual(
selectors.grading.selected.gradeStatus(testState),
);

View File

@@ -14,7 +14,7 @@ const messages = defineMessages({
optional: {
id: 'ora-grading.CriterionFeedback.optional',
defaultMessage: '(Optional)',
description: 'additional label for optional feedback field',
description: 'addtional label for optional feedback field',
},
optionPoints: {
id: 'ora-grading.RadioCriterion.optionPoints',

View File

@@ -1,8 +1,8 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import React from 'react';
import { shallow } from 'enzyme';
import { selectors } from 'data/redux';
import { DemoWarning, mapStateToProps } from '.';
import messages from './messages';
jest.mock('data/redux', () => ({
selectors: {
@@ -10,26 +10,24 @@ jest.mock('data/redux', () => ({
},
}));
describe('DemoWarning component', () => {
describe('behavior', () => {
it('does not render when hide prop is true', () => {
const { container } = render(<IntlProvider locale="en"><DemoWarning hide /></IntlProvider>);
expect(container.firstChild).toBeNull();
});
let el;
it('renders alert with warning message when hide prop is false', () => {
render(<IntlProvider locale="en"><DemoWarning hide={false} /></IntlProvider>);
const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();
expect(alert).toHaveClass('alert-warning');
expect(alert).toHaveTextContent(messages.demoModeMessage.defaultMessage);
expect(alert).toHaveTextContent(messages.demoModeHeading.defaultMessage);
describe('DemoWarning component', () => {
describe('snapshots', () => {
test('does not render if disabled flag is missing', () => {
el = shallow(<DemoWarning hide />);
expect(el).toMatchSnapshot();
expect(el.isEmptyRender()).toEqual(true);
});
test('snapshot: disabled flag is present', () => {
el = shallow(<DemoWarning hide={false} />);
expect(el).toMatchSnapshot();
expect(el.isEmptyRender()).toEqual(false);
});
});
describe('mapStateToProps', () => {
it('maps hide prop from app.isEnabled selector', () => {
const testState = { some: 'test-state' };
const testState = { some: 'test-state' };
test('hide is forwarded from app.isEnabled', () => {
expect(mapStateToProps(testState).hide).toEqual(
selectors.app.isEnabled(testState),
);

View File

@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DemoWarning component snapshots does not render if disabled flag is missing 1`] = `""`;
exports[`DemoWarning component snapshots snapshot: disabled flag is present 1`] = `
<Alert
className="mb-0 rounded-0"
variant="warning"
>
<Alert.Heading>
<FormattedMessage
defaultMessage="Demo Mode"
description="Demo mode heading"
id="ora-grading.ReviewModal.demoHeading"
/>
</Alert.Heading>
<p>
<FormattedMessage
defaultMessage="You are demoing the new ORA staff grading experience. You will be unable to submit grades until you activate the feature. This will become the default grading experience on May 9th (05/09/2022). To opt-in early, or opt-out, please contact Partner Support."
description="Demo mode message"
id="ora-grading.ReviewModal.demoMessage"
/>
</p>
</Alert>
`;

View File

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

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Hyperlink, Button } from '@openedx/paragon';
import { Hyperlink, Button } from '@edx/paragon';
import urls from 'data/services/lms/urls';
import emptyStateSVG from './assets/empty-state.svg';

View File

@@ -1,38 +1,33 @@
import { screen } from '@testing-library/react';
import React from 'react';
import { shallow } from 'enzyme';
import { Hyperlink } from '@edx/paragon';
import urls from 'data/services/lms/urls';
import { renderWithIntl } from '../../testUtils';
import EmptySubmission from './EmptySubmission';
jest.mock('data/services/lms/urls', () => ({
openResponse: (courseId) => `openResponseUrl(${courseId})`,
}));
jest.mock('./assets/empty-state.svg', () => './assets/empty-state.svg');
jest.mock('./assets/emptyState.svg', () => './assets/emptyState.svg');
let el;
describe('EmptySubmission component', () => {
const props = { courseId: 'test-course-id' };
it('renders the empty state image with correct alt text', () => {
renderWithIntl(<EmptySubmission {...props} />);
expect(screen.getByAltText('empty state')).toBeInTheDocument();
});
it('renders the no results found title message', () => {
renderWithIntl(<EmptySubmission {...props} />);
expect(screen.getByText('Nothing here yet')).toBeInTheDocument();
});
it('renders hyperlink with correct destination URL', () => {
renderWithIntl(<EmptySubmission {...props} />);
const hyperlink = screen.getByRole('link');
expect(hyperlink).toHaveAttribute(
'href',
urls.openResponse(props.courseId),
);
});
it('renders the back to responses button', () => {
renderWithIntl(<EmptySubmission {...props} />);
expect(screen.getByText('Back to all open responses')).toBeInTheDocument();
describe('component', () => {
const props = { courseId: 'test-course-id' };
beforeEach(() => {
el = shallow(<EmptySubmission {...props} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
test('openResponse destination', () => {
expect(
el.find(Hyperlink).at(0).props().destination,
).toEqual(urls.openResponse(props.courseId));
});
});
});

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, DataTableContext } from '@openedx/paragon';
import { Button, DataTableContext } from '@edx/paragon';
import * as module from './FilterStatusComponent';

View File

@@ -1,18 +1,54 @@
import React from 'react';
import PropTypes from 'prop-types';
import { render } from '@testing-library/react';
import { DataTableContext } from '@openedx/paragon';
import { shallow } from 'enzyme';
import * as module from './FilterStatusComponent';
const fieldIds = ['field-id-0', 'field-id-1', 'field-id-2', 'field-id-3'];
const fieldIds = [
'field-id-0',
'field-id-1',
'field-id-2',
'field-id-3',
];
const filterOrder = [1, 0, 3, 2];
const filters = filterOrder.map((v) => ({ id: fieldIds[v] }));
const headers = [0, 1, 2, 3].map((v) => ({
const filters = filterOrder.map(v => ({ id: fieldIds[v] }));
const headers = [0, 1, 2, 3].map(v => ({
id: fieldIds[v],
Header: `HeaDer-${v}`,
}));
describe('FilterStatusComponent hooks', () => {
const context = { headers, state: { filters } };
const mockTableContext = (newContext) => {
React.useContext.mockReturnValueOnce(newContext);
};
beforeEach(() => {
context.setAllFilters = jest.fn();
});
it('returns empty dict if setAllFilters or state.filters is falsey', () => {
mockTableContext({ ...context, setAllFilters: null });
expect(module.filterHooks()).toEqual({});
mockTableContext({ ...context, state: { filters: null } });
expect(module.filterHooks()).toEqual({});
});
describe('clearFilters', () => {
it('uses React.useCallback to clear filters, only once', () => {
mockTableContext(context);
const { cb, prereqs } = module.filterHooks().clearFilters.useCallback;
expect(prereqs).toEqual([context.setAllFilters]);
expect(context.setAllFilters).not.toHaveBeenCalled();
cb();
expect(context.setAllFilters).toHaveBeenCalledWith([]);
});
});
describe('filterNames', () => {
it('returns list of Header values by filter order', () => {
mockTableContext(context);
expect(module.filterHooks().filterNames).toEqual(
filterOrder.map(v => headers[v].Header),
);
});
});
});
describe('FilterStatusComponent component', () => {
const props = {
className: 'css-class-name',
@@ -22,98 +58,34 @@ describe('FilterStatusComponent component', () => {
buttonClassName: 'css-class-name-for-button',
showFilteredFields: true,
};
const { FilterStatusComponent } = module;
const renderWithContext = (contextValue, componentProps = props) => {
const TestWrapper = ({ children }) => (
<DataTableContext.Provider value={contextValue}>
{children}
</DataTableContext.Provider>
);
TestWrapper.propTypes = {
children: PropTypes.node,
};
return render(
<TestWrapper>
<FilterStatusComponent {...componentProps} />
</TestWrapper>,
);
const hookProps = {
clearFilters: jest.fn().mockName('hookProps.clearFilters'),
filterNames: ['filter-name-0', 'filter-name-1'],
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('behavior', () => {
it('does not render when there are no filters', () => {
const contextValue = {
headers,
state: { filters: null },
setAllFilters: jest.fn(),
};
const { container } = renderWithContext(contextValue);
expect(container.firstChild).toBeNull();
});
it('does not render when setAllFilters is not available', () => {
const contextValue = { headers, state: { filters }, setAllFilters: null };
const { container } = renderWithContext(contextValue);
expect(container.firstChild).toBeNull();
});
it('renders clear filters button with correct text when filters exist', () => {
const contextValue = {
headers,
state: { filters },
setAllFilters: jest.fn(),
};
const { getByText } = renderWithContext(contextValue);
expect(getByText(props.clearFiltersText)).toBeInTheDocument();
});
it('displays filtered field names when showFilteredFields is true', () => {
const contextValue = {
headers,
state: { filters },
setAllFilters: jest.fn(),
};
const { getByText } = renderWithContext(contextValue);
const expectedFilterNames = filterOrder.map((v) => headers[v].Header);
expectedFilterNames.forEach((name) => {
expect(getByText(name, { exact: false })).toBeInTheDocument();
const { FilterStatusComponent } = module;
const mockHooks = (value) => {
jest.spyOn(module, 'filterHooks').mockReturnValueOnce(value);
};
describe('snapshot', () => {
describe('with filters', () => {
test('showFilteredFields', () => {
mockHooks(hookProps);
const el = shallow(<FilterStatusComponent {...props} />);
expect(el).toMatchSnapshot();
});
test('showFilteredFields=false - hide filterTexts', () => {
mockHooks(hookProps);
const el = shallow(
<FilterStatusComponent {...props} showFilteredFields={false} />,
);
expect(el).toMatchSnapshot();
});
});
it('does not display filtered field names when showFilteredFields is false', () => {
const contextValue = {
headers,
state: { filters },
setAllFilters: jest.fn(),
};
const { queryByText } = renderWithContext(contextValue, {
...props,
showFilteredFields: false,
});
expect(queryByText(/Filtered by/)).not.toBeInTheDocument();
});
it('applies correct CSS classes to the component', () => {
const contextValue = {
headers,
state: { filters },
setAllFilters: jest.fn(),
};
const { container } = renderWithContext(contextValue);
expect(container.firstChild).toHaveClass(props.className);
});
it('calls setAllFilters with empty array when clear button is clicked', () => {
const setAllFilters = jest.fn();
const contextValue = { headers, state: { filters }, setAllFilters };
const { getByText } = renderWithContext(contextValue);
const clearButton = getByText(props.clearFiltersText);
clearButton.click();
expect(setAllFilters).toHaveBeenCalledWith([]);
test('without filters', () => {
mockHooks({});
const el = shallow(<FilterStatusComponent {...props} />);
expect(el).toMatchSnapshot();
expect(el.isEmptyRender()).toEqual(true);
});
});
});

View File

@@ -6,8 +6,8 @@ import {
Alert,
Button,
Hyperlink,
} from '@openedx/paragon';
import { Info } from '@openedx/paragon/icons';
} from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import urls from 'data/services/lms/urls';

View File

@@ -1,14 +1,19 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { shallow } from 'enzyme';
import { selectors, thunkActions } from 'data/redux';
import { renderWithIntl } from '../../testUtils';
import { ListError, mapDispatchToProps, mapStateToProps } from './ListError';
import messages from './messages';
import { formatMessage } from 'testUtils';
import {
ListError,
mapDispatchToProps,
mapStateToProps,
} from './ListError';
jest.mock('data/redux', () => ({
selectors: {
app: {
courseId: jest.fn((state) => state.courseId || 'test-course-id'),
courseId: (...args) => ({ courseId: args }),
},
},
thunkActions: {
@@ -22,60 +27,41 @@ jest.mock('data/services/lms/urls', () => ({
openResponse: (courseId) => `api/openResponse/${courseId}`,
}));
let el;
jest.useFakeTimers('modern');
describe('ListError component', () => {
const props = {
courseId: 'test-course-id',
initializeApp: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('behavior', () => {
it('renders error alert with proper styling', () => {
renderWithIntl(<ListError {...props} />);
const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();
expect(alert).toHaveClass('alert-danger');
describe('component', () => {
const props = {
courseId: 'test-course-id',
};
beforeEach(() => {
props.loadSelectionForReview = jest.fn();
props.intl = { formatMessage };
props.initializeApp = jest.fn();
});
it('displays error heading and message', () => {
renderWithIntl(<ListError {...props} />);
const heading = screen.getByRole('alert').querySelector('.alert-heading');
expect(heading).toBeInTheDocument();
expect(heading).toHaveTextContent(messages.loadErrorHeading.defaultMessage);
});
it('displays try again button', () => {
renderWithIntl(<ListError {...props} />);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).toHaveClass('btn-primary');
});
it('calls initializeApp when try again button is clicked', async () => {
renderWithIntl(<ListError {...props} />);
const user = userEvent.setup();
const button = screen.getByRole('button');
await user.click(button);
expect(props.initializeApp).toHaveBeenCalledTimes(1);
describe('render tests', () => {
beforeEach(() => {
el = shallow(<ListError {...props} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
let mapped;
const testState = { some: 'test-state' };
it('maps courseId from app.courseId selector', () => {
const mapped = mapStateToProps(testState);
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('courseId loads from app.courseId', () => {
expect(mapped.courseId).toEqual(selectors.app.courseId(testState));
});
});
describe('mapDispatchToProps', () => {
it('maps initializeApp from thunkActions.app.initialize', () => {
expect(mapDispatchToProps.initializeApp).toEqual(
thunkActions.app.initialize,
);
it('loads initializeApp from thunkActions.app.initialize', () => {
expect(mapDispatchToProps.initializeApp).toEqual(thunkActions.app.initialize);
});
});
});

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