Compare commits

..

1 Commits

Author SHA1 Message Date
Simon Chen
cdb0f48f08 feat!: upsell through a course cert preview pop up
Even if the learner is enrolled in the course as a audit learner, each course cards would show a link that, once clicked upon, will pop a modal with verified cert preview and upgrade button
2023-10-25 13:37:58 -04:00
427 changed files with 37417 additions and 23184 deletions

8
.env
View File

@@ -32,6 +32,7 @@ ENTERPRISE_MARKETING_UTM_SOURCE=''
ENTERPRISE_MARKETING_UTM_CAMPAIGN=''
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=''
LEARNING_BASE_URL=''
ZENDESK_KEY=''
HOTJAR_APP_ID=''
HOTJAR_VERSION='6'
HOTJAR_DEBUG=''
@@ -39,8 +40,5 @@ ACCOUNT_SETTINGS_URL=''
ACCOUNT_PROFILE_URL=''
ENABLE_NOTICES=''
CAREER_LINK_URL=''
ENABLE_EDX_PERSONAL_DASHBOARD=false
ENABLE_PROGRAMS=false
NON_BROWSABLE_COURSES=false
# Fallback in local style files
PARAGON_THEME_URLS={}
OPTIMIZELY_FULL_STACK_SDK_KEY=''
EXPERIMENT_08_23_VAN_PAINTED_DOOR='true'

View File

@@ -8,6 +8,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'
@@ -20,7 +21,7 @@ LMS_CLIENT_ID='login-service-client-id'
SEGMENT_KEY=''
FEATURE_FLAGS={}
MARKETING_SITE_BASE_URL='http://localhost:18000'
SUPPORT_URL=''
SUPPORT_URL='http://localhost:18000/support'
CONTACT_URL='http://localhost:18000/contact'
OPEN_SOURCE_URL='http://localhost:18000/openedx'
TERMS_OF_SERVICE_URL='http://localhost:18000/terms-of-service'
@@ -38,6 +39,7 @@ ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
LEARNING_BASE_URL='http://localhost:2000'
SESSION_COOKIE_DOMAIN='localhost'
ZENDESK_KEY=''
HOTJAR_APP_ID=''
HOTJAR_VERSION='6'
HOTJAR_DEBUG=''
@@ -45,8 +47,5 @@ ACCOUNT_SETTINGS_URL='http://localhost:1997'
ACCOUNT_PROFILE_URL='http://localhost:1995'
ENABLE_NOTICES=''
CAREER_LINK_URL=''
ENABLE_EDX_PERSONAL_DASHBOARD=false
ENABLE_PROGRAMS=false
NON_BROWSABLE_COURSES=false
# Fallback in local style files
PARAGON_THEME_URLS={}
OPTIMIZELY_FULL_STACK_SDK_KEY=''
EXPERIMENT_08_23_VAN_PAINTED_DOOR=true

View File

@@ -8,6 +8,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'
@@ -20,7 +21,7 @@ LMS_CLIENT_ID='login-service-client-id'
SEGMENT_KEY=''
FEATURE_FLAGS={}
MARKETING_SITE_BASE_URL='http://localhost:18000'
SUPPORT_URL=''
SUPPORT_URL='http://localhost:18000/support'
CONTACT_URL='http://localhost:18000/contact'
OPEN_SOURCE_URL='http://localhost:18000/openedx'
TERMS_OF_SERVICE_URL='http://localhost:18000/terms-of-service'
@@ -37,6 +38,7 @@ ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
LEARNING_BASE_URL='http://localhost:2000'
ZENDESK_KEY='test-zendesk-key'
HOTJAR_APP_ID='hot-jar-app-id'
HOTJAR_VERSION='6'
HOTJAR_DEBUG=''
@@ -44,7 +46,5 @@ ACCOUNT_SETTINGS_URL='http://account-settings-url.test'
ACCOUNT_PROFILE_URL='http://account-profile-url.test'
ENABLE_NOTICES=''
CAREER_LINK_URL=''
ENABLE_EDX_PERSONAL_DASHBOARD=true
ENABLE_PROGRAMS=false
NON_BROWSABLE_COURSES=false
PARAGON_THEME_URLS={}
OPTIMIZELY_FULL_STACK_SDK_KEY='SDK Key'
EXPERIMENT_08_23_VAN_PAINTED_DOOR=true

View File

@@ -1,4 +1,4 @@
const { createConfig } = require('@openedx/frontend-build');
const { createConfig } = require('@edx/frontend-build');
const config = createConfig('eslint', {
rules: {

8
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,8 @@
# Root app is developed and owned by Aurora
* @edx/content-aurora
# WIDGETS and experiments are developed and owned by separate teams below
# Recommendations panel
/src/widgets/RecommendationsPanel @edx/vanguards
/src/widgets/LookingForChallengeWidget @edx/vanguards

View File

@@ -1,7 +0,0 @@
version: 2
updates:
# Adding new check for github-actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

33
.github/renovate.json vendored
View File

@@ -1,33 +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"
}

View File

@@ -10,16 +10,18 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Nodejs
uses: actions/setup-node@v6
uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
node-version: ${{ env.NODE_VER }}
- name: Install dependencies
run: npm ci
@@ -29,9 +31,6 @@ jobs:
- name: Lint
run: npm run lint
- name: Type check
run: npm run types
- name: Test
run: npm run test
@@ -40,7 +39,19 @@ jobs:
run: npm run build
- name: Run Coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v3
- name: Send failure notification
if: ${{ failure() }}
uses: dawidd6/action-send-mail@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
server_address: email-smtp.us-east-1.amazonaws.com
server_port: 465
username: ${{ secrets.EDX_SMTP_USERNAME }}
password: ${{ secrets.EDX_SMTP_PASSWORD }}
subject: CI workflow failed in ${{github.repository}}
to: masters-grades@edx.org
from: github-actions <github-actions@edx.org>
body: CI workflow in ${{github.repository}} failed!
For details see "github.com/${{ github.repository }}/actions/runs/${{ github.run_id
}}"

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

@@ -0,0 +1,35 @@
name: Release CI
on:
push:
tags:
- "*"
jobs:
release:
name: Release
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VER }}
- 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

@@ -1,6 +1,5 @@
.DS_Store
.eslintcache
env.config.*
node_modules
npm-debug.log
coverage
@@ -26,4 +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-learner-dashboard]
file_filter = src/i18n/messages/<lang>.json
source_file = src/i18n/transifex_input.json
source_lang = en
type = KEYVALUEJSON

View File

@@ -1,6 +1,8 @@
npm-install-%: ## install specified % npm package
npm install $* --save-dev
git add package.json
export TRANSIFEX_RESOURCE = frontend-app-learner-dashboard
transifex_langs = "ar,fr,fr_CA,es_419,pt_BR,zh_CN"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js
@@ -8,7 +10,7 @@ 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,34 @@ detect_changed_source_translations:
# Checking for changed translations...
git diff --exit-code $(i18n)
# Pushes translations to Transifex. You must run make extract_translations first.
push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
else
# Experimental: OEP-58 Pulls translations using atlas
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull $(ATLAS_OPTIONS) \
translations/frontend-platform/src/i18n/messages:frontend-platform \
&& atlas pull --filter=$(transifex_langs) \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-app-learner-dashboard/src/i18n/messages:frontend-app-learner-dashboard
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-app-learner-dashboard
$(intl_imports) paragon frontend-component-footer frontend-app-learner-dashboard
endif
# This target is used by CI.
validate-no-uncommitted-package-lock-changes:

26
README.md Normal file
View File

@@ -0,0 +1,26 @@
# frontend-app-learner-dashboard
The Learner Home app is a microfrontend (MFE) course listing experience for the Open edX Learning Management System (LMS). This experience was designed to provide a clean and functional interface to allow learners to view all of their open enrollments, as well as take relevant actions on those enrollments. It also serves as host to a number of exposed "widget" containers to provide upsell and discovery widgets as sidebar/footer components.
## 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:1996`).
From there, simply load the configured address/port. You should be prompted to log into your LMS if you are not already, and then redirected to your home page.
## Contributing
A core goal of this app is to provide a clean experimentation interface. To promote this end, we have provided a silo'ed code directory at `src/widgets` in which contributors should add their custom widget components. In order to ensure our ability to maintain the code stability of the app, the code for these widgets should be strictly contained within the bounds of that directory.
Once written, the widgets can be configured into one of our widget containers at `src/containers/WidgetContainers`. This can include conditional logic, as well as Optimizely triggers. It is important to note that our integration tests will isolate and ignore these containers, and thus testing your widget is the response of the creator/maintainer of the widget itself.
Some guidelines for writing widgets:
* Code for the widget should be strictly confined to the `src/widgets` directory.
* You can load data from the redux store, but should not add or modify fields in that structure.
* Network events should be managed in component hooks, though can use our `data/constants/requests:requestStates` for ease of tracking the request states.
## Resources
* [Learner Home project info at the Open edX Wiki](https://openedx.atlassian.net/wiki/spaces/OEPM/pages/3575906333/Learner+Home)

View File

@@ -1,97 +0,0 @@
|license-badge| |status-badge| |ci-badge| |codecov-badge|
.. |license-badge| image:: https://img.shields.io/github/license/openedx/frontend-app-learner-dashboard.svg
:target: https://github.com/openedx/frontend-app-learner-dashboard/blob/master/LICENSE
:alt: License
.. |status-badge| image:: https://img.shields.io/badge/Status-Maintained-brightgreen
:alt: Maintained
.. |ci-badge| image:: https://github.com/openedx/frontend-app-learner-dashboard/actions/workflows/ci.yml/badge.svg
:target: https://github.com/openedx/frontend-app-learner-dashboard/actions/workflows/ci.yml
:alt: Continuous Integration
.. |codecov-badge| image:: https://codecov.io/github/openedx/frontend-app-learner-dashboard/coverage.svg?branch=master
:target: https://app.codecov.io/github/openedx/frontend-app-learner-dashboard?branch=master
:alt: Codecov
frontend-app-learner-dashboard
==============================
The Learner Home app is a microfrontend (MFE) course listing experience for the Open edX Learning Management System
(LMS). This experience was designed to provide a clean and functional interface to allow learners to view all of their
open enrollments, as well as take relevant actions on those enrollments. It also serves as host to a number of exposed
"widget" containers to provide upsell and discovery widgets as sidebar/footer components.
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:1996``).
From there, simply load the configured address/port. You should be prompted to log into your LMS if you are not
already, and then redirected to your home page.
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>`_.
Contributing
------------
Contributions are very welcome. Please read `So you want to contribute to Open edX <https://docs.openedx.org/en/latest/developers/quickstarts/so_you_want_to_contribute.html>`_ for details on how to get started as an Open edX contributor.
This project is currently accepting all types of contributions — bug fixes, security fixes, maintenance work, or new features.
However, if you intend to add a new feature, make sure it has gone through the `Product Review process <https://openedx.atlassian.net/wiki/spaces/COMM/pages/3875962884/How+to+submit+an+open+source+contribution+for+Product+Review>`_.
When proposing a change, create an issue in this repo to get the discussion started.
License
-------
The code in this repository is licensed under the AGPLv3 unless otherwise noted.
Please see the `license`_ for more info.
.. _license: https://github.com/openedx/frontend-app-learner-dashboard/blob/master/LICENSE
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-learner-dashboard/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
Resources
---------
Additional info about the Learner Home MFE project can be found on the `Open edX Wiki`_.
.. _Open edX Wiki: https://openedx.atlassian.net/wiki/spaces/OEPM/pages/3575906333/Learner+Home
The Open edX Code of Conduct
----------------------------
All community members are expected to follow the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
Reporting Security Issues
-------------------------
Please do not report security issues in public. Please email security@openedx.org.

View File

@@ -1,25 +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-learner-dashboard'
description: 'The Microfrontend for the course listing interface allowing learners to view and act upon enrollments.'
links:
- url: 'https://github.com/openedx/frontend-app-learner-dashboard/blob/master/README.rst'
title: 'Documentation'
icon: 'Article'
annotations:
# (Optional) Annotation keys and values can be whatever you want.
# We use it in Open edX repos to have a comma-separated list of GitHub user
# names that might be interested in changes to the architecture of this
# component.
openedx.org/arch-interest-groups: ""
# This can be multiple comma-separated projects.
openedx.org/add-to-projects: "openedx:23"
openedx.org/release: "master"
spec:
type: 'service'
lifecycle: 'production'
owner: 2U-aperture
# (Optional) An array of different components or resources.

View File

@@ -1,73 +0,0 @@
/*
Learner Dashboard is now able to handle JS-based configuration!
For the time being, the `.env.*` files are still made available when cloning down this repo or pulling from
the master branch. To switch to using `env.config.js`, make a copy of `example.env.config.js` and configure as needed.
For testing with Jest Snapshot, there is a mock in `/src/setupTest.jsx` for `getConfig` that will need to be
uncommented.
Note: having both .env and env.config.js files will follow a predictable order, in which non-empty values in the
JS-based config will overwrite the .env environment variables.
frontend-platform's getConfig loads configuration in the following sequence:
- .env file config
- optional handlers (commonly used to merge MFE-specific config in via additional process.env variables)
- env.config.js file config
- runtime config
*/
module.exports = {
NODE_ENV: 'development',
NODE_PATH: './src',
PORT: 1996,
BASE_URL: 'localhost:1996',
LMS_BASE_URL: 'http://localhost:18000',
ECOMMERCE_BASE_URL: 'http://localhost:18130',
CREDIT_PURCHASE_URL: 'http://localhost:8140',
LOGIN_URL: 'http://localhost:18000/login',
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',
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',
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
USER_INFO_COOKIE_NAME: 'edx-user-info',
SITE_NAME: 'localhost',
DATA_API_BASE_URL: 'http://localhost:8000',
// LMS_CLIENT_ID should match the lms DOT client application in your LMS container
LMS_CLIENT_ID: 'login-service-client-id',
SEGMENT_KEY: '',
FEATURE_FLAGS: {},
MARKETING_SITE_BASE_URL: 'http://localhost:18000',
SUPPORT_URL: 'http://localhost:18000/support',
CONTACT_URL: 'http://localhost:18000/contact',
OPEN_SOURCE_URL: 'http://localhost:18000/openedx',
TERMS_OF_SERVICE_URL: 'http://localhost:18000/terms-of-service',
PRIVACY_POLICY_URL: 'http://localhost:18000/privacy-policy',
FACEBOOK_URL: 'https://www.facebook.com',
TWITTER_URL: 'https://twitter.com',
YOU_TUBE_URL: 'https://www.youtube.com',
LINKED_IN_URL: 'https://www.linkedin.com',
REDDIT_URL: 'https://www.reddit.com',
APPLE_APP_STORE_URL: 'https://www.apple.com/ios/app-store/',
GOOGLE_PLAY_URL: 'https://play.google.com/store',
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',
LEARNING_BASE_URL: 'http://localhost:2000',
SESSION_COOKIE_DOMAIN: 'localhost',
HOTJAR_APP_ID: '',
HOTJAR_VERSION: 6,
HOTJAR_DEBUG: '',
NEW_RELIC_APP_ID: '',
NEW_RELIC_LICENSE_KEY: '',
ACCOUNT_SETTINGS_URL: 'http://localhost:1997',
ACCOUNT_PROFILE_URL: 'http://localhost:1995',
ENABLE_NOTICES: '',
CAREER_LINK_URL: '',
EXPERIMENT_08_23_VAN_PAINTED_DOOR: true,
};

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.jsx',
],
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}

43463
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,21 +6,18 @@
"type": "git",
"url": "git+https://github.com/edx/frontend-app-learner-dashboard.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=/learner-dashboard/ 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",
"quality": "npm run lint-fix && npm run test",
"watch-tests": "jest --watch",
"types": "tsc --noEmit"
"snapshot": "fedx-scripts jest --updateSnapshot",
"prepare": "husky install"
},
"author": "edX",
"license": "AGPL-3.0",
@@ -29,58 +26,74 @@
"access": "public"
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.6.0",
"@edx/frontend-enterprise-hotjar": "7.2.0",
"@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.7.0",
"@edx/brand": "npm:@edx/brand-edx.org@^2.0.3",
"@edx/browserslist-config": "^1.1.0",
"@edx/frontend-component-footer": "^12.2.1",
"@edx/frontend-enterprise-hotjar": "^2.0.0",
"@edx/frontend-platform": "^5.5.4",
"@edx/paragon": "^20.44.0",
"@edx/react-unit-test-utils": "^1.7.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/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.4.5",
"@redux-devtools/extension": "3.3.0",
"@reduxjs/toolkit": "^2.0.0",
"@fortawesome/react-fontawesome": "^0.1.15",
"@optimizely/react-sdk": "^2.9.2",
"@redux-beacon/segment": "^1.1.0",
"@reduxjs/toolkit": "^1.6.1",
"@testing-library/user-event": "^13.5.0",
"axios": "^0.21.4",
"classnames": "^2.3.1",
"core-js": "3.46.0",
"filesize": "^10.0.0",
"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",
"jest": "^26.6.3",
"jest-when": "^3.6.0",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"prop-types": "15.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"prop-types": "15.7.2",
"query-string": "7.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-helmet": "^6.1.0",
"react-intl": "6.8.9",
"react-intl": "^5.20.9",
"react-pdf": "^5.5.0",
"react-redux": "^7.2.4",
"react-router-dom": "6.30.1",
"react-router-dom": "6.15.0",
"react-share": "^4.4.0",
"redux": "4.2.1",
"react-zendesk": "^0.1.13",
"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",
"universal-cookie": "^4.0.4",
"util": "^0.12.4"
"util": "^0.12.4",
"whatwg-fetch": "^3.6.2"
},
"devDependencies": {
"@edx/browserslist-config": "^1.3.0",
"@edx/typescript-config": "^1.1.0",
"@openedx/frontend-build": "^14.6.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"copy-webpack-plugin": "^13.0.0",
"@edx/frontend-build": "12.8.27",
"@edx/reactifex": "^2.1.1",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.1.0",
"@wojtekmaj/enzyme-adapter-react-17": "0.8.0",
"axios-mock-adapter": "^1.20.0",
"copy-webpack-plugin": "^11.0.0",
"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-expect-message": "^1.1.3",
"jest-when": "^3.6.0",
"react-dev-utils": "^12.0.0",
"react-test-renderer": "^18.3.1",
"redux-mock-store": "^1.5.4"
"jest-expect-message": "^1.0.2",
"react-dev-utils": "^11.0.4",
"react-test-renderer": "^17.0.2",
"redux-mock-store": "^1.5.4",
"semantic-release": "^20.1.3"
}
}

View File

@@ -1,8 +1,19 @@
<!doctype html>
<html lang="en-us" dir="ltr">
<head>
<title>Learner Dashboard | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
<% if (process.env.OPTIMIZELY_URL) { %>
<script
src="<%= process.env.OPTIMIZELY_URL %>"
></script>
<% } else if (process.env.OPTIMIZELY_PROJECT_ID) { %>
<script
src="<%= process.env.MARKETING_SITE_BASE_URL %>/optimizelyjs/<%= process.env.OPTIMIZELY_PROJECT_ID %>.js"
></script>
<% } %>
</head>
<body>
<div id="root"></div>

View File

@@ -6,8 +6,8 @@ import { logError } from '@edx/frontend-platform/logging';
import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
import { ErrorPage, AppContext } from '@edx/frontend-platform/react';
import { FooterSlot } from '@edx/frontend-component-footer';
import { Alert } from '@openedx/paragon';
import Footer from '@edx/frontend-component-footer';
import { Alert } from '@edx/paragon';
import { RequestKeys } from 'data/constants/requests';
import store from 'data/store';
@@ -17,15 +17,16 @@ import {
} from 'data/redux';
import { reduxHooks } from 'hooks';
import Dashboard from 'containers/Dashboard';
import ZendeskFab from 'components/ZendeskFab';
import { ExperimentProvider } from 'ExperimentContext';
import track from 'tracking';
import fakeData from 'data/services/lms/fakeData/courses';
import AppWrapper from 'containers/AppWrapper';
import AppWrapper from 'containers/WidgetContainers/AppWrapper';
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
import './App.scss';
@@ -41,7 +42,7 @@ export const App = () => {
const loadData = reduxHooks.useLoadData();
React.useEffect(() => {
if (authenticatedUser?.administrator || getConfig().NODE_ENV === 'development') {
if (authenticatedUser?.administrator || process.env.NODE_ENV === 'development') {
window.loadEmptyData = () => {
loadData({ ...fakeData.globalData, courses: [] });
};
@@ -59,12 +60,12 @@ export const App = () => {
window.actions = actions;
window.track = track;
}
if (getConfig().HOTJAR_APP_ID) {
if (process.env.HOTJAR_APP_ID) {
try {
initializeHotjar({
hotjarId: getConfig().HOTJAR_APP_ID,
hotjarVersion: getConfig().HOTJAR_VERSION,
hotjarDebug: !!getConfig().HOTJAR_DEBUG,
hotjarId: process.env.HOTJAR_APP_ID,
hotjarVersion: process.env.HOTJAR_VERSION,
hotjarDebug: !!process.env.HOTJAR_DEBUG,
});
} catch (error) {
logError(error);
@@ -75,23 +76,25 @@ export const App = () => {
<>
<Helmet>
<title>{formatMessage(messages.pageTitle)}</title>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
<div>
<AppWrapper>
<LearnerDashboardHeader />
<main id="main">
<main>
{hasNetworkFailure
? (
<Alert variant="danger">
<ErrorPage message={formatMessage(messages.errorMessage, { supportEmail })} />
</Alert>
) : (
<Dashboard />
<ExperimentProvider>
<Dashboard />
</ExperimentProvider>
)}
</main>
</AppWrapper>
<FooterSlot />
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
<ZendeskFab />
</div>
</>
);

View File

@@ -1,12 +1,14 @@
// 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-header/dist/index";
@import "~@edx/frontend-component-footer/dist/_footer";
.text-ellipsis {

View File

@@ -1,19 +1,28 @@
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { Helmet } from 'react-helmet';
import { shallow } from '@edx/react-unit-test-utils';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import Footer from '@edx/frontend-component-footer';
import { useIntl } from '@edx/frontend-platform/i18n';
import { RequestKeys } from 'data/constants/requests';
import { reduxHooks } from 'hooks';
import Dashboard from 'containers/Dashboard';
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
import AppWrapper from 'containers/WidgetContainers/AppWrapper';
import { ExperimentProvider } from 'ExperimentContext';
import { App } from './App';
import messages from './messages';
jest.mock('@edx/frontend-component-footer', () => ({
FooterSlot: jest.fn(() => <div>FooterSlot</div>),
jest.mock('@edx/frontend-component-footer', () => 'Footer');
jest.mock('containers/Dashboard', () => 'Dashboard');
jest.mock('containers/LearnerDashboardHeader', () => 'LearnerDashboardHeader');
jest.mock('components/ZendeskFab', () => 'ZendeskFab');
jest.mock('ExperimentContext', () => ({
ExperimentProvider: 'ExperimentProvider',
}));
jest.mock('containers/Dashboard', () => jest.fn(() => <div>Dashboard</div>));
jest.mock('containers/LearnerDashboardHeader', () => jest.fn(() => <div>LearnerDashboardHeader</div>));
jest.mock('containers/AppWrapper', () => jest.fn(({ children }) => <div className="AppWrapper">{children}</div>));
jest.mock('containers/WidgetContainers/AppWrapper', () => 'AppWrapper');
jest.mock('data/redux', () => ({
selectors: 'redux.selectors',
actions: 'redux.actions',
@@ -28,107 +37,88 @@ jest.mock('hooks', () => ({
}));
jest.mock('data/store', () => 'data/store');
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(() => ({})),
}));
jest.mock('@edx/frontend-platform/react', () => ({
...jest.requireActual('@edx/frontend-platform/react'),
ErrorPage: () => 'ErrorPage',
}));
const loadData = jest.fn();
reduxHooks.useLoadData.mockReturnValue(loadData);
const supportEmail = 'test@support.com';
const logo = 'fakeLogo.png';
let el;
const supportEmail = 'test-support-url';
reduxHooks.usePlatformSettingsData.mockReturnValue({ supportEmail });
describe('App router component', () => {
process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG = logo;
const { formatMessage } = useIntl();
describe('component', () => {
const runBasicTests = () => {
it('displays title in helmet component', async () => {
await waitFor(() => expect(document.title).toEqual(messages.pageTitle.defaultMessage));
test('snapshot', () => { expect(el.snapshot).toMatchSnapshot(); });
it('displays title in helmet component', () => {
const control = el.instance
.findByType(Helmet)[0]
.findByType('title')[0];
expect(control.children[0].el).toEqual(formatMessage(messages.pageTitle));
});
it('displays learner dashboard header', () => {
const learnerDashboardHeader = screen.getByText('LearnerDashboardHeader');
expect(learnerDashboardHeader).toBeInTheDocument();
expect(el.instance.findByType(LearnerDashboardHeader).length).toEqual(1);
});
test('Footer logo drawn from env variable', () => {
expect(el.instance.findByType(Footer)[0].props.logo).toEqual(logo);
});
it('wraps the header and main components in an AppWrapper widget container', () => {
const appWrapper = screen.getByText('LearnerDashboardHeader').parentElement;
expect(appWrapper).toHaveClass('AppWrapper');
expect(appWrapper.children[1].id).toEqual('main');
});
it('displays footer slot', () => {
const footerSlot = screen.getByText('FooterSlot');
expect(footerSlot).toBeInTheDocument();
const container = el.instance.findByType(AppWrapper)[0];
expect(container.children[0].type).toEqual('LearnerDashboardHeader');
expect(container.children[1].type).toEqual('main');
});
};
describe('no network failure', () => {
beforeEach(() => {
jest.clearAllMocks();
beforeAll(() => {
reduxHooks.useRequestIsFailed.mockReturnValue(false);
getConfig.mockReturnValue({});
render(<IntlProvider locale="en"><App /></IntlProvider>);
el = shallow(<App />);
});
runBasicTests();
it('loads dashboard', () => {
const dashboard = screen.getByText('Dashboard');
expect(dashboard).toBeInTheDocument();
});
});
describe('no network failure with optimizely url', () => {
beforeEach(() => {
jest.clearAllMocks();
reduxHooks.useRequestIsFailed.mockReturnValue(false);
getConfig.mockReturnValue({ OPTIMIZELY_URL: 'fake.url' });
render(<IntlProvider locale="en"><App /></IntlProvider>);
});
runBasicTests();
it('loads dashboard', () => {
const dashboard = screen.getByText('Dashboard');
expect(dashboard).toBeInTheDocument();
});
});
describe('no network failure with optimizely project id', () => {
beforeEach(() => {
jest.clearAllMocks();
reduxHooks.useRequestIsFailed.mockReturnValue(false);
getConfig.mockReturnValue({ OPTIMIZELY_PROJECT_ID: 'fakeId' });
render(<IntlProvider locale="en"><App /></IntlProvider>);
});
runBasicTests();
it('loads dashboard', () => {
const dashboard = screen.getByText('Dashboard');
expect(dashboard).toBeInTheDocument();
const main = el.instance.findByType('main')[0];
expect(main.children.length).toEqual(1);
const expProvider = main.children[0];
expect(expProvider.type).toEqual('ExperimentProvider');
expect(expProvider.children.length).toEqual(1);
expect(
expProvider.matches(shallow(<ExperimentProvider><Dashboard /></ExperimentProvider>)),
).toEqual(true);
});
});
describe('initialize failure', () => {
beforeEach(() => {
jest.clearAllMocks();
beforeAll(() => {
reduxHooks.useRequestIsFailed.mockImplementation((key) => key === RequestKeys.initialize);
getConfig.mockReturnValue({});
render(<IntlProvider locale="en" messages={messages}><App /></IntlProvider>);
el = shallow(<App />);
});
runBasicTests();
it('loads error page', () => {
const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();
const errorPage = screen.getByText('ErrorPage');
expect(errorPage).toBeInTheDocument();
const main = el.instance.findByType('main')[0];
expect(main.children.length).toEqual(1);
const alert = main.children[0];
expect(alert.type).toEqual('Alert');
expect(alert.children.length).toEqual(1);
const errorPage = alert.children[0];
expect(errorPage.type).toEqual('ErrorPage');
expect(errorPage.props.message).toEqual(formatMessage(messages.errorMessage, { supportEmail }));
});
});
describe('refresh failure', () => {
beforeEach(() => {
beforeAll(() => {
reduxHooks.useRequestIsFailed.mockImplementation((key) => key === RequestKeys.refreshList);
getConfig.mockReturnValue({});
render(<IntlProvider locale="en"><App /></IntlProvider>);
el = shallow(<App />);
});
runBasicTests();
it('loads error page', () => {
const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();
const errorPage = screen.getByText('ErrorPage');
expect(errorPage).toBeInTheDocument();
const main = el.instance.findByType('main')[0];
expect(main.children.length).toEqual(1);
const alert = main.children[0];
expect(alert.type).toEqual('Alert');
expect(alert.children.length).toEqual(1);
const errorPage = alert.children[0];
expect(errorPage.type).toEqual('ErrorPage');
expect(errorPage.props.message).toEqual(formatMessage(messages.errorMessage, { supportEmail }));
});
});
});

64
src/ExperimentContext.jsx Normal file
View File

@@ -0,0 +1,64 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useWindowSize, breakpoints } from '@edx/paragon';
import { StrictDict } from 'utils';
import api from 'widgets/ProductRecommendations/api';
import * as module from './ExperimentContext';
export const state = StrictDict({
experiment: (val) => React.useState(val), // eslint-disable-line
countryCode: (val) => React.useState(val), // eslint-disable-line
});
export const useCountryCode = (setCountryCode) => {
React.useEffect(() => {
api
.fetchRecommendationsContext()
.then((response) => {
setCountryCode(response.data.countryCode);
})
.catch(() => {
setCountryCode('');
});
/* eslint-disable */
}, []);
};
export const ExperimentContext = React.createContext();
export const ExperimentProvider = ({ children }) => {
const [countryCode, setCountryCode] = module.state.countryCode(null);
const [experiment, setExperiment] = module.state.experiment({
isExperimentActive: false,
inRecommendationsVariant: true,
});
module.useCountryCode(setCountryCode);
const { width } = useWindowSize();
const isMobile = width < breakpoints.small.minWidth;
const contextValue = React.useMemo(
() => ({
experiment,
countryCode,
setExperiment,
setCountryCode,
isMobile,
}),
[experiment, countryCode, setExperiment, setCountryCode, isMobile]
);
return (
<ExperimentContext.Provider value={contextValue}>
{children}
</ExperimentContext.Provider>
);
};
export const useExperimentContext = () => React.useContext(ExperimentContext);
ExperimentProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export default { useCountryCode, useExperimentContext };

View File

@@ -0,0 +1,123 @@
import React from 'react';
import { mount } from 'enzyme';
import { waitFor } from '@testing-library/react';
import { useWindowSize } from '@edx/paragon';
import api from 'widgets/ProductRecommendations/api';
import { MockUseState } from 'testUtils';
import * as experiment from 'ExperimentContext';
const state = new MockUseState(experiment);
jest.unmock('react');
jest.spyOn(React, 'useEffect').mockImplementation((cb, prereqs) => ({ useEffect: { cb, prereqs } }));
jest.mock('widgets/ProductRecommendations/api', () => ({
fetchRecommendationsContext: jest.fn(),
}));
describe('experiments context', () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe('useCountryCode', () => {
describe('behaviour', () => {
describe('useEffect call', () => {
let calls;
let cb;
const setCountryCode = jest.fn();
const successfulFetch = { data: { countryCode: 'ZA' } };
beforeEach(() => {
experiment.useCountryCode(setCountryCode);
({ calls } = React.useEffect.mock);
[[cb]] = calls;
});
it('calls useEffect once', () => {
expect(calls.length).toEqual(1);
});
describe('successfull fetch', () => {
it('sets the country code', async () => {
let resolveFn;
api.fetchRecommendationsContext.mockReturnValueOnce(
new Promise((resolve) => {
resolveFn = resolve;
}),
);
cb();
expect(api.fetchRecommendationsContext).toHaveBeenCalled();
expect(setCountryCode).not.toHaveBeenCalled();
resolveFn(successfulFetch);
await waitFor(() => {
expect(setCountryCode).toHaveBeenCalledWith(successfulFetch.data.countryCode);
});
});
});
describe('unsuccessfull fetch', () => {
it('sets the country code to an empty string', async () => {
let rejectFn;
api.fetchRecommendationsContext.mockReturnValueOnce(
new Promise((resolve, reject) => {
rejectFn = reject;
}),
);
cb();
expect(api.fetchRecommendationsContext).toHaveBeenCalled();
expect(setCountryCode).not.toHaveBeenCalled();
rejectFn();
await waitFor(() => {
expect(setCountryCode).toHaveBeenCalledWith('');
});
});
});
});
});
});
describe('ExperimentProvider', () => {
const { ExperimentProvider } = experiment;
const TestComponent = () => {
const {
experiment: exp,
setExperiment,
countryCode,
setCountryCode,
isMobile,
} = experiment.useExperimentContext();
expect(exp.isExperimentActive).toBeFalsy();
expect(exp.inRecommendationsVariant).toBeTruthy();
expect(countryCode).toBeNull();
expect(isMobile).toBe(false);
expect(setExperiment).toBeDefined();
expect(setCountryCode).toBeDefined();
return (
<div />
);
};
it('allows access to child components with the context stateful values', () => {
const countryCodeSpy = jest.spyOn(experiment, 'useCountryCode').mockImplementationOnce(() => {});
useWindowSize.mockImplementationOnce(() => ({ width: 577, height: 943 }));
state.mock();
mount(
<ExperimentProvider>
<TestComponent />
</ExperimentProvider>,
);
expect(countryCodeSpy).toHaveBeenCalledWith(state.setState.countryCode);
state.expectInitializedWith(state.keys.countryCode, null);
state.expectInitializedWith(state.keys.experiment, { isExperimentActive: false, inRecommendationsVariant: true });
});
});
});

View File

@@ -0,0 +1,90 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`App router component component initialize failure snapshot 1`] = `
<Fragment>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
>
<title>
Learner Home
</title>
</HelmetWrapper>
<div>
<AppWrapper>
<LearnerDashboardHeader />
<main>
<Alert
variant="danger"
>
<ErrorPage
message="If you experience repeated failures, please email support at test-support-url"
/>
</Alert>
</main>
</AppWrapper>
<Footer
logo="fakeLogo.png"
/>
<ZendeskFab />
</div>
</Fragment>
`;
exports[`App router component component no network failure snapshot 1`] = `
<Fragment>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
>
<title>
Learner Home
</title>
</HelmetWrapper>
<div>
<AppWrapper>
<LearnerDashboardHeader />
<main>
<ExperimentProvider>
<Dashboard />
</ExperimentProvider>
</main>
</AppWrapper>
<Footer
logo="fakeLogo.png"
/>
<ZendeskFab />
</div>
</Fragment>
`;
exports[`App router component component refresh failure snapshot 1`] = `
<Fragment>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
>
<title>
Learner Home
</title>
</HelmetWrapper>
<div>
<AppWrapper>
<LearnerDashboardHeader />
<main>
<Alert
variant="danger"
>
<ErrorPage
message="If you experience repeated failures, please email support at test-support-url"
/>
</Alert>
</main>
</AppWrapper>
<Footer
logo="fakeLogo.png"
/>
<ZendeskFab />
</div>
</Fragment>
`;

View File

@@ -0,0 +1,40 @@
// 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`] = `
<AppProvider
store={
Object {
"redux": "store",
}
}
wrapWithRouter={true}
>
<NoticesWrapper>
<Routes>
<Route
element={
<PageWrap>
<App />
</PageWrap>
}
path="/"
/>
<Route
element={
<Navigate
replace={true}
to="/"
/>
}
path="*"
/>
</Routes>
</NoticesWrapper>
</AppProvider>
`;

View File

@@ -1,8 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from '@openedx/paragon';
import { Info } from '@openedx/paragon/icons';
import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
export const Banner = ({
children, variant, icon, className,

View File

@@ -1,27 +1,27 @@
import { render, screen } from '@testing-library/react';
import { shallow } from 'enzyme';
import { Alert } from '@edx/paragon';
import Banner from './Banner';
describe('Banner component', () => {
it('renders children content', () => {
render(<Banner>Test content</Banner>);
expect(screen.getByText('Test content')).toBeInTheDocument();
});
describe('Banner', () => {
const props = {
children: 'Hello, world!',
};
describe('snapshot', () => {
test('renders default banner', () => {
const wrapper = shallow(<Banner {...props} />);
expect(wrapper).toMatchSnapshot();
});
test('renders with variants', () => {
const wrapper = shallow(<Banner {...props} variant="success" />);
expect(wrapper).toMatchSnapshot();
it('uses default props correctly', () => {
render(<Banner>Test content</Banner>);
const banner = screen.getByRole('alert');
expect(banner).toHaveClass('mb-0');
});
it('accepts custom variant prop', () => {
render(<Banner variant="success">Test content</Banner>);
const banner = screen.getByRole('alert');
expect(banner).toHaveClass('alert-success');
});
it('accepts custom className prop', () => {
render(<Banner className="custom-class">Test content</Banner>);
const banner = screen.getByRole('alert');
expect(banner).toHaveClass('custom-class');
expect(wrapper.find(Alert).prop('variant')).toEqual('success');
});
test('renders with custom class', () => {
const wrapper = shallow(<Banner {...props} className="custom-class" />);
expect(wrapper).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import { MailtoLink } from '@edx/paragon';
export const EmailLink = ({ address }) => {
if (!address) {
return null;
}
return (
<MailtoLink to={address}>{address}</MailtoLink>
);
};
EmailLink.defaultProps = { address: null };
EmailLink.propTypes = { address: PropTypes.string };
export default EmailLink;

View File

@@ -0,0 +1,16 @@
import { shallow } from 'enzyme';
import EmailLink from './EmailLink';
describe('EmailLink', () => {
it('renders null when no address is provided', () => {
const wrapper = shallow(<EmailLink />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.isEmptyRender()).toEqual(true);
});
it('renders a MailtoLink when an address is provided', () => {
const wrapper = shallow(<EmailLink address="test@email.com" />);
expect(wrapper.find('MailtoLink').length).toEqual(1);
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -3,15 +3,16 @@ import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-
import { logError, logInfo } from '@edx/frontend-platform/logging';
export const noticesUrl = `${getConfig().LMS_BASE_URL}/notices/api/v1/unacknowledged`;
export const error404Message = 'This probably happened because the notices plugin is not installed on platform.';
export const getNotices = ({ onLoad, notFoundMessage }) => {
export const getNotices = ({ onLoad }) => {
const authenticatedUser = getAuthenticatedUser();
const handleError = async (e) => {
// Error probably means that notices is not installed, which is fine.
const { customAttributes: { httpErrorStatus } } = e;
if (httpErrorStatus === 404) {
logInfo(`${e}. ${notFoundMessage}`);
logInfo(`${e}. ${error404Message}`);
} else {
logError(e);
}

View File

@@ -1,11 +1,9 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { StrictDict } from 'utils';
import { getNotices } from './api';
import * as module from './hooks';
import messages from './messages';
/**
* This component uses the platform-plugin-notices plugin to function.
@@ -19,8 +17,6 @@ export const state = StrictDict({
export const useNoticesWrapperData = () => {
const [isRedirected, setIsRedirected] = module.state.isRedirected();
const { formatMessage } = useIntl();
React.useEffect(() => {
if (getConfig().ENABLE_NOTICES) {
getNotices({
@@ -30,10 +26,9 @@ export const useNoticesWrapperData = () => {
window.location.replace(`${data.data.results[0]}?next=${window.location.href}`);
}
},
notFoundMessage: formatMessage(messages.error404Message),
});
}
}, [setIsRedirected, formatMessage]);
}, [setIsRedirected]);
return { isRedirected };
};

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { MockUseState, formatMessage } from 'testUtils';
import { MockUseState } from 'testUtils';
import { getConfig } from '@edx/frontend-platform';
import { getNotices } from './api';
@@ -9,22 +9,6 @@ import * as hooks from './hooks';
jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() }));
jest.mock('./api', () => ({ getNotices: jest.fn() }));
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })),
useContext: jest.fn(context => context),
}));
jest.mock('@edx/frontend-platform/i18n', () => {
const { formatMessage: fn } = jest.requireActual('testUtils');
return {
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: () => ({
formatMessage: fn,
}),
};
});
getConfig.mockReturnValue({ ENABLE_NOTICES: true });
const state = new MockUseState(hooks);
@@ -50,7 +34,7 @@ describe('NoticesWrapper hooks', () => {
getConfig.mockReturnValueOnce({ ENABLE_NOTICES: false });
hooks.useNoticesWrapperData();
const [cb, prereqs] = React.useEffect.mock.calls[0];
expect(prereqs).toEqual([state.setState.isRedirected, formatMessage]);
expect(prereqs).toEqual([state.setState.isRedirected]);
cb();
expect(getNotices).not.toHaveBeenCalled();
});
@@ -59,7 +43,7 @@ describe('NoticesWrapper hooks', () => {
hooks.useNoticesWrapperData();
expect(React.useEffect).toHaveBeenCalled();
const [cb, prereqs] = React.useEffect.mock.calls[0];
expect(prereqs).toEqual([state.setState.isRedirected, formatMessage]);
expect(prereqs).toEqual([state.setState.isRedirected]);
cb();
expect(getNotices).toHaveBeenCalled();
const { onLoad } = getNotices.mock.calls[0][0];
@@ -75,7 +59,7 @@ describe('NoticesWrapper hooks', () => {
window.location = { replace: jest.fn(), href: 'test-old-href' };
hooks.useNoticesWrapperData();
const [cb, prereqs] = React.useEffect.mock.calls[0];
expect(prereqs).toEqual([state.setState.isRedirected, formatMessage]);
expect(prereqs).toEqual([state.setState.isRedirected]);
cb();
expect(getNotices).toHaveBeenCalled();
const { onLoad } = getNotices.mock.calls[0][0];

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { shallow } from 'enzyme';
import useNoticesWrapperData from './hooks';
import NoticesWrapper from '.';
@@ -6,31 +7,28 @@ import NoticesWrapper from '.';
jest.mock('./hooks', () => jest.fn());
const hookProps = { isRedirected: false };
useNoticesWrapperData.mockReturnValue(hookProps);
let el;
const children = [<b key={1}>some</b>, <i key={2}>children</i>];
describe('NoticesWrapper component', () => {
beforeEach(() => {
useNoticesWrapperData.mockClear();
});
describe('behavior', () => {
it('initializes hooks', () => {
useNoticesWrapperData.mockReturnValue(hookProps);
render(<NoticesWrapper>{children}</NoticesWrapper>);
el = shallow(<NoticesWrapper>{children}</NoticesWrapper>);
expect(useNoticesWrapperData).toHaveBeenCalledWith();
});
});
describe('output', () => {
it('does not show children if redirected', () => {
useNoticesWrapperData.mockReturnValueOnce({ isRedirected: true });
render(<NoticesWrapper>{children}</NoticesWrapper>);
expect(screen.queryByText('some')).not.toBeInTheDocument();
expect(screen.queryByText('children')).not.toBeInTheDocument();
el = shallow(<NoticesWrapper>{children}</NoticesWrapper>);
expect(el.children().length).toEqual(0);
});
it('shows children if not redirected', () => {
useNoticesWrapperData.mockReturnValue(hookProps);
render(<NoticesWrapper>{children}</NoticesWrapper>);
expect(screen.getByText('some')).toBeInTheDocument();
expect(screen.getByText('children')).toBeInTheDocument();
el = shallow(<NoticesWrapper>{children}</NoticesWrapper>);
expect(el.children().length).toEqual(2);
expect(el.children().at(0).matchesElement(children[0])).toEqual(true);
expect(el.children().at(1).matchesElement(children[1])).toEqual(true);
});
});
});

View File

@@ -1,11 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
error404Message: {
id: 'learner-dash.notices.error404Message',
defaultMessage: 'This probably happened because the notices plugin is not installed on platform.',
description: 'Error message when notices API returns 404',
},
});
export default messages;

View File

@@ -0,0 +1,65 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ZendeskFab snapshot 1`] = `
<Zendesk
cookies={true}
defer={true}
webWidget={
Object {
"answerBot": Object {
"avatar": Object {
"name": Object {
"*": "edX Support",
},
"url": "https://edx-cdn.org/v3/prod/favicon.ico",
},
"contactOnlyAfterQuery": true,
"suppress": false,
"title": Object {
"*": "edX Support",
},
},
"chat": Object {
"departments": Object {
"enabled": Array [
"account settings",
"billing and payments",
"certificates",
"deadlines",
"errors and technical issues",
"other",
"proctoring",
],
},
"suppress": false,
},
"contactForm": Object {
"attachments": true,
"selectTicketForm": Object {
"*": "Please choose your request type:",
},
"ticketForms": Array [
Object {
"fields": Array [
Object {
"id": "description",
"prefill": Object {
"*": "",
},
},
],
"id": 360003368814,
"subject": false,
},
],
},
"contactOptions": Object {
"enabled": false,
},
"helpCenter": Object {
"originalArticleButton": true,
},
}
}
/>
`;

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import Zendesk from 'react-zendesk';
import messages from './messages';
const ZendeskFab = () => {
const { formatMessage } = useIntl();
const setting = {
cookies: true,
webWidget: {
contactOptions: {
enabled: false,
},
chat: {
suppress: false,
departments: {
enabled: ['account settings', 'billing and payments', 'certificates', 'deadlines', 'errors and technical issues', 'other', 'proctoring'],
},
},
contactForm: {
ticketForms: [
{
id: 360003368814,
subject: false,
fields: [{ id: 'description', prefill: { '*': '' } }],
},
],
selectTicketForm: {
'*': formatMessage(messages.selectTicketForm),
},
attachments: true,
},
helpCenter: {
originalArticleButton: true,
},
answerBot: {
suppress: false,
contactOnlyAfterQuery: true,
title: { '*': formatMessage(messages.supportTitle) },
avatar: {
url: 'https://edx-cdn.org/v3/prod/favicon.ico',
name: { '*': formatMessage(messages.supportTitle) },
},
},
},
};
return (
<Zendesk defer zendeskKey={getConfig().ZENDESK_KEY} {...setting} />
);
};
export default ZendeskFab;

View File

@@ -0,0 +1,12 @@
import { shallow } from 'enzyme';
import ZendeskFab from '.';
jest.mock('react-zendesk', () => 'Zendesk');
describe('ZendeskFab', () => {
test('snapshot', () => {
const wrapper = shallow(<ZendeskFab />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,16 @@
import { StrictDict } from 'utils';
export const messages = StrictDict({
supportTitle: {
id: 'zendesk.supportTitle',
description: 'Title for the support button',
defaultMessage: 'edX Support',
},
selectTicketForm: {
id: 'zendesk.selectTicketForm',
description: 'Select ticket form',
defaultMessage: 'Please choose your request type:',
},
});
export default messages;

View File

@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Banner snapshot renders default banner 1`] = `
<Alert
className="mb-0"
icon={[MockFunction icons.Info]}
variant="info"
>
Hello, world!
</Alert>
`;
exports[`Banner snapshot renders with custom class 1`] = `
<Alert
className="custom-class"
icon={[MockFunction icons.Info]}
variant="info"
>
Hello, world!
</Alert>
`;
exports[`Banner snapshot renders with variants 1`] = `
<Alert
className="mb-0"
icon={[MockFunction icons.Info]}
variant="success"
>
Hello, world!
</Alert>
`;

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EmailLink renders a MailtoLink when an address is provided 1`] = `
<MailtoLink
to="test@email.com"
>
test@email.com
</MailtoLink>
`;
exports[`EmailLink renders null when no address is provided 1`] = `""`;

View File

@@ -2,7 +2,6 @@ const configuration = {
// BASE_URL: process.env.BASE_URL,
LMS_BASE_URL: process.env.LMS_BASE_URL,
ECOMMERCE_BASE_URL: process.env.ECOMMERCE_BASE_URL,
CREDIT_PURCHASE_URL: process.env.CREDIT_PURCHASE_URL,
// LOGIN_URL: process.env.LOGIN_URL,
// LOGOUT_URL: process.env.LOGOUT_URL,
// CSRF_TOKEN_API_PATH: process.env.CSRF_TOKEN_API_PATH,
@@ -13,14 +12,11 @@ const configuration = {
// ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME,
LEARNING_BASE_URL: process.env.LEARNING_BASE_URL,
SESSION_COOKIE_DOMAIN: process.env.SESSION_COOKIE_DOMAIN || '',
ZENDESK_KEY: process.env.ZENDESK_KEY,
SUPPORT_URL: process.env.SUPPORT_URL || null,
ENABLE_NOTICES: process.env.ENABLE_NOTICES || null,
CAREER_LINK_URL: process.env.CAREER_LINK_URL || null,
LOGO_URL: process.env.LOGO_URL,
ENABLE_EDX_PERSONAL_DASHBOARD: process.env.ENABLE_EDX_PERSONAL_DASHBOARD === 'true',
SEARCH_CATALOG_URL: process.env.SEARCH_CATALOG_URL || null,
ENABLE_PROGRAMS: process.env.ENABLE_PROGRAMS === 'true',
NON_BROWSABLE_COURSES: process.env.NON_BROWSABLE_COURSES === 'true',
};
const features = {};

View File

@@ -1,13 +0,0 @@
import PropTypes from 'prop-types';
export const AppWrapper = ({
children,
}) => children;
AppWrapper.propTypes = {
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node),
]).isRequired,
};
export default AppWrapper;

View File

@@ -0,0 +1,29 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks';
import messages from './messages';
export const useCertificatePreviewData = () => {
const { formatMessage } = useIntl();
const selectedCardId = reduxHooks.useCertificatePreviewData().cardId;
const { courseId } = reduxHooks.useCardCourseRunData(selectedCardId) || {};
const courseTitle = courseId;
const header = formatMessage(messages.previewTitle, { courseTitle });
const closePreviewModal = reduxHooks.useUpdateCertificatePreviewModalCallback(null);
const getCertificatePreviewUrl = () => `${getConfig().LMS_BASE_URL}/certificates/upsell/course/${courseId}`;
return {
showModal: selectedCardId != null,
header,
closePreviewModal,
getCertificatePreviewUrl,
};
};
export default useCertificatePreviewData;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
ModalDialog,
} from '@edx/paragon';
import { UpgradeButton } from '../CourseCard/components/CourseCardActions/UpgradeButton';
import useCertificatePreviewData from './hooks';
export const CertificatePreviewModal = ({
cardId,
}) => {
const {
showModal,
header,
closePreviewModal,
getCertificatePreviewUrl,
} = useCertificatePreviewData();
return (
<ModalDialog
isOpen={showModal}
onClose={closePreviewModal}
hasCloseButton
isFullscreenOnMobile
size="lg"
className="p-4 px-4.5"
title={header}
>
<h3>{header}</h3>
<div>
<iframe
title={header}
src={getCertificatePreviewUrl()}
width={725}
height={400}
/>
</div>
<UpgradeButton
cardId={cardId}
/>
</ModalDialog>
);
};
CertificatePreviewModal.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default CertificatePreviewModal;

View File

@@ -0,0 +1,12 @@
/* eslint-disable quotes */
import { StrictDict } from 'utils';
export const messages = StrictDict({
previewTitle: {
id: 'learner-dash.certificatePreview.title',
description: 'The title of the email settings modal',
defaultMessage: 'Your certificate preview for {courseTitle} ',
},
});
export default messages;

View File

@@ -1,8 +1,10 @@
@import "@edx/paragon/scss/core/core";
.course-card {
.card {
.pgn__card-wrapper-image-cap.vertical {
display: flex;
min-height: var(--pgn-size-card-image-vertical-max-height);
min-height: $card-image-vertical-max-height;
}
.pgn__card-image-cap {
border-bottom-left-radius: 0 !important;
@@ -51,11 +53,11 @@
> .alert {
border-radius: 0;
box-shadow: none;
padding: var(--pgn-spacing-spacer-3) var(--pgn-spacing-spacer-4);
padding: map-get($spacers, 3) map-get($spacers, 4);
&:last-of-type {
border-bottom-left-radius: var(--pgn-size-alert-border-radius);
border-bottom-right-radius: var(--pgn-size-alert-border-radius);
border-bottom-left-radius: $alert-border-radius;
border-bottom-right-radius: $alert-border-radius;
}
}

View File

@@ -0,0 +1,111 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseCard component snapshot: collapsed 1`] = `
<div
className="mb-4.5 course-card"
data-testid="CourseCard"
id="test-card-id"
>
<Card
orientation="vertical"
>
<div
className="d-flex flex-column w-100"
>
<div>
<CourseCardImage
cardId="test-card-id"
orientation="horizontal"
/>
<Card.Body>
<Card.Header
actions={
<CourseCardMenu
cardId="test-card-id"
/>
}
title={
<CourseCardTitle
cardId="test-card-id"
/>
}
/>
<Card.Section
className="pt-0"
>
<CourseCardDetails
cardId="test-card-id"
/>
</Card.Section>
<Card.Footer
orientation="vertical"
>
<CourseCardActions
cardId="test-card-id"
/>
</Card.Footer>
</Card.Body>
</div>
<CourseCardBanners
cardId="test-card-id"
/>
</div>
</Card>
</div>
`;
exports[`CourseCard component snapshot: not collapsed 1`] = `
<div
className="mb-4.5 course-card"
data-testid="CourseCard"
id="test-card-id"
>
<Card
orientation="horizontal"
>
<div
className="d-flex flex-column w-100"
>
<div
className="d-flex"
>
<CourseCardImage
cardId="test-card-id"
orientation="horizontal"
/>
<Card.Body>
<Card.Header
actions={
<CourseCardMenu
cardId="test-card-id"
/>
}
title={
<CourseCardTitle
cardId="test-card-id"
/>
}
/>
<Card.Section
className="pt-0"
>
<CourseCardDetails
cardId="test-card-id"
/>
</Card.Section>
<Card.Footer
orientation="horizontal"
>
<CourseCardActions
cardId="test-card-id"
/>
</Card.Footer>
</Card.Body>
</div>
<CourseCardBanners
cardId="test-card-id"
/>
</div>
</Card>
</div>
`;

View File

@@ -0,0 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ActionButton snapshot is collapsed 1`] = `
<Button
arbitary="props"
size="sm"
/>
`;
exports[`ActionButton snapshot is not collapsed 1`] = `
<Button
arbitary="props"
/>
`;

View File

@@ -1,4 +1,4 @@
import { useWindowSize, breakpoints } from '@openedx/paragon';
import { useWindowSize, breakpoints } from '@edx/paragon';
export const useIsCollapsed = () => {
const { width } = useWindowSize();

View File

@@ -1,33 +1,6 @@
import { useWindowSize, breakpoints } from '@openedx/paragon';
import { useWindowSize, breakpoints } from '@edx/paragon';
import useIsCollapsed from './hooks';
jest.mock('@openedx/paragon', () => ({
...jest.requireActual('@openedx/paragon'),
useWindowSize: jest.fn(),
breakpoints: {
extraSmall: {
minWidth: 0,
maxWidth: 575,
},
small: {
minWidth: 576,
maxWidth: 767,
},
medium: {
minWidth: 768,
maxWidth: 991,
},
large: {
minWidth: 992,
maxWidth: 1199,
},
extraLarge: {
minWidth: 1200,
maxWidth: 100000,
},
},
}));
describe('useIsCollapsed', () => {
it('returns true only when it is between medium and small', () => {
// make sure all three breakpoints gap is large enough for test

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Button } from '@openedx/paragon';
import { Button } from '@edx/paragon';
import useIsCollapsed from './hooks';

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
import { shallow } from 'enzyme';
import ActionButton from '.';
import useIsCollapsed from './hooks';
@@ -7,22 +8,18 @@ jest.mock('./hooks', () => jest.fn());
describe('ActionButton', () => {
const props = {
className: 'custom-class',
children: 'Test',
arbitary: 'props',
};
it('is collapsed', async () => {
useIsCollapsed.mockReturnValue(true);
render(<ActionButton {...props} />);
const button = screen.getByRole('button', { name: 'Test' });
expect(button).toHaveClass('btn-sm', 'custom-class');
});
it('is not collapsed', () => {
useIsCollapsed.mockReturnValue(false);
render(<ActionButton {...props} />);
const button = screen.getByRole('button', { name: 'Test' });
expect(button).toBeInTheDocument();
expect(button).not.toHaveClass('size', 'sm');
describe('snapshot', () => {
test('is collapsed', () => {
useIsCollapsed.mockReturnValueOnce(true);
const wrapper = shallow(<ActionButton {...props} />);
expect(wrapper).toMatchSnapshot();
});
test('is not collapsed', () => {
useIsCollapsed.mockReturnValueOnce(false);
const wrapper = shallow(<ActionButton {...props} />);
expect(wrapper).toMatchSnapshot();
});
});
});

View File

@@ -1,6 +1,6 @@
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 { htmlProps } from 'data/constants/htmlKeys';
import { reduxHooks } from 'hooks';
import track from 'tracking';
import useActionDisabledState from '../hooks';
@@ -19,70 +19,68 @@ jest.mock('hooks', () => ({
useTrackCourseEvent: jest.fn(),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableBeginCourse: false })));
jest.mock('./ActionButton', () => 'ActionButton');
jest.mock('./ActionButton/hooks', () => jest.fn(() => false));
let wrapper;
const homeUrl = 'home-url';
reduxHooks.useCardCourseRunData.mockReturnValue({ homeUrl });
const execEdPath = (cardId) => `exec-ed-tracking-path=${cardId}`;
reduxHooks.useCardExecEdTrackingParam.mockImplementation(execEdPath);
reduxHooks.useTrackCourseEvent.mockImplementation(
(eventName, cardId, url) => ({ trackCourseEvent: { eventName, cardId, url } }),
(eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
);
const props = {
cardId: 'cardId',
};
const renderComponent = () => render(<IntlProvider locale="en"><BeginCourseButton {...props} /></IntlProvider>);
describe('BeginCourseButton', () => {
const props = {
cardId: 'cardId',
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('initiliaze hooks', () => {
describe('behavior', () => {
it('initializes course run data with cardId', () => {
renderComponent();
wrapper = shallow(<BeginCourseButton {...props} />);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId);
});
it('loads exec education path param', () => {
renderComponent();
wrapper = shallow(<BeginCourseButton {...props} />);
expect(reduxHooks.useCardExecEdTrackingParam).toHaveBeenCalledWith(props.cardId);
});
it('loads disabled states for begin action from action hooks', () => {
renderComponent();
wrapper = shallow(<BeginCourseButton {...props} />);
expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId);
});
});
describe('behavior', () => {
describe('snapshot', () => {
describe('disabled', () => {
it('should be disabled', () => {
beforeEach(() => {
useActionDisabledState.mockReturnValueOnce({ disableBeginCourse: true });
renderComponent();
const button = screen.getByRole('button', { name: 'Begin Course' });
expect(button).toHaveClass('disabled');
expect(button).toHaveAttribute('aria-disabled', 'true');
wrapper = shallow(<BeginCourseButton {...props} />);
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('should be disabled', () => {
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
});
describe('enabled', () => {
it('should be enabled', () => {
renderComponent();
const button = screen.getByRole('button', { name: 'Begin Course' });
expect(button).not.toHaveClass('disabled');
expect(button).not.toHaveAttribute('aria-disabled', 'true');
beforeEach(() => {
wrapper = shallow(<BeginCourseButton {...props} />);
});
it('should track enter course clicked event on click, with exec ed param', async () => {
renderComponent();
const user = userEvent.setup();
const button = screen.getByRole('button', { name: 'Begin Course' });
user.click(button);
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('should be enabled', () => {
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
});
it('should track enter course clicked event on click, with exec ed param', () => {
expect(wrapper.prop(htmlProps.onClick)).toEqual(reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
props.cardId,
homeUrl + execEdPath(props.cardId),
);
));
});
});
});

View File

@@ -1,7 +1,6 @@
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 { htmlProps } from 'data/constants/htmlKeys';
import { reduxHooks } from 'hooks';
import track from 'tracking';
import useActionDisabledState from '../hooks';
@@ -21,63 +20,65 @@ jest.mock('hooks', () => ({
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableResumeCourse: false })));
jest.mock('./ActionButton/hooks', () => jest.fn(() => false));
jest.mock('./ActionButton', () => 'ActionButton');
const resumeUrl = 'resume-url';
reduxHooks.useCardCourseRunData.mockReturnValue({ resumeUrl });
const execEdPath = (cardId) => `exec-ed-tracking-path=${cardId}`;
reduxHooks.useCardExecEdTrackingParam.mockImplementation(execEdPath);
reduxHooks.useTrackCourseEvent.mockImplementation(
(eventName, cardId, url) => ({ trackCourseEvent: { eventName, cardId, url } }),
(eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
);
let wrapper;
describe('ResumeButton', () => {
const props = {
cardId: 'cardId',
};
describe('initialize hooks', () => {
beforeEach(() => render(<IntlProvider locale="en"><ResumeButton {...props} /></IntlProvider>));
describe('behavior', () => {
it('initializes course run data with cardId', () => {
wrapper = shallow(<ResumeButton {...props} />);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId);
});
it('loads exec education path param', () => {
wrapper = shallow(<ResumeButton {...props} />);
expect(reduxHooks.useCardExecEdTrackingParam).toHaveBeenCalledWith(props.cardId);
});
it('loads disabled states for resume action from action hooks', () => {
wrapper = shallow(<ResumeButton {...props} />);
expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId);
});
});
describe('behavior', () => {
describe('snapshot', () => {
describe('disabled', () => {
beforeEach(() => {
useActionDisabledState.mockReturnValueOnce({ disableResumeCourse: true });
wrapper = shallow(<ResumeButton {...props} />);
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('should be disabled', () => {
render(<IntlProvider locale="en"><ResumeButton {...props} /></IntlProvider>);
const button = screen.getByRole('button', { name: 'Resume' });
expect(button).toHaveClass('disabled');
expect(button).toHaveAttribute('aria-disabled', 'true');
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
});
describe('enabled', () => {
it('should be enabled', () => {
render(<IntlProvider locale="en"><ResumeButton {...props} /></IntlProvider>);
const button = screen.getByRole('button', { name: 'Resume' });
expect(button).toBeInTheDocument();
expect(button).not.toHaveClass('disabled');
expect(button).not.toHaveAttribute('aria-disabled', 'true');
beforeEach(() => {
wrapper = shallow(<ResumeButton {...props} />);
});
it('should track enter course clicked event on click, with exec ed param', async () => {
render(<IntlProvider locale="en"><ResumeButton {...props} /></IntlProvider>);
const user = userEvent.setup();
const button = screen.getByRole('button', { name: 'Resume' });
user.click(button);
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('should be enabled', () => {
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
});
it('should track enter course clicked event on click, with exec ed param', () => {
expect(wrapper.prop(htmlProps.onClick)).toEqual(reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
props.cardId,
resumeUrl + execEdPath(props.cardId),
);
));
});
});
});

View File

@@ -1,43 +1,35 @@
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 { reduxHooks } from 'hooks';
import { htmlProps } from 'data/constants/htmlKeys';
import useActionDisabledState from '../hooks';
import SelectSessionButton from './SelectSessionButton';
jest.mock('hooks', () => ({
reduxHooks: {
useUpdateSelectSessionModalCallback: jest.fn(),
useUpdateSelectSessionModalCallback: () => jest.fn().mockName('mockOpenSessionModal'),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableSelectSession: false })));
jest.mock('./ActionButton', () => 'ActionButton');
jest.mock('./ActionButton/hooks', () => jest.fn(() => false));
let wrapper;
describe('SelectSessionButton', () => {
const props = { cardId: 'cardId' };
it('default render', () => {
render(<IntlProvider locale="en"><SelectSessionButton {...props} /></IntlProvider>);
const button = screen.getByRole('button', { name: 'Select Session' });
expect(button).toBeInTheDocument();
expect(wrapper).toMatchSnapshot();
wrapper = shallow(<SelectSessionButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
expect(wrapper.prop(htmlProps.onClick).getMockName()).toEqual(
reduxHooks.useUpdateSelectSessionModalCallback().getMockName(),
);
});
describe('if useActionDisabledState is false', () => {
it('should disabled Select Session', () => {
useActionDisabledState.mockReturnValueOnce({ disableSelectSession: true });
render(<IntlProvider locale="en"><SelectSessionButton {...props} /></IntlProvider>);
const button = screen.getByRole('button', { name: 'Select Session' });
expect(button).toBeDisabled();
});
});
describe('on click', () => {
it('should call openSessionModal', async () => {
render(<IntlProvider locale="en"><SelectSessionButton {...props} /></IntlProvider>);
const user = userEvent.setup();
const button = screen.getByRole('button', { name: 'Select Session' });
await user.click(button);
expect(reduxHooks.useUpdateSelectSessionModalCallback).toHaveBeenCalledWith(props.cardId);
});
test('disabled states', () => {
useActionDisabledState.mockReturnValueOnce({ disableSelectSession: true });
expect(wrapper).toMatchSnapshot();
wrapper = shallow(<SelectSessionButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
});

View File

@@ -0,0 +1,45 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Locked } from '@edx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton';
import messages from './messages';
export const UpgradeButton = ({ cardId }) => {
const { formatMessage } = useIntl();
const { upgradeUrl } = reduxHooks.useCardCourseRunData(cardId);
const { disableUpgradeCourse } = useActionDisabledState(cardId);
const trackUpgradeClick = reduxHooks.useTrackCourseEvent(
track.course.upgradeClicked,
cardId,
upgradeUrl,
);
const enabledProps = {
as: 'a',
href: upgradeUrl,
onClick: trackUpgradeClick,
};
return (
<ActionButton
iconBefore={Locked}
variant="outline-primary"
disabled={disableUpgradeCourse}
{...!disableUpgradeCourse && enabledProps}
>
{formatMessage(messages.upgrade)}
</ActionButton>
);
};
UpgradeButton.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default UpgradeButton;

View File

@@ -0,0 +1,50 @@
import { shallow } from 'enzyme';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import { htmlProps } from 'data/constants/htmlKeys';
import useActionDisabledState from '../hooks';
import UpgradeButton from './UpgradeButton';
jest.mock('tracking', () => ({
course: {
upgradeClicked: jest.fn().mockName('segment.trackUpgradeClicked'),
},
}));
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseRunData: jest.fn(),
useTrackCourseEvent: jest.fn(
(eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableUpgradeCourse: false })));
jest.mock('./ActionButton', () => 'ActionButton');
describe('UpgradeButton', () => {
const props = {
cardId: 'cardId',
};
const upgradeUrl = 'upgradeUrl';
reduxHooks.useCardCourseRunData.mockReturnValue({ upgradeUrl });
describe('snapshot', () => {
test('can upgrade', () => {
const wrapper = shallow(<UpgradeButton {...props} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
expect(wrapper.prop(htmlProps.onClick)).toEqual(reduxHooks.useTrackCourseEvent(
track.course.upgradeClicked,
props.cardId,
upgradeUrl,
));
});
test('cannot upgrade', () => {
useActionDisabledState.mockReturnValueOnce({ disableUpgradeCourse: true });
const wrapper = shallow(<UpgradeButton {...props} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
});
});

View File

@@ -1,8 +1,7 @@
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 track from 'tracking';
import { htmlProps } from 'data/constants/htmlKeys';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks';
import ViewCourseButton from './ViewCourseButton';
@@ -16,40 +15,32 @@ jest.mock('tracking', () => ({
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseRunData: jest.fn(() => ({ homeUrl: 'homeUrl' })),
useTrackCourseEvent: jest.fn(),
useTrackCourseEvent: jest.fn(
(eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableViewCourse: false })));
jest.mock('./ActionButton/hooks', () => jest.fn(() => false));
jest.mock('./ActionButton', () => 'ActionButton');
const defaultProps = { cardId: 'cardId' };
const homeUrl = 'homeUrl';
describe('ViewCourseButton', () => {
it('learner can view course', async () => {
render(<IntlProvider locale="en"><ViewCourseButton {...defaultProps} /></IntlProvider>);
const button = screen.getByRole('button', { name: 'View Course' });
expect(button).toBeInTheDocument();
expect(button).not.toHaveClass('disabled');
expect(button).not.toHaveAttribute('aria-disabled', 'true');
});
it('calls trackCourseEvent on click', async () => {
render(<IntlProvider locale="en"><ViewCourseButton {...defaultProps} /></IntlProvider>);
const user = userEvent.setup();
const button = screen.getByRole('button', { name: 'View Course' });
await user.click(button);
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
test('learner can view course', () => {
const wrapper = shallow(<ViewCourseButton {...defaultProps} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.onClick)).toEqual(reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
defaultProps.cardId,
homeUrl,
);
));
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
});
it('learner cannot view course', () => {
test('learner cannot view course', () => {
useActionDisabledState.mockReturnValueOnce({ disableViewCourse: true });
render(<IntlProvider locale="en"><ViewCourseButton {...defaultProps} /></IntlProvider>);
const button = screen.getByRole('button', { name: 'View Course' });
expect(button).toHaveClass('disabled');
expect(button).toHaveAttribute('aria-disabled', 'true');
const wrapper = shallow(<ViewCourseButton {...defaultProps} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
});

View File

@@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BeginCourseButton snapshot disabled snapshot 1`] = `
<ActionButton
as="a"
disabled={true}
href="#"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "home-urlexec-ed-tracking-path=cardId",
},
}
}
>
Begin Course
</ActionButton>
`;
exports[`BeginCourseButton snapshot enabled snapshot 1`] = `
<ActionButton
as="a"
disabled={false}
href="#"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "home-urlexec-ed-tracking-path=cardId",
},
}
}
>
Begin Course
</ActionButton>
`;

View File

@@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ResumeButton snapshot disabled snapshot 1`] = `
<ActionButton
as="a"
disabled={true}
href="#"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "resume-urlexec-ed-tracking-path=cardId",
},
}
}
>
Resume
</ActionButton>
`;
exports[`ResumeButton snapshot enabled snapshot 1`] = `
<ActionButton
as="a"
disabled={false}
href="#"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "resume-urlexec-ed-tracking-path=cardId",
},
}
}
>
Resume
</ActionButton>
`;

View File

@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelectSessionButton default render 1`] = `undefined`;
exports[`SelectSessionButton disabled states 1`] = `
<ActionButton
disabled={false}
onClick={[MockFunction mockOpenSessionModal]}
>
Select Session
</ActionButton>
`;

View File

@@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UpgradeButton snapshot can upgrade 1`] = `
<ActionButton
as="a"
disabled={false}
href="upgradeUrl"
iconBefore={[MockFunction icons.Locked]}
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.trackUpgradeClicked],
"upgradeUrl": "upgradeUrl",
},
}
}
variant="outline-primary"
>
Upgrade
</ActionButton>
`;
exports[`UpgradeButton snapshot cannot upgrade 1`] = `
<ActionButton
disabled={true}
iconBefore={[MockFunction icons.Locked]}
variant="outline-primary"
>
Upgrade
</ActionButton>
`;

View File

@@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ViewCourseButton learner can view course 1`] = `
<ActionButton
as="a"
disabled={false}
href="#"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "homeUrl",
},
}
}
>
View Course
</ActionButton>
`;
exports[`ViewCourseButton learner cannot view course 1`] = `
<ActionButton
as="a"
disabled={true}
href="#"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "homeUrl",
},
}
}
>
View Course
</ActionButton>
`;

View File

@@ -1,11 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ActionRow } from '@openedx/paragon';
import { ActionRow } from '@edx/paragon';
import { reduxHooks } from 'hooks';
import CourseCardActionSlot from 'plugin-slots/CourseCardActionSlot';
import UpgradeButton from './UpgradeButton';
import SelectSessionButton from './SelectSessionButton';
import BeginCourseButton from './BeginCourseButton';
import ResumeButton from './ResumeButton';
@@ -14,13 +14,15 @@ import ViewCourseButton from './ViewCourseButton';
export const CourseCardActions = ({ cardId }) => {
const { isEntitlement, isFulfilled } = reduxHooks.useCardEntitlementData(cardId);
const {
isVerified,
hasStarted,
isExecEd2UCourse,
} = reduxHooks.useCardEnrollmentData(cardId);
const { isArchived } = reduxHooks.useCardCourseRunData(cardId);
return (
<ActionRow data-test-id="CourseCardActions">
<CourseCardActionSlot cardId={cardId} />
{!(isEntitlement || isVerified || isExecEd2UCourse) && <UpgradeButton cardId={cardId} />}
{isEntitlement && (isFulfilled
? <ViewCourseButton cardId={cardId} />
: <SelectSessionButton cardId={cardId} />

View File

@@ -1,6 +1,13 @@
import { render, screen } from '@testing-library/react';
import { shallow } from '@edx/react-unit-test-utils';
import { reduxHooks } from 'hooks';
import UpgradeButton from './UpgradeButton';
import SelectSessionButton from './SelectSessionButton';
import BeginCourseButton from './BeginCourseButton';
import ResumeButton from './ResumeButton';
import ViewCourseButton from './ViewCourseButton';
import CourseCardActions from '.';
jest.mock('hooks', () => ({
@@ -12,15 +19,16 @@ jest.mock('hooks', () => ({
},
}));
jest.mock('plugin-slots/CourseCardActionSlot', () => jest.fn(() => <div>CourseCardActionSlot</div>));
jest.mock('./SelectSessionButton', () => jest.fn(() => <div>SelectSessionButton</div>));
jest.mock('./ViewCourseButton', () => jest.fn(() => <div>ViewCourseButton</div>));
jest.mock('./BeginCourseButton', () => jest.fn(() => <div>BeginCourseButton</div>));
jest.mock('./ResumeButton', () => jest.fn(() => <div>ResumeButton</div>));
jest.mock('./UpgradeButton', () => 'UpgradeButton');
jest.mock('./SelectSessionButton', () => 'SelectSessionButton');
jest.mock('./ViewCourseButton', () => 'ViewCourseButton');
jest.mock('./BeginCourseButton', () => 'BeginCourseButton');
jest.mock('./ResumeButton', () => 'ResumeButton');
const cardId = 'test-card-id';
const props = { cardId };
let el;
describe('CourseCardActions', () => {
const mockHooks = ({
isEntitlement = false,
@@ -36,58 +44,71 @@ describe('CourseCardActions', () => {
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isExecEd2UCourse, isVerified, hasStarted });
reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading });
};
const renderComponent = () => render(<CourseCardActions {...props} />);
describe('hooks', () => {
const render = () => {
el = shallow(<CourseCardActions {...props} />);
};
describe('behavior', () => {
it('initializes redux hooks', () => {
mockHooks();
renderComponent();
render();
expect(reduxHooks.useCardEntitlementData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
});
});
describe('output', () => {
describe('Exec Ed course', () => {
it('does not render upgrade button', () => {
mockHooks({ isExecEd2UCourse: true });
render();
expect(el.instance.findByType(UpgradeButton).length).toEqual(0);
});
});
describe('entitlement course', () => {
it('does not render upgrade button', () => {
mockHooks({ isEntitlement: true });
render();
expect(el.instance.findByType(UpgradeButton).length).toEqual(0);
});
it('renders ViewCourseButton if fulfilled', () => {
mockHooks({ isEntitlement: true, isFulfilled: true });
renderComponent();
const ViewCourseButton = screen.getByText('ViewCourseButton');
expect(ViewCourseButton).toBeInTheDocument();
render();
expect(el.instance.findByType(ViewCourseButton)[0].props.cardId).toEqual(cardId);
});
it('renders SelectSessionButton if not fulfilled', () => {
mockHooks({ isEntitlement: true });
renderComponent();
const SelectSessionButton = screen.getByText('SelectSessionButton');
expect(SelectSessionButton).toBeInTheDocument();
render();
expect(el.instance.findByType(SelectSessionButton)[0].props.cardId).toEqual(cardId);
});
});
describe('not entitlement, verified, or exec ed', () => {
it('renders CourseCardActionSlot and ViewCourseButton for archived courses', () => {
describe('verified course', () => {
it('does not render upgrade button', () => {
mockHooks({ isVerified: true });
render();
expect(el.instance.findByType(UpgradeButton).length).toEqual(0);
});
});
describe('not entielement, verified, or exec ed', () => {
it('renders UpgradeButton and ViewCourseButton for archived courses', () => {
mockHooks({ isArchived: true });
renderComponent();
const CourseCardActionSlot = screen.getByText('CourseCardActionSlot');
expect(CourseCardActionSlot).toBeInTheDocument();
const ViewCourseButton = screen.getByText('ViewCourseButton');
expect(ViewCourseButton).toBeInTheDocument();
render();
expect(el.instance.findByType(UpgradeButton)[0].props.cardId).toEqual(cardId);
expect(el.instance.findByType(ViewCourseButton)[0].props.cardId).toEqual(cardId);
});
describe('unstarted courses', () => {
it('renders CourseCardActionSlot and BeginCourseButton', () => {
it('renders UpgradeButton and BeginCourseButton', () => {
mockHooks();
renderComponent();
const CourseCardActionSlot = screen.getByText('CourseCardActionSlot');
expect(CourseCardActionSlot).toBeInTheDocument();
const BeginCourseButton = screen.getByText('BeginCourseButton');
expect(BeginCourseButton).toBeInTheDocument();
render();
expect(el.instance.findByType(UpgradeButton)[0].props.cardId).toEqual(cardId);
expect(el.instance.findByType(BeginCourseButton)[0].props.cardId).toEqual(cardId);
});
});
describe('active courses (started, and not archived)', () => {
it('renders CourseCardActionSlot and ResumeButton', () => {
it('renders UpgradeButton and ResumeButton', () => {
mockHooks({ hasStarted: true });
renderComponent();
const CourseCardActionSlot = screen.getByText('CourseCardActionSlot');
expect(CourseCardActionSlot).toBeInTheDocument();
const ResumeButton = screen.getByText('ResumeButton');
expect(ResumeButton).toBeInTheDocument();
render();
expect(el.instance.findByType(UpgradeButton)[0].props.cardId).toEqual(cardId);
expect(el.instance.findByType(ResumeButton)[0].props.cardId).toEqual(cardId);
});
});
});

View File

@@ -1,6 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
import { StrictDict } from 'utils';
const messages = defineMessages({
export const messages = StrictDict({
upgrade: {
id: 'learner-dash.courseCard.actions.upgrade',
description: 'Course card upgrade button text',
defaultMessage: 'Upgrade',
},
beginCourse: {
id: 'learner-dash.courseCard.actions.beginCourse',
description: 'Course card begin-course button text',

View File

@@ -2,8 +2,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { MailtoLink, Hyperlink } from '@openedx/paragon';
import { CheckCircle } from '@openedx/paragon/icons';
import { MailtoLink, Hyperlink } from '@edx/paragon';
import { CheckCircle } from '@edx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { utilHooks, reduxHooks } from 'hooks';
@@ -26,14 +26,17 @@ export const CertificateBanner = ({ cardId }) => {
const { formatMessage } = useIntl();
const formatDate = useFormatDate();
const emailLink = address => <MailtoLink to={address}>{address}</MailtoLink>;
const emailLink = address => address && <MailtoLink to={address}>{address}</MailtoLink>;
if (certificate.isRestricted) {
return (
<Banner variant="danger">
{ supportEmail ? formatMessage(messages.certRestricted, { supportEmail: emailLink(supportEmail) }) : formatMessage(messages.certRestrictedNoEmail)}
{formatMessage(messages.certRestricted, { supportEmail: emailLink(supportEmail) })}
{isVerified && ' '}
{isVerified && (billingEmail ? formatMessage(messages.certRefundContactBilling, { billingEmail: emailLink(billingEmail) }) : formatMessage(messages.certRefundContactBillingNoEmail))}
{isVerified && formatMessage(
messages.certRefundContactBilling,
{ billingEmail: emailLink(billingEmail) },
)}
</Banner>
);
}

View File

@@ -1,8 +1,8 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { shallow } from 'enzyme';
import { reduxHooks } from 'hooks';
import CertificateBanner from './CertificateBanner';
import messages from './messages';
jest.mock('hooks', () => ({
utilHooks: {
@@ -17,226 +17,139 @@ jest.mock('hooks', () => ({
},
}));
const defaultCertificate = {
availableDate: '10/20/3030',
isRestricted: false,
isDownloadable: false,
isEarnedButUnavailable: false,
};
const defaultEnrollment = {
isAudit: false,
isVerified: false,
};
const defaultCourseRun = { isArchived: false };
const defaultGrade = { isPassing: false };
const defaultPlatformSettings = {};
const props = { cardId: 'cardId' };
const supportEmail = 'suport@email.com';
const billingEmail = 'billing@email.com';
jest.mock('components/Banner', () => 'Banner');
describe('CertificateBanner', () => {
const props = { cardId: 'cardId' };
reduxHooks.usePlatformSettingsData.mockReturnValue({
supportEmail: 'suport@email',
billingEmail: 'billing@email',
});
reduxHooks.useCardCourseRunData.mockReturnValue({
minPassingGrade: 0.8,
progressUrl: 'progressUrl',
});
const defaultCertificate = {
availableDate: '10/20/3030',
isRestricted: false,
isDownloadable: false,
isEarnedButUnavailable: false,
};
const defaultEnrollment = {
isAudit: false,
isVerified: false,
};
const defaultCourseRun = { isArchived: false };
const defaultGrade = { isPassing: false };
const createWrapper = ({
certificate = {},
enrollment = {},
grade = {},
courseRun = {},
platformSettings = {},
}) => {
reduxHooks.useCardGradeData.mockReturnValueOnce({ ...defaultGrade, ...grade });
reduxHooks.useCardCertificateData.mockReturnValueOnce({ ...defaultCertificate, ...certificate });
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ ...defaultEnrollment, ...enrollment });
reduxHooks.useCardCourseRunData.mockReturnValueOnce({ ...defaultCourseRun, ...courseRun });
reduxHooks.usePlatformSettingsData.mockReturnValueOnce({ ...defaultPlatformSettings, ...platformSettings });
return render(<IntlProvider locale="en"><CertificateBanner {...props} /></IntlProvider>);
return shallow(<CertificateBanner {...props} />);
};
beforeEach(() => {
jest.clearAllMocks();
});
it('is restricted', () => {
createWrapper({
certificate: {
isRestricted: true,
},
/** TODO: Update tests to validate snapshots **/
describe('snapshot', () => {
test('is restricted', () => {
const wrapper = createWrapper({
certificate: {
isRestricted: true,
},
});
expect(wrapper).toMatchSnapshot();
});
const banner = screen.getByRole('alert');
expect(banner).toBeInTheDocument();
const msg = screen.getByText((text) => text.includes('please let us know.'));
expect(msg).toBeInTheDocument();
expect(msg).not.toContain(supportEmail);
});
it('is restricted with support email', () => {
createWrapper({
certificate: {
isRestricted: true,
},
platformSettings: {
supportEmail,
},
test('is restricted and verified', () => {
const wrapper = createWrapper({
certificate: {
isRestricted: true,
},
enrollment: {
isVerified: true,
},
});
expect(wrapper).toMatchSnapshot();
});
const banner = screen.getByRole('alert');
expect(banner).toBeInTheDocument();
const msg = screen.getByText((text) => text.includes(supportEmail));
expect(msg).toBeInTheDocument();
});
it('is restricted with billing email but not verified', () => {
createWrapper({
certificate: {
isRestricted: true,
},
platformSettings: {
billingEmail,
},
test('is passing and is downloadable', () => {
const wrapper = createWrapper({
grade: { isPassing: true },
certificate: { isDownloadable: true },
});
expect(wrapper).toMatchSnapshot();
});
const banner = screen.getByRole('alert');
expect(banner).toBeInTheDocument();
expect(banner).toHaveClass('alert-danger');
const msg = screen.queryByText((text) => text.includes(billingEmail));
expect(msg).not.toBeInTheDocument();
});
it('is restricted and verified', () => {
createWrapper({
certificate: {
isRestricted: true,
},
enrollment: {
isVerified: true,
},
test('not passing and is downloadable', () => {
const wrapper = createWrapper({
grade: { isPassing: false },
certificate: { isDownloadable: true },
});
expect(wrapper).toMatchSnapshot();
});
const banner = screen.getByRole('alert');
expect(banner).toBeInTheDocument();
const restrictedMsg = screen.getByText((text) => text.includes('please let us know.'));
expect(restrictedMsg).toBeInTheDocument();
const refundMsg = screen.getByText((text) => text.includes('If you would like a refund'));
expect(refundMsg).toBeInTheDocument();
});
it('is restricted and verified with support email', () => {
createWrapper({
certificate: {
isRestricted: true,
},
enrollment: {
isVerified: true,
},
platformSettings: {
supportEmail,
},
test('not passing and audit', () => {
const wrapper = createWrapper({
enrollment: {
isAudit: true,
},
});
expect(wrapper).toMatchSnapshot();
});
const restrictedMsg = screen.getByText((text) => text.includes(supportEmail));
expect(restrictedMsg).toBeInTheDocument();
const refundMsg = screen.getByText((text) => text.includes('If you would like a refund'));
expect(refundMsg).toBeInTheDocument();
expect(refundMsg.innerHTML).not.toContain(billingEmail);
});
it('is restricted and verified with billing email', () => {
createWrapper({
certificate: {
isRestricted: true,
},
enrollment: {
isVerified: true,
},
platformSettings: {
billingEmail,
},
test('not passing and has finished', () => {
const wrapper = createWrapper({
courseRun: { isArchived: true },
});
expect(wrapper).toMatchSnapshot();
});
const restrictedMsg = screen.queryByText((text) => text.includes('please let us know.'));
expect(restrictedMsg).toBeInTheDocument();
expect(restrictedMsg.innerHTML).not.toContain(supportEmail);
const refundMsg = screen.getByText((text) => text.includes(billingEmail));
expect(refundMsg).toBeInTheDocument();
});
it('is restricted and verified with support and billing email', () => {
createWrapper({
certificate: {
isRestricted: true,
},
enrollment: {
isVerified: true,
},
platformSettings: {
supportEmail,
billingEmail,
},
test('not passing and not audit and not finished', () => {
const wrapper = createWrapper({});
expect(wrapper).toMatchSnapshot();
});
const restrictedMsg = screen.getByText((text) => text.includes(supportEmail));
expect(restrictedMsg).toBeInTheDocument();
const refundMsg = screen.getByText((text) => text.includes(billingEmail));
expect(refundMsg).toBeInTheDocument();
});
it('is passing and is downloadable', () => {
createWrapper({
grade: { isPassing: true },
certificate: { isDownloadable: true },
test('is passing and is earned but unavailable', () => {
const wrapper = createWrapper({
grade: {
isPassing: true,
},
certificate: {
isEarnedButUnavailable: true,
},
});
expect(wrapper).toMatchSnapshot();
});
const banner = screen.getByRole('alert');
expect(banner).toBeInTheDocument();
expect(banner).toHaveClass('alert-success');
const readyMsg = screen.getByText((text) => text.includes('Congratulations.'));
expect(readyMsg).toBeInTheDocument();
});
it('not passing and is downloadable', () => {
createWrapper({
grade: { isPassing: false },
certificate: { isDownloadable: true },
test('is passing and not downloadable render empty', () => {
const wrapper = createWrapper({
grade: {
isPassing: true,
},
});
expect(wrapper).toMatchSnapshot();
});
const banner = screen.getByRole('alert');
expect(banner).toBeInTheDocument();
expect(banner).toHaveClass('alert-success');
const readyMsg = screen.getByText((text) => text.includes('Congratulations.'));
expect(readyMsg).toBeInTheDocument();
});
it('not passing and audit', () => {
createWrapper({
enrollment: {
isAudit: true,
},
describe('behavior', () => {
it('is restricted', () => {
const wrapper = createWrapper({
certificate: {
isRestricted: true,
},
});
const bannerMessage = wrapper.find('format-message-function').map(el => el.prop('message').defaultMessage).join('\n');
expect(bannerMessage).toEqual(messages.certRestricted.defaultMessage);
expect(bannerMessage).toContain(messages.certRestricted.defaultMessage);
});
const banner = screen.getByRole('alert');
expect(banner).toHaveClass('alert-info');
const auditMsg = screen.getByText((text) => text.includes('Grade required to pass the course:'));
expect(auditMsg).toBeInTheDocument();
});
it('not passing and has finished', () => {
createWrapper({
courseRun: { isArchived: true },
it('is restricted and verified', () => {
const wrapper = createWrapper({
certificate: {
isRestricted: true,
},
enrollment: {
isVerified: true,
},
});
const bannerMessage = wrapper.find('format-message-function').map(el => el.prop('message').defaultMessage).join('\n');
expect(bannerMessage).toContain(messages.certRestricted.defaultMessage);
expect(bannerMessage).toContain(messages.certRefundContactBilling.defaultMessage);
});
const banner = screen.getByRole('alert');
expect(banner).toHaveClass('alert-warning');
const archivedMsg = screen.getByText('You are not eligible for a certificate.');
expect(archivedMsg).toBeInTheDocument();
});
it('not passing and not audit and not finished', () => {
createWrapper({});
const banner = screen.getByRole('alert');
expect(banner).toHaveClass('alert-warning');
const msg = screen.getByText((text) => text.includes('Grade required for a certificate'));
expect(msg).toBeInTheDocument();
});
it('is passing and is earned but unavailable', () => {
createWrapper({
grade: {
isPassing: true,
},
certificate: {
isEarnedButUnavailable: true,
},
});
const banner = screen.getByRole('alert');
expect(banner).toHaveClass('alert-info');
const earnedMsg = screen.getByText((text) => text.includes('Your grade and certificate will be ready after'));
expect(earnedMsg).toBeInTheDocument();
});
it('is passing and not downloadable render empty', () => {
createWrapper({
grade: {
isPassing: true,
},
});
const banner = screen.queryByRole('alert');
expect(banner).toBeNull();
});
});

View File

@@ -1,7 +1,7 @@
/* eslint-disable max-len */
import React from 'react';
import PropTypes from 'prop-types';
import { Hyperlink } from '@openedx/paragon';
import { Hyperlink } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { utilHooks, reduxHooks } from 'hooks';
@@ -12,6 +12,7 @@ export const CourseBanner = ({ cardId }) => {
const {
isVerified,
isAuditAccessExpired,
canUpgrade,
coursewareAccess = {},
} = reduxHooks.useCardEnrollmentData(cardId);
const courseRun = reduxHooks.useCardCourseRunData(cardId);
@@ -25,7 +26,13 @@ export const CourseBanner = ({ cardId }) => {
return (
<>
{isAuditAccessExpired
&& (
&& (canUpgrade ? (
<Banner>
{formatMessage(messages.auditAccessExpired)}
{' '}
{formatMessage(messages.upgradeToAccess)}
</Banner>
) : (
<Banner>
{formatMessage(messages.auditAccessExpired)}
{' '}
@@ -33,7 +40,17 @@ export const CourseBanner = ({ cardId }) => {
{formatMessage(messages.findAnotherCourse)}
</Hyperlink>
</Banner>
)}
))}
{courseRun.isActive && !canUpgrade && (
<Banner>
{formatMessage(messages.upgradeDeadlinePassed)}
{' '}
<Hyperlink isInline destination={courseRun.marketingUrl || ''}>
{formatMessage(messages.exploreCourseDetails)}
</Hyperlink>
</Banner>
)}
{(!isStaff && isTooEarly && courseRun.startDate) && (
<Banner>
@@ -42,7 +59,6 @@ export const CourseBanner = ({ cardId }) => {
})}
</Banner>
)}
{(!isStaff && hasUnmetPrerequisites) && (
<Banner>{formatMessage(messages.prerequisitesNotMet)}</Banner>
)}

View File

@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import React from 'react';
import { shallow } from 'enzyme';
import { Hyperlink } from '@edx/paragon';
import { reduxHooks } from 'hooks';
import { formatMessage } from 'testUtils';
@@ -7,6 +8,7 @@ import { CourseBanner } from './CourseBanner';
import messages from './messages';
jest.mock('components/Banner', () => 'Banner');
jest.mock('hooks', () => ({
utilHooks: {
useFormatDate: () => date => date,
@@ -17,10 +19,13 @@ jest.mock('hooks', () => ({
},
}));
const cardId = 'test-card-id';
const cardId = 'my-test-course-number';
let el;
const enrollmentData = {
isVerified: false,
canUpgrade: false,
isAuditAccessExpired: false,
coursewareAccess: {
hasUnmetPrerequisites: false,
@@ -34,7 +39,7 @@ const courseRunData = {
marketingUrl: 'marketing-url',
};
const renderCourseBanner = (overrides = {}) => {
const render = (overrides = {}) => {
const {
courseRun = {},
enrollment = {},
@@ -47,58 +52,114 @@ const renderCourseBanner = (overrides = {}) => {
...enrollmentData,
...enrollment,
});
return render(<IntlProvider locale="en"><CourseBanner cardId={cardId} /></IntlProvider>);
el = shallow(<CourseBanner cardId={cardId} />);
};
describe('CourseBanner', () => {
it('initializes data with course number from enrollment, course and course run data', () => {
renderCourseBanner();
test('initializes data with course number from enrollment, course and course run data', () => {
render();
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
});
it('no display if learner is verified', () => {
renderCourseBanner({ enrollment: { isVerified: true } });
expect(screen.queryByRole('alert')).toBeNull();
test('no display if learner is verified', () => {
render({ enrollment: { isVerified: true } });
expect(el.isEmptyRender()).toEqual(true);
});
describe('audit access expired', () => {
it('should display correct message and link', () => {
renderCourseBanner({ enrollment: { isAuditAccessExpired: true } });
const auditAccessText = screen.getByText(formatMessage(messages.auditAccessExpired));
expect(auditAccessText).toBeInTheDocument();
const auditAccessLink = screen.getByText(formatMessage(messages.findAnotherCourse));
expect(auditAccessLink).toBeInTheDocument();
describe('audit access expired, can upgrade', () => {
beforeEach(() => {
render({ enrollment: { isAuditAccessExpired: true, canUpgrade: true } });
});
test('snapshot: (auditAccessExpired, upgradeToAccess)', () => {
expect(el).toMatchSnapshot();
});
test('messages: (auditAccessExpired, upgradeToAccess)', () => {
expect(el.text()).toContain(messages.auditAccessExpired.defaultMessage);
expect(el.text()).toContain(messages.upgradeToAccess.defaultMessage);
});
});
describe('audit access expired, cannot upgrade', () => {
beforeEach(() => {
render({ enrollment: { isAuditAccessExpired: true } });
});
test('snapshot: (auditAccessExpired, findAnotherCourse hyperlink)', () => {
expect(el).toMatchSnapshot();
});
test('messages: (auditAccessExpired, upgradeToAccess)', () => {
expect(el.text()).toContain(messages.auditAccessExpired.defaultMessage);
expect(el.find(Hyperlink).text()).toEqual(messages.findAnotherCourse.defaultMessage);
});
});
describe('course run active and cannot upgrade', () => {
beforeEach(() => {
render({ courseRun: { isActive: true } });
});
test('snapshot: (upgradseDeadlinePassed, exploreCourseDetails hyperlink)', () => {
expect(el).toMatchSnapshot();
});
test('messages: (upgradseDeadlinePassed, exploreCourseDetails hyperlink)', () => {
expect(el.text()).toContain(messages.upgradeDeadlinePassed.defaultMessage);
const link = el.find(Hyperlink);
expect(link.text()).toEqual(messages.exploreCourseDetails.defaultMessage);
expect(link.props().destination).toEqual(courseRunData.marketingUrl);
});
});
test('no display if audit access not expired and (course is not active or can upgrade)', () => {
render();
// isEmptyRender() isn't true because the minimal is <Fragment />
expect(el.html()).toEqual('');
render({ enrollment: { canUpgrade: true }, courseRun: { isActive: true } });
expect(el.html()).toEqual('');
});
describe('unmet prerequisites', () => {
it('should display correct message', () => {
renderCourseBanner({ enrollment: { coursewareAccess: { hasUnmetPrerequisites: true } } });
const preReqText = screen.getByText(formatMessage(messages.prerequisitesNotMet));
expect(preReqText).toBeInTheDocument();
beforeEach(() => {
render({ enrollment: { coursewareAccess: { hasUnmetPrerequisites: true } } });
});
test('snapshot: unmetPrerequisites', () => {
expect(el).toMatchSnapshot();
});
test('messages: prerequisitesNotMet', () => {
expect(el.text()).toContain(messages.prerequisitesNotMet.defaultMessage);
});
});
describe('too early', () => {
describe('no start date', () => {
it('should not display banner', () => {
renderCourseBanner({ enrollment: { coursewareAccess: { isTooEarly: true } }, courseRun: { startDate: null } });
const banner = screen.queryByRole('alert');
expect(banner).toBeNull();
beforeEach(() => {
render({ enrollment: { coursewareAccess: { isTooEarly: true } }, courseRun: { startDate: null } });
});
test('snapshot', () => expect(el).toMatchSnapshot());
test('messages', () => expect(el.text()).toEqual(''));
});
describe('has start date', () => {
it('should display messages courseHasNotStarted', () => {
renderCourseBanner({ enrollment: { coursewareAccess: { isTooEarly: true } } });
const earlyMsg = screen.getByText(
beforeEach(() => {
render({ enrollment: { coursewareAccess: { isTooEarly: true } } });
});
test('snapshot', () => expect(el).toMatchSnapshot());
test('messages: courseHasNotStarted', () => {
expect(el.text()).toContain(
formatMessage(messages.courseHasNotStarted, { startDate: courseRunData.startDate }),
);
expect(earlyMsg).toBeInTheDocument();
});
});
});
describe('staff', () => {
it('should not display banner', () => {
renderCourseBanner({ enrollment: { coursewareAccess: { isStaff: true } } });
const banner = screen.queryByRole('alert');
expect(banner).toBeNull();
beforeEach(() => {
render({ enrollment: { coursewareAccess: { isStaff: true } } });
});
test('snapshot: isStaff', () => {
expect(el).toMatchSnapshot();
});
});
test('snapshot: stacking banners', () => {
render({
enrollment: {
coursewareAccess: {
isStaff: true,
isTooEarly: true,
hasUnmetPrerequisites: true,
},
},
});
expect(el).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CreditBanner component render with error state snapshot 1`] = `
<Banner
variant="danger"
>
<p
className="credit-error-msg"
>
<format-message-function
message={
Object {
"defaultMessage": "An error occurred with this transaction. For help, contact {supportEmailLink}.",
"description": "",
"id": "learner-dash.courseCard.banners.credit.error",
}
}
values={
Object {
"supportEmailLink": <EmailLink
address="test-support-email"
/>,
}
}
/>
</p>
<ContentComponent
cardId="test-card-id"
/>
</Banner>
`;
exports[`CreditBanner component render with no error state snapshot 1`] = `
<Banner>
<ContentComponent
cardId="test-card-id"
/>
</Banner>
`;

View File

@@ -4,8 +4,8 @@ import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import Banner from 'components/Banner';
import EmailLink from 'components/EmailLink';
import { MailtoLink } from '@openedx/paragon';
import hooks from './hooks';
import messages from './messages';
@@ -15,14 +15,13 @@ export const CreditBanner = ({ cardId }) => {
if (hookData === null) {
return null;
}
const { ContentComponent, error, supportEmail } = hookData;
const supportEmailLink = (<MailtoLink to={supportEmail}>{supportEmail}</MailtoLink>);
const supportEmailLink = (<EmailLink address={supportEmail} />);
return (
<Banner {...(error && { variant: 'danger' })}>
{error && (
<p className="credit-error-msg" data-testid="credit-error-msg">
{supportEmail ? formatMessage(messages.error, { supportEmailLink }) : formatMessage(messages.errorNoEmail)}
<p className="credit-error-msg">
{formatMessage(messages.error, { supportEmailLink })}
</p>
)}
<ContentComponent cardId={cardId} />

View File

@@ -1,65 +1,82 @@
import { screen, render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import EmailLink from 'components/EmailLink';
import hooks from './hooks';
import { CreditBanner } from '.';
import messages from './messages';
import CreditBanner from '.';
jest.mock('components/Banner', () => 'Banner');
jest.mock('components/EmailLink', () => 'EmailLink');
jest.mock('./hooks', () => ({
useCreditBannerData: jest.fn(),
}));
describe('CreditBanner', () => {
const mockCardId = 'test-card-id';
const mockContentComponent = () => <div data-testid="mock-content">Test Content</div>;
const mockSupportEmail = 'support@test.com';
let el;
const cardId = 'test-card-id';
const renderCreditBanner = () => render(
<IntlProvider locale="en">
<CreditBanner cardId={mockCardId} />
</IntlProvider>,
);
const ContentComponent = () => 'ContentComponent';
const supportEmail = 'test-support-email';
beforeEach(() => {
jest.clearAllMocks();
});
it('should return null if hook returns null', () => {
hooks.useCreditBannerData.mockReturnValue(null);
renderCreditBanner();
const banner = screen.queryByRole('alert');
expect(banner).toBeNull();
});
it('should render content component without error', () => {
hooks.useCreditBannerData.mockReturnValue({
ContentComponent: mockContentComponent,
error: false,
describe('CreditBanner component', () => {
describe('behavior', () => {
beforeEach(() => {
hooks.useCreditBannerData.mockReturnValue(null);
el = shallow(<CreditBanner cardId={cardId} />);
});
renderCreditBanner();
expect(screen.getByTestId('mock-content')).toBeInTheDocument();
expect(screen.queryByTestId('credit-error-msg')).not.toBeInTheDocument();
});
it('should render error message with support email', () => {
hooks.useCreditBannerData.mockReturnValue({
ContentComponent: mockContentComponent,
error: true,
supportEmail: mockSupportEmail,
it('initializes hooks with cardId', () => {
expect(hooks.useCreditBannerData).toHaveBeenCalledWith(cardId);
});
renderCreditBanner();
expect(screen.getByTestId('credit-error-msg')).toBeInTheDocument();
expect(screen.getByText(mockSupportEmail)).toBeInTheDocument();
});
it('should render error message without support email', () => {
hooks.useCreditBannerData.mockReturnValue({
ContentComponent: mockContentComponent,
error: true,
it('returns null if hookData is null', () => {
expect(el.isEmptyRender()).toEqual(true);
});
});
describe('render', () => {
describe('with error state', () => {
beforeEach(() => {
hooks.useCreditBannerData.mockReturnValue({
error: true,
ContentComponent,
supportEmail,
});
el = shallow(<CreditBanner cardId={cardId} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('passes danger variant to Banner parent', () => {
expect(el.find('Banner').props().variant).toEqual('danger');
});
it('includes credit-error-msg with support email link', () => {
expect(el.find('.credit-error-msg').containsMatchingElement(
formatMessage(messages.error, {
supportEmailLink: (<EmailLink address={supportEmail} />),
}),
)).toEqual(true);
});
it('loads ContentComponent with cardId', () => {
expect(el.find('ContentComponent').props().cardId).toEqual(cardId);
});
});
describe('with no error state', () => {
beforeEach(() => {
hooks.useCreditBannerData.mockReturnValue({
error: false,
ContentComponent,
supportEmail,
});
el = shallow(<CreditBanner cardId={cardId} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('loads ContentComponent with cardId', () => {
expect(el.find('ContentComponent').props().cardId).toEqual(cardId);
});
});
renderCreditBanner();
expect(screen.getByTestId('credit-error-msg')).toBeInTheDocument();
expect(screen.queryByText(mockSupportEmail)).not.toBeInTheDocument();
});
});

View File

@@ -1,16 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
import { StrictDict } from 'utils';
const messages = defineMessages({
export const messages = StrictDict({
error: {
id: 'learner-dash.courseCard.banners.credit.error',
description: '',
defaultMessage: 'An error occurred with this transaction. For help, contact {supportEmailLink}.',
},
errorNoEmail: {
id: 'learner-dash.courseCard.banners.credit.errorNoEmail',
description: '',
defaultMessage: 'An error occurred with this transaction.',
},
});
export default messages;

View File

@@ -1,8 +1,10 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import { reduxHooks } from 'hooks';
import messages from './messages';
import ProviderLink from './components/ProviderLink';
import ApprovedContent from './ApprovedContent';
jest.mock('hooks', () => ({
@@ -11,7 +13,10 @@ jest.mock('hooks', () => ({
useMasqueradeData: jest.fn(),
},
}));
jest.mock('./components/CreditContent', () => 'CreditContent');
jest.mock('./components/ProviderLink', () => 'ProviderLink');
let el;
const cardId = 'test-card-id';
const credit = {
providerStatusUrl: 'test-credit-provider-status-url',
@@ -21,54 +26,38 @@ reduxHooks.useCardCreditData.mockReturnValue(credit);
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
describe('ApprovedContent component', () => {
describe('hooks', () => {
beforeEach(() => {
el = shallow(<ApprovedContent cardId={cardId} />);
});
describe('behavior', () => {
it('initializes credit data with cardId', () => {
render(<IntlProvider locale="en"><ApprovedContent cardId={cardId} /></IntlProvider>);
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
describe('rendered CreditContent component', () => {
beforeEach(() => {
jest.clearAllMocks();
render(<IntlProvider locale="en"><ApprovedContent cardId={cardId} /></IntlProvider>);
let component;
beforeAll(() => {
component = el.find('CreditContent');
});
it('action.message is formatted viewCredit message', () => {
const actionButton = screen.getByRole('link', { name: messages.viewCredit.defaultMessage });
expect(actionButton).toBeInTheDocument();
expect(actionButton).toHaveTextContent(formatMessage(messages.viewCredit));
test('action.href from credit.providerStatusUrl', () => {
expect(component.props().action.href).toEqual(credit.providerStatusUrl);
});
it('action.href from credit.providerStatusUrl', () => {
const actionButton = screen.getByRole('link', { name: messages.viewCredit.defaultMessage });
expect(actionButton).toHaveAttribute('href', credit.providerStatusUrl);
test('action.message is formatted viewCredit message', () => {
expect(component.props().action.message).toEqual(formatMessage(messages.viewCredit));
});
it('action.disabled is false', () => {
const actionButton = screen.getByRole('link', { name: messages.viewCredit.defaultMessage });
expect(actionButton).not.toHaveAttribute('aria-disabled', 'true');
expect(actionButton).not.toHaveClass('disabled');
expect(actionButton).toBeEnabled();
test('action.disabled is false', () => {
expect(component.props().action.disabled).toEqual(false);
});
it('message is formatted approved message', () => {
const creditMsg = screen.getByTestId('credit-msg');
expect(creditMsg).toBeInTheDocument();
expect(creditMsg.textContent).toContain(`${credit.providerName} has approved your request for course credit`);
});
});
describe('when masquerading', () => {
beforeEach(() => {
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true });
render(<IntlProvider locale="en"><ApprovedContent cardId={cardId} /></IntlProvider>);
});
it('disables the action button', () => {
const actionButton = screen.getByRole('link', { name: messages.viewCredit.defaultMessage });
expect(actionButton).toHaveAttribute('aria-disabled', 'true');
expect(actionButton).toHaveClass('disabled');
});
it('still renders provider name and link correctly', () => {
const creditMsg = screen.getByTestId('credit-msg');
expect(creditMsg.textContent).toContain(credit.providerName);
test('message is formatted approved message', () => {
expect(component.props().message).toEqual(formatMessage(
messages.approved,
{
congratulations: (<b>{formatMessage(messages.congratulations)}</b>),
linkToProviderSite: <ProviderLink cardId={cardId} />,
providerName: credit.providerName,
},
));
});
});
});

View File

@@ -1,8 +1,8 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import React from 'react';
import { shallow } from 'enzyme';
import { reduxHooks } from 'hooks';
import { formatMessage } from 'testUtils';
import track from 'tracking';
import messages from './messages';
@@ -14,13 +14,16 @@ jest.mock('hooks', () => ({
useCardCourseRunData: jest.fn(),
},
}));
jest.mock('./components/CreditContent', () => 'CreditContent');
jest.mock('tracking', () => ({
credit: {
purchase: jest.fn(),
purchase: (...args) => ({ trackCredit: args }),
},
}));
let el;
let component;
const cardId = 'test-card-id';
const courseId = 'test-course-id';
const credit = {
@@ -29,45 +32,50 @@ const credit = {
reduxHooks.useCardCreditData.mockReturnValue(credit);
reduxHooks.useCardCourseRunData.mockReturnValue({ courseId });
const renderEligibleContent = () => render(<IntlProvider locale="en" messages={{}}><EligibleContent cardId={cardId} /></IntlProvider>);
const render = () => {
el = shallow(<EligibleContent cardId={cardId} />);
};
const loadComponent = () => {
component = el.find('CreditContent');
};
describe('EligibleContent component', () => {
describe('hooks', () => {
beforeEach(() => {
render();
});
describe('behavior', () => {
it('initializes credit data with cardId', () => {
renderEligibleContent();
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
it('initializes course run data with cardId', () => {
renderEligibleContent();
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
});
});
describe('behavior', () => {
describe('render', () => {
describe('rendered CreditContent component', () => {
it('action message is formatted getCredit message', () => {
renderEligibleContent();
const button = screen.getByRole('button', { name: messages.getCredit.defaultMessage });
expect(button).toBeInTheDocument();
beforeEach(() => {
loadComponent();
});
it('onClick sends credit purchase track event', async () => {
const user = userEvent.setup();
renderEligibleContent();
const button = screen.getByRole('button', { name: messages.getCredit.defaultMessage });
await user.click(button);
expect(track.credit.purchase).toHaveBeenCalledWith(courseId);
test('action.onClick sends credit purchase track event', () => {
expect(component.props().action.onClick).toEqual(
track.credit.purchase(courseId),
);
});
it('message is formatted eligible message if provider', () => {
renderEligibleContent();
const eligibleMessage = screen.getByTestId('credit-msg');
expect(eligibleMessage).toBeInTheDocument();
expect(eligibleMessage).toHaveTextContent(credit.providerName);
test('action.message is formatted getCredit message', () => {
expect(component.props().action.message).toEqual(formatMessage(messages.getCredit));
});
it('message is formatted eligible message if no provider', () => {
reduxHooks.useCardCreditData.mockReturnValue({});
renderEligibleContent();
const eligibleMessage = screen.getByTestId('credit-msg');
expect(eligibleMessage).toBeInTheDocument();
expect(eligibleMessage).toHaveTextContent(messages.getCredit.defaultMessage);
test('message is formatted eligible message if no provider', () => {
reduxHooks.useCardCreditData.mockReturnValueOnce({});
render();
loadComponent();
expect(component.props().message).toEqual(formatMessage(
messages.eligible,
{ getCredit: (<b>{formatMessage(messages.getCredit)}</b>) },
));
});
test('message is formatted eligible message if provider', () => {
expect(component.props().message).toEqual(
formatMessage(messages.eligibleFromProvider, { providerName: credit.providerName }),
);
});
});
});

View File

@@ -1,103 +1,73 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import { reduxHooks } from 'hooks';
import messages from './messages';
import hooks from './hooks';
import ProviderLink from './components/ProviderLink';
import MustRequestContent from './MustRequestContent';
jest.mock('./hooks', () => ({
useCreditRequestData: jest.fn(),
}));
jest.mock('hooks', () => ({
reduxHooks: {
useMasqueradeData: jest.fn(),
useCardCreditData: jest.fn(),
},
reduxHooks: { useMasqueradeData: jest.fn() },
}));
jest.mock('./components/CreditContent', () => 'CreditContent');
jest.mock('./components/ProviderLink', () => 'ProviderLink');
let el;
let component;
const cardId = 'test-card-id';
const requestData = {
url: 'test-request-data-url',
parameters: {
key1: 'val1',
key2: 'val2',
key3: 'val3',
},
};
const providerName = 'test-credit-provider-name';
const providerStatusUrl = 'test-credit-provider-status-url';
const requestData = { test: 'requestData' };
const createCreditRequest = jest.fn().mockName('createCreditRequest');
hooks.useCreditRequestData.mockReturnValue({
requestData,
createCreditRequest,
});
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
const renderMustRequestContent = () => render(
<IntlProvider locale="en" messages={messages}>
<MustRequestContent cardId={cardId} />
</IntlProvider>,
);
const render = () => {
el = shallow(<MustRequestContent cardId={cardId} />);
};
describe('MustRequestContent component', () => {
beforeEach(() => {
jest.clearAllMocks();
hooks.useCreditRequestData.mockReturnValue({
requestData,
createCreditRequest,
});
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
reduxHooks.useCardCreditData.mockReturnValue({
providerName,
providerStatusUrl,
});
render();
});
describe('hooks', () => {
describe('behavior', () => {
it('initializes credit request data with cardId', () => {
renderMustRequestContent();
expect(hooks.useCreditRequestData).toHaveBeenCalledWith(cardId);
});
});
describe('behavior', () => {
describe('rendered content', () => {
describe('render', () => {
describe('rendered CreditContent component', () => {
beforeEach(() => {
renderMustRequestContent();
component = el.find('CreditContent');
});
it('calls createCreditRequest when request credit button is clicked', async () => {
const user = userEvent.setup();
const button = screen.getByRole('button', { name: /request credit/i });
await user.click(button);
expect(createCreditRequest).toHaveBeenCalled();
test('action.onClick calls createCreditRequest from useCreditRequestData hook', () => {
expect(component.props().action.onClick).toEqual(createCreditRequest);
});
it('shows request credit button that is enabled', () => {
const button = screen.getByRole('button', { name: /request credit/i });
expect(button).toBeEnabled();
test('action.message is formatted requestCredit message', () => {
expect(component.props().action.message).toEqual(
formatMessage(messages.requestCredit),
);
});
it('displays must request message with provider link', () => {
expect(screen.getByTestId('credit-msg')).toHaveTextContent(/request credit/i);
test('action.disabled is false', () => {
expect(component.props().action.disabled).toEqual(false);
});
it('renders credit request form with correct data', () => {
const { container } = renderMustRequestContent();
const form = container.querySelector('form');
expect(form).toBeInTheDocument();
expect(form).toHaveAttribute('action', requestData.url);
test('message is formatted mustRequest message', () => {
expect(component.props().message).toEqual(
formatMessage(messages.mustRequest, {
linkToProviderSite: <ProviderLink cardId={cardId} />,
requestCredit: <b>{formatMessage(messages.requestCredit)}</b>,
}),
);
});
});
describe('when masquerading', () => {
beforeEach(() => {
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true });
renderMustRequestContent();
});
it('disables the request credit button', () => {
const button = screen.getByRole('button', { name: /request credit/i });
expect(button).toHaveClass('disabled');
expect(button).toHaveAttribute('aria-disabled', 'true');
test('requestData drawn from useCreditRequestData hook', () => {
expect(component.props().requestData).toEqual(requestData);
});
});
});

View File

@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import { reduxHooks } from 'hooks';
import messages from './messages';
@@ -9,6 +10,11 @@ import PendingContent from './PendingContent';
jest.mock('hooks', () => ({
reduxHooks: { useCardCreditData: jest.fn(), useMasqueradeData: jest.fn() },
}));
jest.mock('./components/CreditContent', () => 'CreditContent');
jest.mock('./components/ProviderLink', () => 'ProviderLink');
let el;
let component;
const cardId = 'test-card-id';
const providerName = 'test-credit-provider-name';
@@ -19,48 +25,38 @@ reduxHooks.useCardCreditData.mockReturnValue({
});
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
const renderPendingContent = () => render(
<IntlProvider messages={{}} locale="en">
<PendingContent cardId={cardId} />
</IntlProvider>,
);
const render = () => {
el = shallow(<PendingContent cardId={cardId} />);
};
describe('PendingContent component', () => {
describe('hooks', () => {
beforeEach(() => {
render();
});
describe('behavior', () => {
it('initializes card credit data with cardId', () => {
renderPendingContent();
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
});
describe('behavior', () => {
describe('render', () => {
describe('rendered CreditContent component', () => {
it('action message is formatted requestCredit message', () => {
renderPendingContent();
const button = screen.getByRole('link', { name: messages.viewDetails.defaultMessage });
expect(button).toBeInTheDocument();
beforeEach(() => {
component = el.find('CreditContent');
});
it('action href will go to provider status site', () => {
renderPendingContent();
const button = screen.getByRole('link', { name: messages.viewDetails.defaultMessage });
expect(button).toHaveAttribute('href', providerStatusUrl);
test('action.href will go to provider status site', () => {
expect(component.props().action.href).toEqual(providerStatusUrl);
});
it('action.disabled is false', () => {
renderPendingContent();
const button = screen.getByRole('link', { name: messages.viewDetails.defaultMessage });
expect(button).not.toHaveClass('disabled');
test('action.message is formatted requestCredit message', () => {
expect(component.props().action.message).toEqual(
formatMessage(messages.viewDetails),
);
});
it('message is formatted pending message with provider name', () => {
renderPendingContent();
const component = screen.getByTestId('credit-msg');
expect(component).toBeInTheDocument();
expect(component).toHaveTextContent(`${providerName} has received`);
test('action.disabled is false', () => {
expect(component.props().action.disabled).toEqual(false);
});
describe('when masqueradeData is true', () => {
it('disables the view details button', () => {
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true });
renderPendingContent();
const button = screen.getByRole('link', { name: messages.viewDetails.defaultMessage });
expect(button).toHaveClass('disabled');
});
test('message is formatted pending message', () => {
expect(component.props().message).toEqual(
formatMessage(messages.received, { providerName }),
);
});
});
});

View File

@@ -1,7 +1,10 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import { reduxHooks } from 'hooks';
import messages from './messages';
import ProviderLink from './components/ProviderLink';
import RejectedContent from './RejectedContent';
jest.mock('hooks', () => ({
@@ -9,6 +12,8 @@ jest.mock('hooks', () => ({
useCardCreditData: jest.fn(),
},
}));
jest.mock('./components/CreditContent', () => 'CreditContent');
jest.mock('./components/ProviderLink', () => 'ProviderLink');
const cardId = 'test-card-id';
const credit = {
@@ -17,27 +22,32 @@ const credit = {
};
reduxHooks.useCardCreditData.mockReturnValue(credit);
const renderRejectedContent = () => render(<IntlProvider><RejectedContent cardId={cardId} /></IntlProvider>);
let el;
let component;
const render = () => { el = shallow(<RejectedContent cardId={cardId} />); };
const loadComponent = () => { component = el.find('CreditContent'); };
describe('RejectedContent component', () => {
describe('hooks', () => {
beforeEach(render);
describe('behavior', () => {
it('initializes credit data with cardId', () => {
renderRejectedContent();
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
describe('rendered CreditContent component', () => {
it('no action is passed', () => {
renderRejectedContent();
const action = screen.queryByTestId('action-row-btn');
expect(action).not.toBeInTheDocument();
beforeAll(loadComponent);
test('no action is passed', () => {
expect(component.props().action).toEqual(undefined);
});
it('message is formatted rejected message', () => {
renderRejectedContent();
const message = screen.getByTestId('credit-msg');
expect(message).toBeInTheDocument();
expect(message).toHaveTextContent(`${credit.providerName} did not approve your request for course credit.`);
test('message is formatted rejected message', () => {
expect(component.props().message).toEqual(formatMessage(
messages.rejected,
{
linkToProviderSite: <ProviderLink cardId={cardId} />,
providerName: credit.providerName,
},
));
});
});
});

View File

@@ -1,12 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ActionRow, Button } from '@openedx/paragon';
import { ActionRow, Button } from '@edx/paragon';
import CreditRequestForm from './CreditRequestForm';
export const CreditContent = ({ action, message, requestData }) => (
<>
<div className="message-copy credit-msg" data-testid="credit-msg">
<div className="message-copy credit-msg">
{message}
</div>
{action && (
@@ -21,7 +21,6 @@ export const CreditContent = ({ action, message, requestData }) => (
variant="outline-primary"
className="border-gray-400"
onClick={action.onClick}
data-testid="action-row-btn"
>
{action.message}
</Button>

View File

@@ -1,7 +1,9 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { shallow } from 'enzyme';
import CreditContent from './CreditContent';
let el;
const action = {
href: 'test-action-href',
onClick: jest.fn().mockName('test-action-onClick'),
@@ -13,57 +15,45 @@ const message = 'test-message';
const requestData = { url: 'test-request-data-url', parameters: { key1: 'val1' } };
const props = { action, message, requestData };
const renderCreditContent = (data) => render(
<CreditContent {...data} />,
);
describe('CreditContent component', () => {
describe('render', () => {
describe('with action', () => {
it('loads href and message into action row button', () => {
renderCreditContent(props);
const button = screen.getByRole('link', { name: action.message });
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('href', action.href);
expect(button).not.toHaveAttribute('disabled');
beforeEach(() => {
el = shallow(<CreditContent {...props} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('loads href, onClick, and message into action row button', () => {
const buttonEl = el.find('ActionRow Button');
expect(buttonEl.props().href).toEqual(action.href);
expect(buttonEl.props().onClick).toEqual(action.onClick);
expect(buttonEl.props().disabled).toEqual(action.disabled);
expect(buttonEl.text()).toEqual(action.message);
});
it('loads message into credit-msg div', () => {
renderCreditContent(props);
const creditMsg = screen.getByTestId('credit-msg');
expect(creditMsg).toBeInTheDocument();
expect(creditMsg.innerHTML).toEqual(message);
expect(el.find('div.credit-msg').text()).toEqual(message);
});
it('loads CreditRequestForm with passed requestData', () => {
const { container } = renderCreditContent(props);
const creditForm = container.querySelector('form');
expect(creditForm).toBeInTheDocument();
expect(creditForm).toHaveAttribute('action', requestData.url);
expect(el.find('CreditRequestForm').props().requestData).toEqual(requestData);
});
it('disables action button when action.disabled is true', () => {
renderCreditContent({ ...props, action: { ...action, disabled: true } });
const button = screen.getByRole('link', { name: action.message });
expect(button).toBeInTheDocument();
expect(button).toHaveClass('disabled');
expect(button).toHaveAttribute('aria-disabled', 'true');
test('disables action button when action.disabled is true', () => {
el.setProps({ action: { ...action, disabled: true } });
expect(el.find('ActionRow Button').props().disabled).toEqual(true);
});
});
describe('without action', () => {
test('snapshot', () => {
el = shallow(<CreditContent {...{ message, requestData }} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('loads message into credit-msg div', () => {
renderCreditContent({ message, requestData });
const creditMsg = screen.getByTestId('credit-msg');
expect(creditMsg).toBeInTheDocument();
expect(creditMsg.innerHTML).toEqual(message);
expect(el.find('div.credit-msg').text()).toEqual(message);
});
it('loads CreditRequestForm with passed requestData', () => {
const { container } = renderCreditContent({ message, requestData });
const creditForm = container.querySelector('form');
expect(creditForm).toBeInTheDocument();
expect(creditForm).toHaveAttribute('action', requestData.url);
});
it('does not render action row button', () => {
renderCreditContent({ message, requestData });
const button = screen.queryByRole('link', { name: action.message });
expect(button).not.toBeInTheDocument();
expect(el.find('CreditRequestForm').props().requestData).toEqual(requestData);
});
});
});

View File

@@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CreditRequestForm component render output valid requestData snapshot 1`] = `
<Form
accept-method="UTF-8"
action="test-request-data-url"
className="hidden"
method="POST"
>
<FormControl
as="textarea"
key="key1"
name="key1"
value="val1"
/>
<FormControl
as="textarea"
key="key2"
name="key2"
value="val2"
/>
<FormControl
as="textarea"
key="key3"
name="key3"
value="val3"
/>
<Button
type="submit"
/>
</Form>
`;

View File

@@ -4,12 +4,6 @@ import useCreditRequestFormData from './hooks';
const requestData = 'test-request-data';
jest.mock('react', () => ({
...jest.requireActual('react'),
useRef: jest.fn((val) => ({ current: val, useRef: true })),
useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })),
}));
let out;
const ref = {
current: { click: jest.fn() },

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, Form, FormControl } from '@openedx/paragon';
import { Button, Form, FormControl } from '@edx/paragon';
import useCreditRequestFormData from './hooks';

View File

@@ -1,4 +1,5 @@
import { render } from '@testing-library/react';
import React from 'react';
import { shallow } from 'enzyme';
import { keyStore } from 'utils';
@@ -10,8 +11,7 @@ jest.mock('./hooks', () => ({
default: jest.fn(),
}));
const ref = { current: { click: jest.fn() }, useRef: jest.fn() };
const ref = 'test-ref';
const requestData = {
url: 'test-request-data-url',
parameters: {
@@ -25,41 +25,40 @@ const paramKeys = keyStore(requestData.parameters);
useCreditRequestFormData.mockReturnValue({ ref });
const renderForm = (data) => render(<CreditRequestForm requestData={data} />);
let el;
const shallowRender = (data) => { el = shallow(<CreditRequestForm requestData={data} />); };
describe('CreditRequestForm component', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('hooks', () => {
describe('behavior', () => {
it('initializes ref from hook with requestData', () => {
renderForm(requestData);
shallowRender(requestData);
expect(useCreditRequestFormData).toHaveBeenCalledWith(requestData);
});
});
describe('render output', () => {
describe('null requestData', () => {
it('returns null', () => {
const { container } = renderForm(null);
expect(container.firstChild).toBeNull();
shallowRender(null);
expect(el.isEmptyRender()).toEqual(true);
});
});
describe('valid requestData', () => {
beforeEach(() => {
shallowRender(requestData);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('loads Form with requestData url', () => {
const { container } = renderForm(requestData);
const creditForm = container.querySelector('form');
expect(creditForm).toBeInTheDocument();
expect(creditForm).toHaveAttribute('action', requestData.url);
expect(el.find('Form').props().action).toEqual(requestData.url);
});
it('loads a textarea form control for each requestData parameter', () => {
const { container } = renderForm(requestData);
const controls = container.querySelectorAll('textarea');
expect(controls.length).toEqual(Object.keys(requestData.parameters).length);
expect(controls[0]).toHaveAttribute('name', paramKeys.key1);
expect(controls[0]).toHaveValue(requestData.parameters.key1);
expect(controls[1]).toHaveAttribute('name', paramKeys.key2);
expect(controls[1]).toHaveValue(requestData.parameters.key2);
expect(controls[2]).toHaveAttribute('name', paramKeys.key3);
expect(controls[2]).toHaveValue(requestData.parameters.key3);
const controls = el.find('FormControl');
expect(controls.at(0).props().name).toEqual(paramKeys.key1);
expect(controls.at(0).props().value).toEqual(requestData.parameters.key1);
expect(controls.at(1).props().name).toEqual(paramKeys.key2);
expect(controls.at(1).props().value).toEqual(requestData.parameters.key2);
expect(controls.at(2).props().name).toEqual(paramKeys.key3);
expect(controls.at(2).props().value).toEqual(requestData.parameters.key3);
});
});
});

View File

@@ -4,6 +4,9 @@ import { render } from '@testing-library/react';
import useCreditRequestFormData from './hooks';
import CreditRequestForm from '.';
jest.unmock('@edx/paragon');
jest.unmock('react');
jest.mock('./hooks', () => ({
__esModule: true,
default: jest.fn(),

View File

@@ -3,7 +3,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { reduxHooks } from 'hooks';
import { Hyperlink } from '@openedx/paragon';
import { Hyperlink } from '@edx/paragon';
export const ProviderLink = ({ cardId }) => {
const credit = reduxHooks.useCardCreditData(cardId);

View File

@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { shallow } from 'enzyme';
import { reduxHooks } from 'hooks';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import ProviderLink from './ProviderLink';
@@ -15,30 +16,27 @@ const credit = {
providerStatusUrl: 'test-credit-provider-status-url',
providerName: 'test-credit-provider-name',
};
const renderProviderLink = () => render(
<IntlProvider locale="en"><ProviderLink cardId={cardId} /></IntlProvider>,
);
let el;
describe('ProviderLink component', () => {
beforeEach(() => {
jest.clearAllMocks();
reduxHooks.useCardCreditData.mockReturnValue(credit);
renderProviderLink();
el = shallow(<ProviderLink cardId={cardId} />);
});
describe('hooks', () => {
describe('behavior', () => {
it('initializes credit hook with cardId', () => {
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('passes credit.providerStatusUrl to the hyperlink href', () => {
const providerLink = screen.getByRole('link', { href: credit.providerStatusUrl });
expect(providerLink).toBeInTheDocument();
expect(el.find('Hyperlink').props().href).toEqual(credit.providerStatusUrl);
});
it('passes providerName for the link message', () => {
const providerLink = screen.getByRole('link', { href: credit.providerStatusUrl });
expect(providerLink).toHaveTextContent(credit.providerName);
expect(el.find('Hyperlink').text()).toEqual(credit.providerName);
});
});
});

View File

@@ -0,0 +1,57 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CreditContent component render with action snapshot 1`] = `
<Fragment>
<div
className="message-copy credit-msg"
>
test-message
</div>
<ActionRow
className="mt-4"
>
<Button
as="a"
className="border-gray-400"
disabled={false}
href="test-action-href"
onClick={[MockFunction test-action-onClick]}
rel="noopener"
target="_blank"
variant="outline-primary"
>
test-action-message
</Button>
</ActionRow>
<CreditRequestForm
requestData={
Object {
"parameters": Object {
"key1": "val1",
},
"url": "test-request-data-url",
}
}
/>
</Fragment>
`;
exports[`CreditContent component render without action snapshot 1`] = `
<Fragment>
<div
className="message-copy credit-msg"
>
test-message
</div>
<CreditRequestForm
requestData={
Object {
"parameters": Object {
"key1": "val1",
},
"url": "test-request-data-url",
}
}
/>
</Fragment>
`;

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ProviderLink component render snapshot 1`] = `
<Hyperlink
href="test-credit-provider-status-url"
rel="noopener"
target="_blank"
>
test-credit-provider-name
</Hyperlink>
`;

View File

@@ -1,6 +1,6 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
import { StrictDict } from 'utils';
const messages = defineMessages({
export const messages = StrictDict({
approved: {
id: 'learner-dash.courseCard.banners.credit.approved',
description: '',

View File

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

View File

@@ -1,11 +1,10 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import React from 'react';
import { shallow } from 'enzyme';
import { reduxHooks } from 'hooks';
import EntitlementBanner from './EntitlementBanner';
import messages from './messages';
jest.mock('components/Banner', () => 'Banner');
jest.mock('hooks', () => ({
utilHooks: {
useFormatDate: () => date => date,
@@ -19,7 +18,9 @@ jest.mock('hooks', () => ({
},
}));
const cardId = 'test-card-id';
const cardId = 'my-test-course-number';
let el;
const entitlementData = {
isEntitlement: true,
@@ -30,43 +31,33 @@ const entitlementData = {
};
const platformData = { supportEmail: 'test-support-email' };
const renderComponent = (overrides = {}) => {
const render = (overrides = {}) => {
const { entitlement = {} } = overrides;
reduxHooks.useCardEntitlementData.mockReturnValueOnce({ ...entitlementData, ...entitlement });
reduxHooks.usePlatformSettingsData.mockReturnValueOnce(platformData);
return render(<IntlProvider locale="en"><EntitlementBanner cardId={cardId} /></IntlProvider>);
el = shallow(<EntitlementBanner cardId={cardId} />);
};
describe('EntitlementBanner', () => {
it('initializes data with course number from entitlement', () => {
renderComponent();
test('initializes data with course number from entitlement', () => {
render();
expect(reduxHooks.useCardEntitlementData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useUpdateSelectSessionModalCallback).toHaveBeenCalledWith(cardId);
});
it('no display if not an entitlement', () => {
renderComponent({ entitlement: { isEntitlement: false } });
const banner = screen.queryByRole('alert');
expect(banner).toBeNull();
test('no display if not an entitlement', () => {
render({ entitlement: { isEntitlement: false } });
expect(el.isEmptyRender()).toEqual(true);
});
it('renders when no sessions available', () => {
renderComponent({ entitlement: { isFulfilled: false, hasSessions: false } });
const banner = screen.getByRole('alert');
expect(banner).toBeInTheDocument();
expect(banner).toHaveClass('alert-warning');
expect(banner.innerHTML).toContain(platformData.supportEmail);
test('snapshot: no sessions available', () => {
render({ entitlement: { isFulfilled: false, hasSessions: false } });
expect(el).toMatchSnapshot();
});
it('renders when expiration warning', () => {
renderComponent({ entitlement: { showExpirationWarning: true } });
const banner = screen.getByRole('alert');
expect(banner).toBeInTheDocument();
expect(banner).toHaveClass('alert-info');
const button = screen.getByRole('button', { name: formatMessage(messages.selectSession) });
expect(button).toBeInTheDocument();
test('snapshot: expiration warning', () => {
render({ entitlement: { showExpirationWarning: true } });
expect(el).toMatchSnapshot();
});
it('renders expired banner', () => {
renderComponent({ entitlement: { isExpired: true } });
const banner = screen.getByRole('alert');
expect(banner).toBeInTheDocument();
expect(banner.innerHTML).toContain(formatMessage(messages.entitlementExpired));
test('no display if sessions available and not displaying warning', () => {
render();
expect(el.isEmptyRender()).toEqual(true);
});
});

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { shallow } from 'enzyme';
import { ProgramsList } from './ProgramsList';
@@ -9,23 +9,15 @@ describe('ProgramsList', () => {
title: 'Example Program 1',
},
{
programUrl: 'http://example2.com',
programUrl: 'http://example.com',
title: 'Example Program 2',
},
];
it('renders correctly', () => {
render(<ProgramsList programs={programs} />);
const list = screen.getByRole('list');
expect(list).toBeInTheDocument();
expect(list.children.length).toEqual(programs.length);
});
const wrapper = shallow(<ProgramsList programs={programs} />);
expect(wrapper).toMatchSnapshot();
it('add the links correctly', () => {
render(<ProgramsList programs={programs} />);
programs.forEach(program => {
const link = screen.getByRole('link', { name: program.title });
expect(link).toHaveAttribute('href', program.url);
});
expect(wrapper.find('li').length).toEqual(programs.length);
});
});

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