Compare commits

..

2 Commits

Author SHA1 Message Date
Muhammad Abdullah Waheed
a4e504da84 feat: added browser logging 2024-04-18 17:44:37 +05:00
Muhammad Abdullah Waheed
26f53ccfbd refactor: added datadog logging 2024-04-04 14:58:13 +05:00
442 changed files with 17535 additions and 20002 deletions

4
.env
View File

@@ -4,7 +4,7 @@
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=''
APP_ID='learning'
AI_TRANSLATIONS_URL=''
BASE_URL=''
CONTACT_URL=''
CREDENTIALS_BASE_URL=''
@@ -14,6 +14,7 @@ DISCOVERY_API_BASE_URL=''
DISCUSSIONS_MFE_BASE_URL=''
ECOMMERCE_BASE_URL=''
ENABLE_JUMPNAV='true'
ENABLE_NEW_SIDEBAR=''
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
EXAMS_BASE_URL=''
@@ -48,4 +49,3 @@ TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''

View File

@@ -4,16 +4,17 @@
NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
APP_ID='learning'
AI_TRANSLATIONS_URL='http://localhost:18760'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'
CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-college-credit-or-credit-hours-for-my-course'
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NEW_SIDEBAR=''
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL=''
@@ -50,4 +51,3 @@ SESSION_COOKIE_DOMAIN='localhost'
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_KEY=''
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''

View File

@@ -4,16 +4,17 @@
NODE_ENV='test'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
APP_ID='learning'
AI_TRANSLATIONS_URL='http://localhost:18760'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'
CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-college-credit-or-credit-hours-for-my-course'
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NEW_SIDEBAR=''
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL='http://localhost:18740'
@@ -47,4 +48,3 @@ TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''

View File

@@ -3,5 +3,3 @@ dist/
packages/
node_modules/
jest.config.js
env.config.jsx
example.env.config.jsx

View File

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

View File

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

View File

@@ -10,27 +10,14 @@ jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
node-version: ${{ env.NODE_VER }}
- run: make validate.ci
- name: Archive code coverage results
uses: actions/upload-artifact@v4
with:
name: code-coverage-report
path: coverage/*.*
coverage:
runs-on: ubuntu-latest
needs: tests
steps:
- uses: actions/checkout@v4
- name: Download code coverage results
uses: actions/download-artifact@v4
with:
name: code-coverage-report
- name: Upload coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v3
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}

1
.husky/_/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*

31
.husky/_/husky.sh Normal file
View File

@@ -0,0 +1,31 @@
#!/bin/sh
if [ -z "$husky_skip_init" ]; then
debug () {
if [ "$HUSKY_DEBUG" = "1" ]; then
echo "husky (debug) - $1"
fi
}
readonly hook_name="$(basename "$0")"
debug "starting $hook_name..."
if [ "$HUSKY" = "0" ]; then
debug "HUSKY env variable is set to 0, skipping hook"
exit 0
fi
if [ -f ~/.huskyrc ]; then
debug "sourcing ~/.huskyrc"
. ~/.huskyrc
fi
export readonly husky_skip_init=1
sh -e "$0" "$@"
exitCode="$?"
if [ $exitCode != 0 ]; then
echo "husky - $hook_name hook exited with code $exitCode (error)"
fi
exit $exitCode
fi

4
.husky/pre-commit Executable file
View File

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

2
.nvmrc
View File

@@ -1 +1 @@
20
18

View File

@@ -55,10 +55,8 @@ validate:
make validate-no-uncommitted-package-lock-changes
npm run i18n_extract
npm run lint -- --max-warnings 0
npm run types
npm run test
npm run build
npm run bundlewatch
.PHONY: validate.ci
validate.ci:

View File

@@ -1,109 +1,77 @@
#####################
frontend-app-learning
#####################
|codecov| |license|
********
Purpose
*******
********
This is the Learning MFE (micro-frontend application), which renders all
learner-facing course pages (like the course outline, the progress page,
actual course content, etc).
Please tag **@edx/engage-squad** on any PRs or issues. Thanks.
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
:target: https://codecov.io/gh/edx/frontend-app-learning
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
:target: https://github.com/openedx/frontend-app-account/blob/master/LICENSE
***************
Getting Started
***************
Prerequisites
=============
`Tutor`_ is currently recommended as a development environment for the Learning
MFE. Most likely, it already has this MFE configured; however, you'll need to
make some changes in order to run it in development mode. You can refer
to the `relevant tutor-mfe documentation`_ for details, or follow the quick
guide below.
The `devstack`_ is currently recommended as a development environment for your
new MFE. If you start it with ``make dev.up.lms`` that should give you
everything you need as a companion to this frontend.
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Devstack: https://github.com/openedx/devstack
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
To use this application, `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it.
Cloning and Setup
=================
- Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
1. Clone your new repo:
Cloning and Startup
===================
.. code-block:: bash
.. code-block::
git clone https://github.com/openedx/frontend-app-learning.git
1. Clone your new repo:
2. Use the version of Node specified in ``.nvmrc``.
``git clone https://github.com/openedx/frontend-app-learning.git``
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an ``.nvmrc`` file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
2. Use node v18.x.
3. Stop the Tutor devstack, if it's running: ``tutor dev stop``
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
4. Next, we need to tell Tutor that we're going to be running this repo in
development mode, and it should be excluded from the ``mfe`` container that
otherwise runs every MFE. Run this:
3. Install npm dependencies:
.. code-block:: bash
``cd frontend-app-learning && npm ci``
tutor mounts add /path/to/frontend-app-learning
4. Start the dev server:
5. Start Tutor in development mode. This command will start the LMS and Studio,
and other required MFEs like ``authn`` and ``account``, but will not start
the learning MFE, which we're going to run on the host instead of in a
container managed by Tutor. Run:
.. code-block:: bash
tutor dev start lms cms mfe
Startup
=======
1. Install npm dependencies:
.. code-block:: bash
cd frontend-app-learning && npm ci
2. Start the dev server:
.. code-block:: bash
npm run dev
Then you can access the app at http://local.openedx.io:2000/learning/
Troubleshooting
---------------
If you see an "Invalid Host header" error, then you're probably using a different domain name for your devstack such as
``local.edly.io`` or ``local.overhang.io`` (not the new recommended default, ``local.openedx.io``). In that case, run
these commands to update your devstack's domain names:
.. code-block:: bash
tutor dev stop
tutor config save --set LMS_HOST=local.openedx.io --set CMS_HOST=studio.local.openedx.io
tutor dev launch -I --skip-build
tutor dev stop learning # We will run this MFE on the host
``npm start``
Local module development
=========================
To develop locally on modules that are installed into this app, you'll need to create a ``module.config.js``
file (which is git-ignored) that defines where to find your local modules, for instance:
.. code-block:: js
file (which is git-ignored) that defines where to find your local modules, for instance::
module.exports = {
/*
@@ -130,21 +98,15 @@ Deployment
The Learning MFE is similar to all the other Open edX MFEs. Read the Open
edX Developer Guide's section on
`MFE applications <https://openedx.github.io/frontend-platform/>`_.
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>`_.
`MFE applications <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html>`_.
Environment Variables
=====================
======================
This MFE is configured via environment variables supplied at build time.
All micro-frontends have a shared set of required environment variables,
as documented in the Open edX Developer Guide under
`Required Environment Variables <https://openedx.github.io/frontend-platform/>`_.
`Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`_.
The learning micro-frontend also supports the following additional variables:
@@ -165,7 +127,7 @@ SOCIAL_UTM_MILESTONE_CAMPAIGN
SUPPORT_URL_CALCULATOR_MATH
A link that explains how to use the in-course calculator. You can use the
one in the example below if you don't want to have your own branded version.
one in the example below, if you don't want to have your own branded version.
Example: https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator
@@ -178,7 +140,7 @@ SUPPORT_URL_ID_VERIFICATION
SUPPORT_URL_VERIFIED_CERTIFICATE
A link that explains what a verified certificate is. You can use the
one in the example below if you don't want to have your own branded version.
one in the example below, if you don't want to have your own branded version.
Optional.
Example: https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate
@@ -194,13 +156,13 @@ TWITTER_URL
A link to your Twitter account. The Twitter social-share link won't appear
unless this is set. Optional.
Example: https://twitter.com/openedx
Example: https://twitter.com/edXOnline
Getting Help
============
===========
If you're having trouble, we have `discussion forums`_
where you can connect with others in the community.
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
@@ -218,18 +180,17 @@ For more information about these options, see the `Getting Help`_ page.
.. _community Slack workspace: https://openedx.slack.com/
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/community/connect
.. _discussion forums: https://discuss.openedx.org
Contributing
============
Contributions are very welcome. Please read `How To Contribute`_ for details.
Contributions are very welcome. Please read `How To Contribute`_ for details.
.. _How To Contribute: https://openedx.org/r/how-to-contribute
This project is currently accepting all types of contributions, bug fixes,
security fixes, maintenance work, or new features. However, please make sure
to discuss your new feature idea with the maintainers before
to have a discussion about your new feature idea with the maintainers prior to
beginning development to maximize the chances of your change being accepted.
You can start a conversation by creating a new issue on this repo summarizing
your idea.

View File

@@ -12,8 +12,7 @@ metadata:
icon: "Web"
annotations:
openedx.org/arch-interest-groups: ""
openedx.org/release: "master"
spec:
owner: group:committers-frontend-app-learning
owner: group:2u-aurora
type: 'website'
lifecycle: 'production'

View File

@@ -1,4 +1,4 @@
import UnitTranslationPlugin from '@edx/unit-translation-selector-plugin';
import UnitTranslationPlugin from '@plugins/UnitTranslationPlugin';
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
// Load environment variables from .env file

View File

@@ -9,12 +9,13 @@ const config = createConfig('jest', {
'src/i18n',
'src/.*\\.exp\\..*',
],
// see https://github.com/axios/axios/issues/5026
moduleNameMapper: {
"^axios$": "axios/dist/axios.js",
// See https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
'@src/(.*)': '<rootDir>/src/$1',
// Explicit mapping to ensure Jest resolves the module correctly
'@edx/frontend-lib-special-exams': '<rootDir>/node_modules/@edx/frontend-lib-special-exams',
'@plugins/(.*)': '<rootDir>/plugins/$1',
},
testTimeout: 30000,
globalSetup: "./global-setup.js",
@@ -26,7 +27,7 @@ const config = createConfig('jest', {
config.reporters = [...(config.reporters || []), ["jest-console-group-reporter", {
// change this setting if need to see less details for each test
// reportType: "summary" | "details",
// reportType: "summary" | "details",
// enable: true | false,
afterEachTest: {
enable: true,

10
openedx.yaml Normal file
View File

@@ -0,0 +1,10 @@
# This file describes this Open edX repo, as described in OEP-2:
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0002-bp-repo-metadata.html#specification
oeps: {}
owner: edx/platform-core-tnl
openedx-release:
# The openedx-release key is described in OEP-10:
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
ref: master

19235
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,16 +11,13 @@
],
"scripts": {
"build": "fedx-scripts webpack",
"bundlewatch": "bundlewatch",
"i18n_extract": "fedx-scripts formatjs extract",
"lint": "fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"prepare": "husky install",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"dev": "PUBLIC_PATH=/learning/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "fedx-scripts jest --coverage --passWithNoTests",
"test:watch": "fedx-scripts jest --watch --passWithNoTests",
"types": "tsc --noEmit"
"test": "fedx-scripts jest --coverage --passWithNoTests"
},
"author": "edX",
"license": "AGPL-3.0",
@@ -32,69 +29,68 @@
"url": "https://github.com/openedx/frontend-app-learning/issues"
},
"dependencies": {
"@datadog/browser-logs": "^5.14.0",
"@datadog/browser-rum": "^5.14.0",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/browserslist-config": "1.5.0",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-lib-learning-assistant": "^2.20.0",
"@edx/frontend-lib-special-exams": "^3.5.0",
"@edx/frontend-platform": "^8.3.1",
"@edx/frontend-component-footer": "^13.0.4",
"@edx/frontend-component-header": "^5.0.2",
"@edx/frontend-lib-learning-assistant": "^2.0.0",
"@edx/frontend-lib-special-exams": "^3.0.0",
"@edx/frontend-platform": "^7.1.2",
"@edx/openedx-atlas": "^0.6.0",
"@edx/react-unit-test-utils": "^4.0.0",
"@edx/react-unit-test-utils": "^2.0.0",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "^0.1.4",
"@openedx/frontend-build": "^14.5.0",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^22.16.0",
"@openedx/frontend-plugin-framework": "^1.0.2",
"@openedx/paragon": "^22.1.1",
"@popperjs/core": "2.11.8",
"@reduxjs/toolkit": "1.9.7",
"buffer": "^6.0.3",
"classnames": "2.5.1",
"copy-webpack-plugin": "^12.0.0",
"@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.2",
"core-js": "3.22.2",
"history": "5.3.0",
"joi": "^17.11.0",
"js-cookie": "3.0.5",
"lodash": "^4.17.21",
"lodash.camelcase": "4.3.0",
"postcss-loader": "^8.1.1",
"prop-types": "15.8.1",
"query-string": "^7.1.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.15.0",
"react-router-dom": "6.15.0",
"react-share": "4.4.1",
"redux": "4.2.1",
"redux": "4.1.2",
"regenerator-runtime": "0.13.11",
"reselect": "4.1.8",
"sass": "^1.79.3",
"sass-loader": "^16.0.2",
"source-map-loader": "^5.0.0",
"truncate-html": "1.0.4"
"truncate-html": "1.0.4",
"util": "0.12.5"
},
"devDependencies": {
"@edx/browserslist-config": "1.2.0",
"@edx/reactifex": "2.2.0",
"@pact-foundation/pact": "^13.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "14.6.1",
"axios-mock-adapter": "2.1.0",
"bundlewatch": "^0.4.0",
"eslint-import-resolver-webpack": "^0.13.9",
"jest": "^29.7.0",
"jest-console-group-reporter": "^1.1.1",
"@openedx/frontend-build": "13.0.30",
"@pact-foundation/pact": "^11.0.2",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.0",
"copy-webpack-plugin": "^11.0.0",
"es-check": "6.2.1",
"eslint-import-resolver-webpack": "^0.13.8",
"husky": "7.0.4",
"jest": "^26.6.3",
"jest-console-group-reporter": "^1.0.1",
"jest-when": "^3.6.0",
"rosie": "2.1.1"
},
"bundlewatch": {
"files": [
{
"path": "dist/*.js",
"maxSize": "1450kB"
}
],
"normalizeFilenames": "^.+?(\\..+?)\\.\\w+$"
"postcss-loader": "^8.1.1",
"rosie": "2.1.1",
"sass": "^1.72.0",
"sass-loader": "^14.1.1",
"source-map-loader": "^5.0.0",
"style-loader": "^3.3.4"
}
}

17
plugins/README.md Normal file
View File

@@ -0,0 +1,17 @@
## How to develop plugin
You can define plugin in `env.config.jsx` see `example.env.config.jsx` as example.
## Current caveat
- The way for how I deal with override method is still wonky
- The redux still require middleware to ignore the plugin's action from serializing
- I am not sure how it behave with useCallback, useMemo, ...etc
- There are still open question on how to write it properly
## Current work that should consider core part and extendable for the future plugin framework
- `usePluingsCallback` is the callback supose to be some level of equality to be using `React.useCallback`. It would try to execute the function, then any plugin that try `registerOverrideMethod`. The order of the it being run isn't the determined. There are a couple things I want to add:
- I might consider testing it with `zustand` library to make sure it is portable and not rely on `redux`. I tried to do this with provider, but it seems to run into infinite loop of trigger changed.
- `registerOverrideMethod` is working like a way to register callback that behave like a middleware. It ran the default one, then pass the result of the default one to the plugin. Any plugin that register the override can update the value. Alternatively, we can override the function completely instead applying each affect. Or we can support both. But it requires a bit more thought out architecture.

View File

@@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<UnitTranslationPlugin /> render TranslationSelection when translation is enabled and language is available 1`] = `
<TranslationSelection
availableLanguages={
Array [
"en",
]
}
courseId="courseId"
id="id"
language="en"
unitId="unitId"
/>
`;

View File

@@ -0,0 +1,90 @@
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
import { stringify } from 'query-string';
export const fetchTranslationConfig = async (courseId) => {
const url = `${
getConfig().LMS_BASE_URL
}/api/translatable_xblocks/config/?course_id=${encodeURIComponent(courseId)}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return {
enabled: data.feature_enabled,
availableLanguages: data.available_translation_languages || [
{
code: 'en',
label: 'English',
},
{
code: 'es',
label: 'Spanish',
},
],
};
} catch (error) {
logError(`Translation plugin fail to fetch from ${url}`, error);
return {
enabled: false,
availableLanguages: [],
};
}
};
export async function getTranslationFeedback({
courseId,
translationLanguage,
unitId,
userId,
}) {
const params = stringify({
translation_language: translationLanguage,
course_id: encodeURIComponent(courseId),
unit_id: encodeURIComponent(unitId),
user_id: userId,
});
const fetchFeedbackUrl = `${
getConfig().AI_TRANSLATIONS_URL
}/api/v1/whole-course-translation-feedback?${params}`;
try {
const { data } = await getAuthenticatedHttpClient().get(fetchFeedbackUrl);
return camelCaseObject(data);
} catch (error) {
logError(
`Translation plugin fail to fetch from ${fetchFeedbackUrl}`,
error,
);
return {};
}
}
export async function createTranslationFeedback({
courseId,
feedbackValue,
translationLanguage,
unitId,
userId,
}) {
const createFeedbackUrl = `${
getConfig().AI_TRANSLATIONS_URL
}/api/v1/whole-course-translation-feedback/`;
try {
const { data } = await getAuthenticatedHttpClient().post(
createFeedbackUrl,
{
course_id: courseId,
feedback_value: feedbackValue,
translation_language: translationLanguage,
unit_id: unitId,
user_id: userId,
},
);
return camelCaseObject(data);
} catch (error) {
logError(
`Translation plugin fail to create feedback from ${createFeedbackUrl}`,
error,
);
return {};
}
}

View File

@@ -0,0 +1,125 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import { stringify } from 'query-string';
import {
fetchTranslationConfig,
getTranslationFeedback,
createTranslationFeedback,
} from './api';
const mockGetMethod = jest.fn();
const mockPostMethod = jest.fn();
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: () => ({
get: mockGetMethod,
post: mockPostMethod,
}),
}));
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
describe('UnitTranslation api', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('fetchTranslationConfig', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const expectedResponse = {
feature_enabled: true,
available_translation_languages: [
{
code: 'en',
label: 'English',
},
{
code: 'es',
label: 'Spanish',
},
],
};
it('should fetch translation config', async () => {
const expectedUrl = `http://localhost:18000/api/translatable_xblocks/config/?course_id=${encodeURIComponent(
courseId,
)}`;
mockGetMethod.mockResolvedValueOnce({ data: expectedResponse });
const result = await fetchTranslationConfig(courseId);
expect(result).toEqual({
enabled: true,
availableLanguages: expectedResponse.available_translation_languages,
});
expect(mockGetMethod).toHaveBeenCalledWith(expectedUrl);
});
it('should return disabled and unavailable languages on error', async () => {
mockGetMethod.mockRejectedValueOnce(new Error('error'));
const result = await fetchTranslationConfig(courseId);
expect(result).toEqual({
enabled: false,
availableLanguages: [],
});
expect(logError).toHaveBeenCalled();
});
});
describe('getTranslationFeedback', () => {
const props = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
translationLanguage: 'es',
unitId: 'unit-v1:edX+DemoX+Demo_Course+type@video+block@video',
userId: 'test_user',
};
const expectedResponse = {
feedback: 'good',
};
it('should fetch translation feedback', async () => {
const params = stringify({
translation_language: props.translationLanguage,
course_id: encodeURIComponent(props.courseId),
unit_id: encodeURIComponent(props.unitId),
user_id: props.userId,
});
const expectedUrl = `http://localhost:18760/api/v1/whole-course-translation-feedback?${params}`;
mockGetMethod.mockResolvedValueOnce({ data: expectedResponse });
const result = await getTranslationFeedback(props);
expect(result).toEqual(camelCaseObject(expectedResponse));
expect(mockGetMethod).toHaveBeenCalledWith(expectedUrl);
});
it('should return empty object on error', async () => {
mockGetMethod.mockRejectedValueOnce(new Error('error'));
const result = await getTranslationFeedback(props);
expect(result).toEqual({});
expect(logError).toHaveBeenCalled();
});
});
describe('createTranslationFeedback', () => {
const props = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
feedbackValue: 'good',
translationLanguage: 'es',
unitId: 'unit-v1:edX+DemoX+Demo_Course+type@video+block@video',
userId: 'test_user',
};
it('should create translation feedback', async () => {
const expectedUrl = 'http://localhost:18760/api/v1/whole-course-translation-feedback/';
mockPostMethod.mockResolvedValueOnce({});
await createTranslationFeedback(props);
expect(mockPostMethod).toHaveBeenCalledWith(expectedUrl, {
course_id: props.courseId,
feedback_value: props.feedbackValue,
translation_language: props.translationLanguage,
unit_id: props.unitId,
user_id: props.userId,
});
});
it('should log error on failure', async () => {
mockPostMethod.mockRejectedValueOnce(new Error('error'));
await createTranslationFeedback(props);
expect(logError).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,204 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<FeedbackWidget /> render feedback widget 1`] = `
<div
className="d-none"
>
<div
className="sequence w-100"
>
<div
className="ml-4 mr-2"
>
<ActionRow>
Rate this page translation
<Spacer />
<div>
<IconButton
alt="positive-feedback"
className="m-1"
iconAs="Icon"
id="positive-feedback-button"
onClick={[MockFunction onThumbsUpClick]}
src="ThumbUpOutline"
variant="secondary"
/>
<IconButton
alt="negative-feedback"
className="mr-2"
iconAs="Icon"
id="negative-feedback-button"
onClick={[MockFunction onThumbsDownClick]}
src="ThumbDownOffAlt"
variant="secondary"
/>
</div>
<div
className="mb-1 text-light action-row-divider"
>
|
</div>
<div>
<IconButton
alt="close-feedback"
className="ml-1 mr-2 float-right"
iconAs="Icon"
id="close-feedback-button"
onClick={[MockFunction closeFeedbackWidget]}
src="Close"
variant="secondary"
/>
</div>
</ActionRow>
</div>
</div>
</div>
`;
exports[`<FeedbackWidget /> render gratitude text 1`] = `
<div
className="d-none"
>
<div
className="sequence w-100"
>
<div
className="ml-4 mr-4"
>
<ActionRow
className="m-2 justify-content-center"
>
Thank you! Your feedback matters.
</ActionRow>
</div>
</div>
</div>
`;
exports[`<FeedbackWidget /> renders hidden by default 1`] = `
<div
className="d-none"
>
<div
className="sequence w-100"
>
<div
className="ml-4 mr-2"
>
<ActionRow>
Rate this page translation
<Spacer />
<div>
<IconButton
alt="positive-feedback"
className="m-1"
iconAs="Icon"
id="positive-feedback-button"
onClick={[MockFunction onThumbsUpClick]}
src="ThumbUpOutline"
variant="secondary"
/>
<IconButton
alt="negative-feedback"
className="mr-2"
iconAs="Icon"
id="negative-feedback-button"
onClick={[MockFunction onThumbsDownClick]}
src="ThumbDownOffAlt"
variant="secondary"
/>
</div>
<div
className="mb-1 text-light action-row-divider"
>
|
</div>
<div>
<IconButton
alt="close-feedback"
className="ml-1 mr-2 float-right"
iconAs="Icon"
id="close-feedback-button"
onClick={[MockFunction closeFeedbackWidget]}
src="Close"
variant="secondary"
/>
</div>
</ActionRow>
</div>
<div
className="ml-4 mr-4"
>
<ActionRow
className="m-2 justify-content-center"
>
Thank you! Your feedback matters.
</ActionRow>
</div>
</div>
</div>
`;
exports[`<FeedbackWidget /> renders show when elemReady is true 1`] = `
<div
className="sequence-container d-inline-flex flex-row w-100"
>
<div
className="sequence w-100"
>
<div
className="ml-4 mr-2"
>
<ActionRow>
Rate this page translation
<Spacer />
<div>
<IconButton
alt="positive-feedback"
className="m-1"
iconAs="Icon"
id="positive-feedback-button"
onClick={[MockFunction onThumbsUpClick]}
src="ThumbUpOutline"
variant="secondary"
/>
<IconButton
alt="negative-feedback"
className="mr-2"
iconAs="Icon"
id="negative-feedback-button"
onClick={[MockFunction onThumbsDownClick]}
src="ThumbDownOffAlt"
variant="secondary"
/>
</div>
<div
className="mb-1 text-light action-row-divider"
>
|
</div>
<div>
<IconButton
alt="close-feedback"
className="ml-1 mr-2 float-right"
iconAs="Icon"
id="close-feedback-button"
onClick={[MockFunction closeFeedbackWidget]}
src="Close"
variant="secondary"
/>
</div>
</ActionRow>
</div>
<div
className="ml-4 mr-4"
>
<ActionRow
className="m-2 justify-content-center"
>
Thank you! Your feedback matters.
</ActionRow>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,116 @@
import React, {
useEffect, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ActionRow, IconButton, Icon } from '@openedx/paragon';
import { Close, ThumbUpOutline, ThumbDownOffAlt } from '@openedx/paragon/icons';
import './index.scss';
import messages from './messages';
import useFeedbackWidget from './useFeedbackWidget';
const FeedbackWidget = ({
courseId,
translationLanguage,
unitId,
userId,
}) => {
const { formatMessage } = useIntl();
const ref = useRef(null);
const [elemReady, setElemReady] = useState(false);
const {
closeFeedbackWidget,
showFeedbackWidget,
showGratitudeText,
onThumbsUpClick,
onThumbsDownClick,
} = useFeedbackWidget({
courseId,
translationLanguage,
unitId,
userId,
});
useEffect(() => {
if (ref.current) {
const domNode = document.getElementById('whole-course-translation-feedback-widget');
domNode.appendChild(ref.current);
setElemReady(true);
}
}, [ref.current]);
return (
<div ref={ref} className={(elemReady) ? 'sequence-container d-inline-flex flex-row w-100' : 'd-none'}>
{(showFeedbackWidget || showGratitudeText) ? (
<div className="sequence w-100">
{
showFeedbackWidget && (
<div className="ml-4 mr-2">
<ActionRow>
{formatMessage(messages.rateTranslationText)}
<ActionRow.Spacer />
<div>
<IconButton
src={ThumbUpOutline}
iconAs={Icon}
alt="positive-feedback"
onClick={onThumbsUpClick}
variant="secondary"
className="m-1"
id="positive-feedback-button"
/>
<IconButton
src={ThumbDownOffAlt}
iconAs={Icon}
alt="negative-feedback"
onClick={onThumbsDownClick}
variant="secondary"
className="mr-2"
id="negative-feedback-button"
/>
</div>
<div className="mb-1 text-light action-row-divider">
|
</div>
<div>
<IconButton
src={Close}
iconAs={Icon}
alt="close-feedback"
onClick={closeFeedbackWidget}
variant="secondary"
className="ml-1 mr-2 float-right"
id="close-feedback-button"
/>
</div>
</ActionRow>
</div>
)
}
{
showGratitudeText && (
<div className="ml-4 mr-4">
<ActionRow className="m-2 justify-content-center">
{formatMessage(messages.gratitudeText)}
</ActionRow>
</div>
)
}
</div>
) : null}
</div>
);
};
FeedbackWidget.propTypes = {
courseId: PropTypes.string.isRequired,
translationLanguage: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
};
FeedbackWidget.defaultProps = {};
export default FeedbackWidget;

View File

@@ -0,0 +1,4 @@
.action-row-divider {
font-size: 31px;
font-weight: 100;
}

View File

@@ -0,0 +1,107 @@
import { useState } from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import FeedbackWidget from './index';
import useFeedbackWidget from './useFeedbackWidget';
jest.mock('react', () => ({
...jest.requireActual('react'),
useState: jest.fn((value) => [value, jest.fn()]),
}));
jest.mock('@openedx/paragon', () => jest.requireActual('@edx/react-unit-test-utils').mockComponents({
ActionRow: {
Spacer: 'Spacer',
},
IconButton: 'IconButton',
Icon: 'Icon',
}));
jest.mock('@openedx/paragon/icons', () => ({
Close: 'Close',
ThumbUpOutline: 'ThumbUpOutline',
ThumbDownOffAlt: 'ThumbDownOffAlt',
}));
jest.mock('./useFeedbackWidget');
jest.mock('@edx/frontend-platform/i18n', () => {
const i18n = jest.requireActual('@edx/frontend-platform/i18n');
const { formatMessage } = jest.requireActual('@edx/react-unit-test-utils');
return {
...i18n,
useIntl: jest.fn(() => ({
formatMessage,
})),
};
});
describe('<FeedbackWidget />', () => {
const props = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
translationLanguage: 'es',
unitId:
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@37b72b3915204b70acb00c55b604b563',
userId: '123',
};
const mockUseFeedbackWidget = ({ showFeedbackWidget, showGratitudeText }) => {
useFeedbackWidget.mockReturnValueOnce({
closeFeedbackWidget: jest.fn().mockName('closeFeedbackWidget'),
sendFeedback: jest.fn().mockName('sendFeedback'),
onThumbsUpClick: jest.fn().mockName('onThumbsUpClick'),
onThumbsDownClick: jest.fn().mockName('onThumbsDownClick'),
showFeedbackWidget,
showGratitudeText,
});
};
it('renders hidden by default', () => {
mockUseFeedbackWidget({
showFeedbackWidget: true,
showGratitudeText: true,
});
const wrapper = shallow(<FeedbackWidget {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
expect(wrapper.instance.findByType('div')[0].props.className).toContain(
'd-none',
);
});
it('renders show when elemReady is true', () => {
mockUseFeedbackWidget({
showFeedbackWidget: true,
showGratitudeText: true,
});
useState.mockReturnValueOnce([true, jest.fn()]);
const wrapper = shallow(<FeedbackWidget {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
expect(wrapper.instance.findByType('div')[0].props.className).not.toContain(
'd-none',
);
});
it('render empty when showFeedbackWidget and showGratitudeText are false', () => {
mockUseFeedbackWidget({
showFeedbackWidget: false,
showGratitudeText: false,
});
useState.mockReturnValueOnce([true, jest.fn()]);
const wrapper = shallow(<FeedbackWidget {...props} />);
expect(wrapper.instance.findByType('div')[0].children.length).toBe(0);
});
it('render feedback widget', () => {
mockUseFeedbackWidget({
showFeedbackWidget: true,
showGratitudeText: false,
});
const wrapper = shallow(<FeedbackWidget {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
});
it('render gratitude text', () => {
mockUseFeedbackWidget({
showFeedbackWidget: false,
showGratitudeText: true,
});
const wrapper = shallow(<FeedbackWidget {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
rateTranslationText: {
id: 'feedbackWidget.rateTranslationText',
defaultMessage: 'Rate this page translation',
description: 'Title for the feedback widget action row.',
},
gratitudeText: {
id: 'feedbackWidget.gratitudeText',
defaultMessage: 'Thank you! Your feedback matters.',
description: 'Title for secondary action row.',
},
});
export default messages;

View File

@@ -0,0 +1,82 @@
import { useCallback, useEffect, useState } from 'react';
import { createTranslationFeedback, getTranslationFeedback } from '../data/api';
const useFeedbackWidget = ({
courseId,
translationLanguage,
unitId,
userId,
}) => {
const [showFeedbackWidget, setShowFeedbackWidget] = useState(false);
const [showGratitudeText, setShowGratitudeText] = useState(false);
const closeFeedbackWidget = useCallback(() => {
setShowFeedbackWidget(false);
}, [setShowFeedbackWidget]);
const openFeedbackWidget = useCallback(() => {
setShowFeedbackWidget(true);
}, [setShowFeedbackWidget]);
useEffect(async () => {
const translationFeedback = await getTranslationFeedback({
courseId,
translationLanguage,
unitId,
userId,
});
setShowFeedbackWidget(!translationFeedback);
}, [
courseId,
translationLanguage,
unitId,
userId,
]);
const openGratitudeText = useCallback(() => {
setShowGratitudeText(true);
setTimeout(() => {
setShowGratitudeText(false);
}, 3000);
}, [setShowGratitudeText]);
const sendFeedback = useCallback(async (feedbackValue) => {
await createTranslationFeedback({
courseId,
feedbackValue,
translationLanguage,
unitId,
userId,
});
closeFeedbackWidget();
openGratitudeText();
}, [
courseId,
translationLanguage,
unitId,
userId,
closeFeedbackWidget,
openGratitudeText,
]);
const onThumbsUpClick = useCallback(() => {
sendFeedback(true);
}, [sendFeedback]);
const onThumbsDownClick = useCallback(() => {
sendFeedback(false);
}, [sendFeedback]);
return {
closeFeedbackWidget,
openFeedbackWidget,
openGratitudeText,
sendFeedback,
showFeedbackWidget,
showGratitudeText,
onThumbsUpClick,
onThumbsDownClick,
};
};
export default useFeedbackWidget;

View File

@@ -0,0 +1,163 @@
import { renderHook, act } from '@testing-library/react-hooks';
import useFeedbackWidget from './useFeedbackWidget';
import { createTranslationFeedback, getTranslationFeedback } from '../data/api';
jest.mock('../data/api', () => ({
createTranslationFeedback: jest.fn(),
getTranslationFeedback: jest.fn(),
}));
const initialProps = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
translationLanguage: 'es',
unitId: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
userId: 3,
};
const newProps = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
translationLanguage: 'fr',
unitId: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
userId: 3,
};
describe('useFeedbackWidget', () => {
beforeEach(async () => {
getTranslationFeedback.mockReturnValue('');
});
afterEach(() => {
jest.restoreAllMocks();
});
test('closeFeedbackWidget behavior', () => {
const { result, waitFor } = renderHook(() => useFeedbackWidget(initialProps));
waitFor(() => expect(result.current.showFeedbackWidget.toBe(true)));
act(() => {
result.current.closeFeedbackWidget();
});
expect(result.current.showFeedbackWidget).toBe(false);
});
test('openFeedbackWidget behavior', () => {
const { result } = renderHook(() => useFeedbackWidget(initialProps));
act(() => {
result.current.closeFeedbackWidget();
});
expect(result.current.showFeedbackWidget).toBe(false);
act(() => {
result.current.openFeedbackWidget();
});
expect(result.current.showFeedbackWidget).toBe(true);
});
test('openGratitudeText behavior', async () => {
const { result, waitFor } = renderHook(() => useFeedbackWidget(initialProps));
expect(result.current.showGratitudeText).toBe(false);
act(() => {
result.current.openGratitudeText();
});
expect(result.current.showGratitudeText).toBe(true);
// Wait for 3 seconds to hide the gratitude text
waitFor(() => {
expect(result.current.showGratitudeText).toBe(false);
}, { timeout: 3000 });
});
test('sendFeedback behavior', () => {
const { result, waitFor } = renderHook(() => useFeedbackWidget(initialProps));
const feedbackValue = true;
waitFor(() => expect(result.current.showFeedbackWidget.toBe(true)));
expect(result.current.showGratitudeText).toBe(false);
act(() => {
result.current.sendFeedback(feedbackValue);
});
waitFor(() => {
expect(result.current.showFeedbackWidget).toBe(false);
expect(result.current.showGratitudeText).toBe(true);
});
expect(createTranslationFeedback).toHaveBeenCalledWith({
courseId: initialProps.courseId,
feedbackValue,
translationLanguage: initialProps.translationLanguage,
unitId: initialProps.unitId,
userId: initialProps.userId,
});
// Wait for 3 seconds to hide the gratitude text
waitFor(() => {
expect(result.current.showGratitudeText).toBe(false);
}, { timeout: 3000 });
});
test('onThumbsUpClick behavior', () => {
const { result } = renderHook(() => useFeedbackWidget(initialProps));
act(() => {
result.current.onThumbsUpClick();
});
expect(createTranslationFeedback).toHaveBeenCalledWith({
courseId: initialProps.courseId,
feedbackValue: true,
translationLanguage: initialProps.translationLanguage,
unitId: initialProps.unitId,
userId: initialProps.userId,
});
});
test('onThumbsDownClick behavior', () => {
const { result } = renderHook(() => useFeedbackWidget(initialProps));
act(() => {
result.current.onThumbsDownClick();
});
expect(createTranslationFeedback).toHaveBeenCalledWith({
courseId: initialProps.courseId,
feedbackValue: false,
translationLanguage: initialProps.translationLanguage,
unitId: initialProps.unitId,
userId: initialProps.userId,
});
});
test('fetch feedback on initialization', () => {
const { waitFor } = renderHook(() => useFeedbackWidget(initialProps));
waitFor(() => {
expect(getTranslationFeedback).toHaveBeenCalledWith({
courseId: initialProps.courseId,
translationLanguage: initialProps.translationLanguage,
unitId: initialProps.unitId,
userId: initialProps.userId,
});
});
});
test('fetch feedback on props update', () => {
const { rerender, waitFor } = renderHook(() => useFeedbackWidget(initialProps));
waitFor(() => {
expect(getTranslationFeedback).toHaveBeenCalledWith({
courseId: initialProps.courseId,
translationLanguage: initialProps.translationLanguage,
unitId: initialProps.unitId,
userId: initialProps.userId,
});
});
rerender(newProps);
waitFor(() => {
expect(getTranslationFeedback).toHaveBeenCalledWith({
courseId: newProps.courseId,
translationLanguage: newProps.translationLanguage,
unitId: newProps.unitId,
userId: newProps.userId,
});
});
});
});

View File

@@ -0,0 +1,43 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useModel } from '@src/generic/model-store';
import TranslationSelection from './translation-selection';
import { fetchTranslationConfig } from './data/api';
const UnitTranslationPlugin = ({ id, courseId, unitId }) => {
const { language } = useModel('coursewareMeta', courseId);
const [translationConfig, setTranslationConfig] = useState({
enabled: false,
availableLanguages: [],
});
useEffect(() => {
fetchTranslationConfig(courseId).then(setTranslationConfig);
}, []);
const { enabled, availableLanguages } = translationConfig;
if (!enabled || !language || !availableLanguages.length) {
return null;
}
return (
<TranslationSelection
id={id}
courseId={courseId}
language={language}
availableLanguages={availableLanguages}
unitId={unitId}
/>
);
};
UnitTranslationPlugin.propTypes = {
id: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
};
export default UnitTranslationPlugin;

View File

@@ -0,0 +1,62 @@
import { shallow } from '@edx/react-unit-test-utils';
import { useState } from 'react';
import { useModel } from '@src/generic/model-store';
import UnitTranslationPlugin from './index';
jest.mock('@src/generic/model-store');
jest.mock('./data/api', () => ({
fetchTranslationConfig: jest.fn(),
}));
jest.mock('./translation-selection', () => 'TranslationSelection');
jest.mock('react', () => ({
...jest.requireActual('react'),
useState: jest.fn(),
}));
describe('<UnitTranslationPlugin />', () => {
const props = {
id: 'id',
courseId: 'courseId',
unitId: 'unitId',
};
const mockInitialState = ({ enabled = true, availableLanguages = ['en'] }) => {
useState.mockReturnValue([{ enabled, availableLanguages }, jest.fn()]);
};
it('render empty when translation is not enabled', () => {
useModel.mockReturnValue({ language: 'en' });
mockInitialState({ enabled: false });
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('render empty when available languages is empty', () => {
useModel.mockReturnValue({ language: 'fr' });
mockInitialState({
availableLanguages: [],
});
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('render empty when course language has not been set', () => {
useModel.mockReturnValue({ language: undefined });
mockInitialState({});
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('render TranslationSelection when translation is enabled and language is available', () => {
useModel.mockReturnValue({ language: 'en' });
mockInitialState({});
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,82 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
StandardModal,
ActionRow,
Button,
Icon,
ListBox,
ListBoxOption,
} from '@openedx/paragon';
import { Check } from '@openedx/paragon/icons';
import useTranslationModal from './useTranslationModal';
import messages from './messages';
import './TranslationModal.scss';
const TranslationModal = ({
isOpen,
close,
selectedLanguage,
setSelectedLanguage,
availableLanguages,
}) => {
const { formatMessage } = useIntl();
const { selectedIndex, setSelectedIndex, onSubmit } = useTranslationModal({
selectedLanguage,
setSelectedLanguage,
close,
availableLanguages,
});
return (
<StandardModal
title={formatMessage(messages.languageSelectionModalTitle)}
isOpen={isOpen}
onClose={close}
footerNode={(
<ActionRow>
<ActionRow.Spacer />
<Button variant="tertiary" onClick={close}>
{formatMessage(messages.cancelButtonText)}
</Button>
<Button onClick={onSubmit}>
{formatMessage(messages.submitButtonText)}
</Button>
</ActionRow>
)}
>
<ListBox className="listbox-container">
{availableLanguages.map(({ code, label }, index) => (
<ListBoxOption
className="d-flex justify-content-between"
key={code}
selectedOptionIndex={selectedIndex}
onSelect={() => setSelectedIndex(index)}
>
{label}
{selectedIndex === index && <Icon src={Check} />}
</ListBoxOption>
))}
</ListBox>
</StandardModal>
);
};
TranslationModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
selectedLanguage: PropTypes.string.isRequired,
setSelectedLanguage: PropTypes.func.isRequired,
availableLanguages: PropTypes.arrayOf(
PropTypes.shape({
code: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
}),
).isRequired,
};
export default TranslationModal;

View File

@@ -0,0 +1,7 @@
.listbox-container {
max-height: 400px;
:last-child {
margin-bottom: 5px;
}
}

View File

@@ -0,0 +1,59 @@
import { shallow } from '@edx/react-unit-test-utils';
import TranslationModal from './TranslationModal';
jest.mock('./useTranslationModal', () => ({
__esModule: true,
default: () => ({
selectedIndex: 0,
setSelectedIndex: jest.fn(),
onSubmit: jest.fn().mockName('onSubmit'),
}),
}));
jest.mock('@openedx/paragon', () => jest.requireActual('@edx/react-unit-test-utils').mockComponents({
StandardModal: 'StandardModal',
ActionRow: {
Spacer: 'Spacer',
},
Button: 'Button',
Icon: 'Icon',
ListBox: 'ListBox',
ListBoxOption: 'ListBoxOption',
}));
jest.mock('@openedx/paragon/icons', () => ({
Check: jest.fn().mockName('icons.Check'),
}));
jest.mock('@edx/frontend-platform/i18n', () => {
const i18n = jest.requireActual('@edx/frontend-platform/i18n');
const { formatMessage } = jest.requireActual('@edx/react-unit-test-utils');
return {
...i18n,
useIntl: jest.fn(() => ({
formatMessage,
})),
};
});
describe('TranslationModal', () => {
const props = {
isOpen: true,
close: jest.fn().mockName('close'),
selectedLanguage: 'en',
setSelectedLanguage: jest.fn().mockName('setSelectedLanguage'),
availableLanguages: [
{
code: 'en',
label: 'English',
},
{
code: 'es',
label: 'Spanish',
},
],
};
it('renders correctly', () => {
const wrapper = shallow(<TranslationModal {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
expect(wrapper.instance.findByType('ListBoxOption')).toHaveLength(2);
});
});

View File

@@ -0,0 +1,49 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TranslationModal renders correctly 1`] = `
<StandardModal
footerNode={
<ActionRow>
<Spacer />
<Button
onClick={[MockFunction close]}
variant="tertiary"
>
Cancel
</Button>
<Button
onClick={[MockFunction onSubmit]}
>
Submit
</Button>
</ActionRow>
}
isOpen={true}
onClose={[MockFunction close]}
title="Translate this course"
>
<ListBox
className="listbox-container"
>
<ListBoxOption
className="d-flex justify-content-between"
key="en"
onSelect={[Function]}
selectedOptionIndex={0}
>
English
<Icon
src={[MockFunction icons.Check]}
/>
</ListBoxOption>
<ListBoxOption
className="d-flex justify-content-between"
key="es"
onSelect={[Function]}
selectedOptionIndex={0}
>
Spanish
</ListBoxOption>
</ListBox>
</StandardModal>
`;

View File

@@ -0,0 +1,50 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<TranslationSelection /> renders 1`] = `
<Fragment>
<ProductTour
tours={
Array [
Object {
"abitrarily": "defined",
},
]
}
/>
<IconButton
alt="change-language"
className="mr-2 mb-2 float-right"
iconAs="Icon"
id="translation-selection-button"
onClick={[MockFunction open]}
src="Language"
variant="primary"
/>
<TranslationModal
availableLanguages={
Array [
Object {
"code": "en",
"label": "English",
},
Object {
"code": "es",
"label": "Spanish",
},
]
}
close={[MockFunction close]}
courseId="course-v1:edX+DemoX+Demo_Course"
id="plugin-test-id"
isOpen={false}
selectedLanguage="en"
setSelectedLanguage={[MockFunction setSelectedLanguage]}
/>
<FeedbackWidget
courseId="course-v1:edX+DemoX+Demo_Course"
translationLanguage="en"
unitId="unit-test-id"
userId="123"
/>
</Fragment>
`;

View File

@@ -0,0 +1,100 @@
import React, { useContext, useEffect } from 'react';
import PropTypes from 'prop-types';
import { AppContext } from '@edx/frontend-platform/react';
import { IconButton, Icon, ProductTour } from '@openedx/paragon';
import { Language } from '@openedx/paragon/icons';
import { useDispatch } from 'react-redux';
import { stringifyUrl } from 'query-string';
import { registerOverrideMethod } from '@src/generic/plugin-store';
import TranslationModal from './TranslationModal';
import useTranslationTour from './useTranslationTour';
import useSelectLanguage from './useSelectLanguage';
import FeedbackWidget from '../feedback-widget';
const TranslationSelection = ({
id, courseId, language, availableLanguages, unitId,
}) => {
const {
authenticatedUser: { userId },
} = useContext(AppContext);
const dispatch = useDispatch();
const {
translationTour, isOpen, open, close,
} = useTranslationTour();
const { selectedLanguage, setSelectedLanguage } = useSelectLanguage({
courseId,
language,
});
useEffect(() => {
dispatch(
registerOverrideMethod({
pluginName: id,
methodName: 'getIFrameUrl',
method: (iframeUrl) => {
const finalUrl = stringifyUrl({
url: iframeUrl,
query: {
...(language
&& selectedLanguage
&& language !== selectedLanguage && {
src_lang: language,
dest_lang: selectedLanguage,
}),
},
});
return finalUrl;
},
}),
);
}, [language, selectedLanguage]);
return (
<>
<ProductTour tours={[translationTour]} />
<IconButton
src={Language}
iconAs={Icon}
alt="change-language"
onClick={open}
variant="primary"
className="mr-2 mb-2 float-right"
id="translation-selection-button"
/>
<TranslationModal
isOpen={isOpen}
close={close}
courseId={courseId}
selectedLanguage={selectedLanguage}
setSelectedLanguage={setSelectedLanguage}
availableLanguages={availableLanguages}
id={id}
/>
<FeedbackWidget
courseId={courseId}
translationLanguage={selectedLanguage}
unitId={unitId}
userId={userId}
/>
</>
);
};
TranslationSelection.propTypes = {
id: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
language: PropTypes.string.isRequired,
availableLanguages: PropTypes.arrayOf(PropTypes.shape({
code: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
})).isRequired,
};
TranslationSelection.defaultProps = {};
export default TranslationSelection;

View File

@@ -0,0 +1,63 @@
import { shallow } from '@edx/react-unit-test-utils';
import TranslationSelection from './index';
jest.mock('react', () => ({
...jest.requireActual('react'),
useContext: jest.fn().mockName('useContext').mockReturnValue({
authenticatedUser: {
userId: '123',
},
}),
}));
jest.mock('@openedx/paragon', () => ({
IconButton: 'IconButton',
Icon: 'Icon',
ProductTour: 'ProductTour',
}));
jest.mock('@openedx/paragon/icons', () => ({
Language: 'Language',
}));
jest.mock('./useTranslationTour', () => () => ({
translationTour: {
abitrarily: 'defined',
},
isOpen: false,
open: jest.fn().mockName('open'),
close: jest.fn().mockName('close'),
}));
jest.mock('react-redux', () => ({
useDispatch: jest.fn().mockName('useDispatch'),
}));
jest.mock('@src/generic/plugin-store', () => ({
registerOverrideMethod: jest.fn().mockName('registerOverrideMethod'),
}));
jest.mock('./TranslationModal', () => 'TranslationModal');
jest.mock('./useSelectLanguage', () => () => ({
selectedLanguage: 'en',
setSelectedLanguage: jest.fn().mockName('setSelectedLanguage'),
}));
jest.mock('../feedback-widget', () => 'FeedbackWidget');
describe('<TranslationSelection />', () => {
const props = {
id: 'plugin-test-id',
courseId: 'course-v1:edX+DemoX+Demo_Course',
language: 'en',
availableLanguages: [
{
code: 'en',
label: 'English',
},
{
code: 'es',
label: 'Spanish',
},
],
unitId: 'unit-test-id',
};
it('renders', () => {
const wrapper = shallow(<TranslationSelection {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,41 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
translationTourModalTitle: {
id: 'translationSelection.translationTourModalTitle',
defaultMessage: 'This is a standard modal dialog',
description: 'Title for the translation modal.',
},
translationTourModalBody: {
id: 'translationSelection.translationTourModalBody',
defaultMessage: 'Now you can easily translate course content.',
description: 'Body for the translation modal.',
},
tryItButtonText: {
id: 'translationSelection.tryItButtonText',
defaultMessage: 'Try it',
description: 'Button text for the translation modal.',
},
dismissButtonText: {
id: 'translationSelection.dismissButtonText',
defaultMessage: 'Dismiss',
description: 'Button text for the translation modal.',
},
languageSelectionModalTitle: {
id: 'translationSelection.languageSelectionModalTitle',
defaultMessage: 'Translate this course',
description: 'Title for the translation modal.',
},
cancelButtonText: {
id: 'translationSelection.cancelButtonText',
defaultMessage: 'Cancel',
description: 'Button text for the translation modal.',
},
submitButtonText: {
id: 'translationSelection.submitButtonText',
defaultMessage: 'Submit',
description: 'Button text for the translation modal.',
},
});
export default messages;

View File

@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import {
getLocalStorage,
setLocalStorage,
} from '@src/data/localStorage';
export const selectedLanguageKey = 'selectedLanguages';
export const stateKeys = StrictDict({
selectedLanguage: 'selectedLanguage',
});
const useSelectLanguage = ({ courseId, language }) => {
const selectedLanguageItem = getLocalStorage(selectedLanguageKey) || {};
const [selectedLanguage, updateSelectedLanguage] = useKeyedState(
stateKeys.selectedLanguage,
selectedLanguageItem[courseId] || language,
);
const setSelectedLanguage = useCallback((newSelectedLanguage) => {
setLocalStorage(selectedLanguageKey, {
...selectedLanguageItem,
[courseId]: newSelectedLanguage,
});
updateSelectedLanguage(newSelectedLanguage);
});
return {
selectedLanguage,
setSelectedLanguage,
};
};
export default useSelectLanguage;

View File

@@ -0,0 +1,63 @@
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import {
getLocalStorage,
setLocalStorage,
} from '@src/data/localStorage';
import useSelectLanguage, {
stateKeys,
selectedLanguageKey,
} from './useSelectLanguage';
const state = mockUseKeyedState(stateKeys);
jest.mock('react', () => ({
...jest.requireActual('react'),
useCallback: jest.fn((cb, prereqs) => (...args) => [
cb(...args),
{ cb, prereqs },
]),
}));
jest.mock('@src/data/localStorage', () => ({
getLocalStorage: jest.fn(),
setLocalStorage: jest.fn(),
}));
describe('useSelectLanguage', () => {
const props = {
courseId: 'test-course-id',
language: 'en',
};
const languages = [
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Spanish' },
];
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
afterEach(() => {
state.resetVals();
});
languages.forEach(({ code, label }) => {
it(`initializes selectedLanguage to the selected language (${label})`, () => {
getLocalStorage.mockReturnValueOnce({ [props.courseId]: code });
const { selectedLanguage } = useSelectLanguage(props);
state.expectInitializedWith(stateKeys.selectedLanguage, code);
expect(selectedLanguage).toBe(code);
});
});
test('setSelectedLanguage behavior', () => {
const { setSelectedLanguage } = useSelectLanguage(props);
setSelectedLanguage('es');
state.expectSetStateCalledWith(stateKeys.selectedLanguage, 'es');
expect(setLocalStorage).toHaveBeenCalledWith(selectedLanguageKey, {
[props.courseId]: 'es',
});
});
});

View File

@@ -0,0 +1,29 @@
import { useCallback } from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
export const stateKeys = StrictDict({
selectedIndex: 'selectedIndex',
});
const useTranslationModal = ({
selectedLanguage, setSelectedLanguage, close, availableLanguages,
}) => {
const [selectedIndex, setSelectedIndex] = useKeyedState(
stateKeys.selectedIndex,
availableLanguages.findIndex((lang) => lang.code === selectedLanguage),
);
const onSubmit = useCallback(() => {
const newSelectedLanguage = availableLanguages[selectedIndex].code;
setSelectedLanguage(newSelectedLanguage);
close();
}, [selectedIndex]);
return {
selectedIndex,
setSelectedIndex,
onSubmit,
};
};
export default useTranslationModal;

View File

@@ -0,0 +1,49 @@
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import useTranslationModal, { stateKeys } from './useTranslationModal';
const state = mockUseKeyedState(stateKeys);
jest.mock('react', () => ({
...jest.requireActual('react'),
useCallback: jest.fn((cb, prereqs) => (...args) => ([
cb(...args), { cb, prereqs },
])),
}));
describe('useTranslationModal', () => {
const props = {
selectedLanguage: 'en',
setSelectedLanguage: jest.fn(),
close: jest.fn(),
availableLanguages: [
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Spanish' },
],
};
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
afterEach(() => {
state.resetVals();
});
it('initializes selectedIndex to the index of the selected language', () => {
const { selectedIndex } = useTranslationModal(props);
state.expectInitializedWith(stateKeys.selectedIndex, 0);
expect(selectedIndex).toBe(0);
});
it('onSubmit updates the selected language and closes the modal', () => {
const { onSubmit } = useTranslationModal({
...props,
selectedLanguage: 'es',
});
onSubmit();
expect(props.setSelectedLanguage).toHaveBeenCalledWith('es');
expect(props.close).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,62 @@
import { useCallback } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useToggle } from '@openedx/paragon';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import messages from './messages';
const hasSeenTranslationTourKey = 'hasSeenTranslationTour';
export const stateKeys = StrictDict({
showTranslationTour: 'showTranslationTour',
});
const useTranslationTour = () => {
const { formatMessage } = useIntl();
const [isTourEnabled, setIsTourEnabled] = useKeyedState(
stateKeys.showTranslationTour,
global.localStorage.getItem(hasSeenTranslationTourKey) !== 'true',
);
const [isOpen, open, close] = useToggle(false);
const endTour = useCallback(() => {
global.localStorage.setItem(hasSeenTranslationTourKey, 'true');
setIsTourEnabled(false);
}, [isTourEnabled, setIsTourEnabled]);
const tryIt = useCallback(() => {
endTour();
open();
}, [endTour, open]);
const translationTour = isTourEnabled
? {
tourId: 'translation',
enabled: isTourEnabled,
onDismiss: endTour,
onEnd: tryIt,
checkpoints: [
{
title: formatMessage(messages.translationTourModalTitle),
body: formatMessage(messages.translationTourModalBody),
placement: 'bottom',
target: '#translation-selection-button',
showDismissButton: true,
endButtonText: formatMessage(messages.tryItButtonText),
dismissButtonText: formatMessage(messages.dismissButtonText),
},
],
}
: {};
return {
translationTour,
isOpen,
open,
close,
};
};
export default useTranslationTour;

View File

@@ -0,0 +1,95 @@
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import { useToggle } from '@openedx/paragon';
import useTranslationTour, { stateKeys } from './useTranslationTour';
jest.mock('react', () => ({
...jest.requireActual('react'),
useCallback: jest.fn((cb, prereqs) => () => {
cb();
return { useCallback: { cb, prereqs } };
}),
}));
jest.mock('@openedx/paragon', () => ({
useToggle: jest.fn(),
}));
jest.mock('@edx/frontend-platform/i18n', () => {
const i18n = jest.requireActual('@edx/frontend-platform/i18n');
const { formatMessage } = jest.requireActual('@edx/react-unit-test-utils');
// this provide consistent for the test on different platform/timezone
const formatDate = jest.fn(date => new Date(date).toISOString()).mockName('useIntl.formatDate');
return {
...i18n,
useIntl: jest.fn(() => ({
formatMessage,
formatDate,
})),
defineMessages: m => m,
FormattedMessage: () => 'FormattedMessage',
};
});
jest.mock('@src/data/localStorage', () => ({
getLocalStorage: jest.fn(),
setLocalStorage: jest.fn(),
}));
const state = mockUseKeyedState(stateKeys);
describe('useTranslationSelection', () => {
const mockLocalStroage = {
getItem: jest.fn(),
setItem: jest.fn(),
};
const toggleOpen = jest.fn();
const toggleClose = jest.fn();
useToggle.mockReturnValue([false, toggleOpen, toggleClose]);
beforeEach(() => {
jest.clearAllMocks();
state.mock();
window.localStorage = mockLocalStroage;
});
afterEach(() => {
state.resetVals();
delete window.localStorage;
});
it('do not have translation tour if user already seen it', () => {
mockLocalStroage.getItem.mockReturnValueOnce('not seen');
const { translationTour } = useTranslationTour();
expect(translationTour.enabled).toBe(true);
});
it('show translation tour if user has not seen it', () => {
mockLocalStroage.getItem.mockReturnValueOnce('true');
const { translationTour } = useTranslationTour();
expect(translationTour).toMatchObject({});
});
test('open and close as pass from useToggle', () => {
const { isOpen, open, close } = useTranslationTour();
expect(isOpen).toBe(false);
expect(toggleOpen).toBe(open);
expect(toggleClose).toBe(close);
});
test('end tour on dismiss button click', () => {
mockLocalStroage.getItem.mockReturnValueOnce('not seen');
const { translationTour } = useTranslationTour();
translationTour.onDismiss();
expect(mockLocalStroage.setItem).toHaveBeenCalledWith(
'hasSeenTranslationTour',
'true',
);
state.expectSetStateCalledWith(stateKeys.showTranslationTour, false);
});
test('end tour and open modal on try it button click', () => {
mockLocalStroage.getItem.mockReturnValueOnce('not seen');
const { translationTour } = useTranslationTour();
translationTour.onEnd();
state.expectSetStateCalledWith(stateKeys.showTranslationTour, false);
expect(toggleOpen).toHaveBeenCalled();
});
});

View File

@@ -9,9 +9,6 @@
<% if (htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID) { %>
<script src="https://www.edx.org/optimizelyjs/<%= htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID %>.js"></script>
<% } %>
<% if (htmlWebpackPlugin.options.META_TAG_ROBOTS_CONTENT_ATTR) { %>
<meta name="robots" content="<%= htmlWebpackPlugin.options.META_TAG_ROBOTS_CONTENT_ATTR %>">
<% } %>
</head>
<body>
<div id="root"></div>

View File

@@ -1,224 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app registry subscribe: APP_INIT_ERROR. snapshot: displays an ErrorPage to root element 1`] = `
<React Strict Mode>
<ErrorPage
message="test-error-message"
/>
</React Strict Mode>
`;
exports[`app registry subscribe: APP_READY. links App to root element 1`] = `
<React Strict Mode>
<AppProvider
store={
{
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
>
<link
href="favicon-url"
rel="shortcut icon"
type="image/x-icon"
/>
</HelmetWrapper>
<PathFixesProvider>
<NoticesProvider>
<UserMessagesProvider>
<Routes>
<Route
element={
<PageWrap>
<Page Not Found />
</PageWrap>
}
path="*"
/>
<Route
element={
<PageWrap>
<Goal Unsubscribe />
</PageWrap>
}
path="/goal-unsubscribe/:token"
/>
<Route
element={
<PageWrap>
<Courseware Redirect Landing Page />
</PageWrap>
}
path="/redirect/*"
/>
<Route
element={
<PageWrap>
<Preferences Unsubscribe />
</PageWrap>
}
path="/preferences-unsubscribe/:userToken/:updatePatch"
/>
<Route
element={
<DecodePageRoute>
<Course Access Error Page />
</DecodePageRoute>
}
path="/course/:courseId/access-denied"
/>
<Route
element={
<DecodePageRoute>
<Tab Container
fetch={[Function]}
slice="courseHome"
tab="outline"
>
<Outline Tab />
</Tab Container>
</DecodePageRoute>
}
path="/course/:courseId/home"
/>
<Route
element={
<DecodePageRoute>
<Tab Container
fetch={[Function]}
slice="courseHome"
tab="lti_live"
>
<Live Tab />
</Tab Container>
</DecodePageRoute>
}
path="/course/:courseId/live"
/>
<Route
element={
<DecodePageRoute>
<Tab Container
fetch={[Function]}
slice="courseHome"
tab="dates"
>
<Dates Tab />
</Tab Container>
</DecodePageRoute>
}
path="/course/:courseId/dates"
/>
<Route
element={
<DecodePageRoute>
<Tab Container
fetch={[Function]}
slice="courseHome"
tab="discussion"
>
<Discussion Tab />
</Tab Container>
</DecodePageRoute>
}
path="/course/:courseId/discussion/:path/*"
/>
<Route
element={
<DecodePageRoute>
<Tab Container
fetch={[Function]}
isProgressTab={true}
slice="courseHome"
tab="progress"
>
<Progress Tab />
</Tab Container>
</DecodePageRoute>
}
path="/course/:courseId/progress/:targetUserId/"
/>
<Route
element={
<DecodePageRoute>
<Tab Container
fetch={[Function]}
isProgressTab={true}
slice="courseHome"
tab="progress"
>
<Progress Tab />
</Tab Container>
</DecodePageRoute>
}
path="/course/:courseId/progress"
/>
<Route
element={
<DecodePageRoute>
<Tab Container
fetch={[Function]}
slice="courseware"
tab="courseware"
>
<Course Exit />
</Tab Container>
</DecodePageRoute>
}
path="/course/:courseId/course-end"
/>
<Route
element={
<DecodePageRoute>
<Courseware Container />
</DecodePageRoute>
}
path="/course/:courseId/:sequenceId/:unitId"
/>
<Route
element={
<DecodePageRoute>
<Courseware Container />
</DecodePageRoute>
}
path="/course/:courseId/:sequenceId"
/>
<Route
element={
<DecodePageRoute>
<Courseware Container />
</DecodePageRoute>
}
path="/course/:courseId"
/>
<Route
element={
<DecodePageRoute>
<Courseware Container />
</DecodePageRoute>
}
path="/preview/course/:courseId/:sequenceId/:unitId"
/>
<Route
element={
<DecodePageRoute>
<Courseware Container />
</DecodePageRoute>
}
path="/preview/course/:courseId/:sequenceId"
/>
</Routes>
</UserMessagesProvider>
</NoticesProvider>
</PathFixesProvider>
</AppProvider>
</React Strict Mode>
`;

View File

@@ -1,13 +1,14 @@
import PropTypes from 'prop-types';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { FormattedMessage, FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
import {
FormattedMessage, FormattedDate, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@openedx/paragon';
import { Info } from '@openedx/paragon/icons';
import messages from './messages';
const AccessExpirationAlert = ({ payload }) => {
const intl = useIntl();
const AccessExpirationAlert = ({ intl, payload }) => {
const {
accessExpiration,
courseId,
@@ -118,6 +119,7 @@ const AccessExpirationAlert = ({ payload }) => {
};
AccessExpirationAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
accessExpiration: PropTypes.shape({
expirationDate: PropTypes.string.isRequired,
@@ -132,4 +134,4 @@ AccessExpirationAlert.propTypes = {
}).isRequired,
};
export default AccessExpirationAlert;
export default injectIntl(AccessExpirationAlert);

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Alert, Hyperlink } from '@openedx/paragon';
import { WarningFilled } from '@openedx/paragon/icons';
@@ -7,8 +7,7 @@ import { WarningFilled } from '@openedx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import genericMessages from './messages';
const ActiveEnterpriseAlert = ({ payload }) => {
const intl = useIntl();
const ActiveEnterpriseAlert = ({ intl, payload }) => {
const { text, courseId } = payload;
const changeActiveEnterprise = (
<Hyperlink
@@ -39,10 +38,11 @@ const ActiveEnterpriseAlert = ({ payload }) => {
};
ActiveEnterpriseAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
text: PropTypes.string,
courseId: PropTypes.string,
}).isRequired,
};
export default ActiveEnterpriseAlert;
export default injectIntl(ActiveEnterpriseAlert);

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Alert, Button } from '@openedx/paragon';
import { Info, WarningFilled } from '@openedx/paragon/icons';
@@ -11,8 +11,7 @@ import { useModel } from '../../generic/model-store';
import messages from './messages';
import useEnrollClickHandler from './clickHook';
const EnrollmentAlert = ({ payload }) => {
const intl = useIntl();
const EnrollmentAlert = ({ intl, payload }) => {
const {
canEnroll,
courseId,
@@ -59,6 +58,7 @@ const EnrollmentAlert = ({ payload }) => {
};
EnrollmentAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
canEnroll: PropTypes.bool,
courseId: PropTypes.string,
@@ -67,4 +67,4 @@ EnrollmentAlert.propTypes = {
}).isRequired,
};
export default EnrollmentAlert;
export default injectIntl(EnrollmentAlert);

View File

@@ -9,12 +9,13 @@ import {
Icon,
} from '@openedx/paragon';
import { Check, ArrowForward } from '@openedx/paragon/icons';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { sendActivationEmail } from '../../courseware/data';
import messages from './messages';
const AccountActivationAlert = () => {
const intl = useIntl();
const AccountActivationAlert = ({
intl,
}) => {
const [showModal, setShowModal] = useState(false);
const [showSpinner, setShowSpinner] = useState(false);
const [showCheck, setShowCheck] = useState(false);
@@ -124,4 +125,8 @@ const AccountActivationAlert = () => {
);
};
export default AccountActivationAlert;
AccountActivationAlert.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(AccountActivationAlert);

View File

@@ -1,14 +1,13 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { Alert, Hyperlink } from '@openedx/paragon';
import { WarningFilled } from '@openedx/paragon/icons';
import genericMessages from '../../generic/messages';
const LogistrationAlert = () => {
const intl = useIntl();
const LogistrationAlert = ({ intl }) => {
const signIn = (
<Hyperlink
style={{ textDecoration: 'underline' }}
@@ -44,4 +43,8 @@ const LogistrationAlert = () => {
);
};
export default LogistrationAlert;
LogistrationAlert.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(LogistrationAlert);

35
src/constants.js Normal file
View File

@@ -0,0 +1,35 @@
export const DECODE_ROUTES = {
ACCESS_DENIED: '/course/:courseId/access-denied',
HOME: '/course/:courseId/home',
LIVE: '/course/:courseId/live',
DATES: '/course/:courseId/dates',
DISCUSSION: '/course/:courseId/discussion/:path/*',
PROGRESS: [
'/course/:courseId/progress/:targetUserId/',
'/course/:courseId/progress',
],
COURSE_END: '/course/:courseId/course-end',
COURSEWARE: [
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
],
REDIRECT_HOME: 'home/:courseId',
REDIRECT_SURVEY: 'survey/:courseId',
};
export const ROUTES = {
UNSUBSCRIBE: '/goal-unsubscribe/:token',
REDIRECT: '/redirect/*',
DASHBOARD: 'dashboard',
ENTERPRISE_LEARNER_DASHBOARD: 'enterprise-learner-dashboard',
CONSENT: 'consent',
};
export const REDIRECT_MODES = {
DASHBOARD_REDIRECT: 'dashboard-redirect',
ENTERPRISE_LEARNER_DASHBOARD_REDIRECT: 'enterprise-learner-dashboard-redirect',
CONSENT_REDIRECT: 'consent-redirect',
HOME_REDIRECT: 'home-redirect',
SURVEY_REDIRECT: 'survey-redirect',
};

View File

@@ -1,74 +0,0 @@
export const DECODE_ROUTES = {
ACCESS_DENIED: '/course/:courseId/access-denied',
HOME: '/course/:courseId/home',
LIVE: '/course/:courseId/live',
DATES: '/course/:courseId/dates',
DISCUSSION: '/course/:courseId/discussion/:path/*',
PROGRESS: [
'/course/:courseId/progress/:targetUserId/',
'/course/:courseId/progress',
],
COURSE_END: '/course/:courseId/course-end',
COURSEWARE: [
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
'/preview/course/:courseId/:sequenceId/:unitId',
'/preview/course/:courseId/:sequenceId',
],
REDIRECT_HOME: 'home/:courseId',
REDIRECT_SURVEY: 'survey/:courseId',
} as const satisfies Readonly<{ [k: string]: string | readonly string[] }>;
export const ROUTES = {
UNSUBSCRIBE: '/goal-unsubscribe/:token',
PREFERENCES_UNSUBSCRIBE: '/preferences-unsubscribe/:userToken/:updatePatch',
REDIRECT: '/redirect/*',
DASHBOARD: 'dashboard',
ENTERPRISE_LEARNER_DASHBOARD: 'enterprise-learner-dashboard',
CONSENT: 'consent',
} as const satisfies Readonly<{ [k: string]: string }>;
export const REDIRECT_MODES = {
DASHBOARD_REDIRECT: 'dashboard-redirect',
ENTERPRISE_LEARNER_DASHBOARD_REDIRECT: 'enterprise-learner-dashboard-redirect',
CONSENT_REDIRECT: 'consent-redirect',
HOME_REDIRECT: 'home-redirect',
SURVEY_REDIRECT: 'survey-redirect',
} as const satisfies Readonly<{ [k: string]: string }>;
export const VERIFIED_MODES = [
'professional',
'verified',
'no-id-professional',
'credit',
'masters',
'executive-education',
'paid-executive-education',
'paid-bootcamp',
] as const satisfies readonly string[];
export const AUDIT_MODES = [
'audit',
'honor',
'unpaid-executive-education',
'unpaid-bootcamp',
] as const satisfies readonly string[];
// In sync with CourseMode.UPSELL_TO_VERIFIED_MODES
// https://github.com/openedx/edx-platform/blob/master/common/djangoapps/course_modes/models.py#L231
export const ALLOW_UPSELL_MODES = [
'audit',
'honor',
] as const satisfies readonly string[];
export const WIDGETS = {
DISCUSSIONS: 'DISCUSSIONS',
NOTIFICATIONS: 'NOTIFICATIONS',
} as const satisfies Readonly<{ [k: string]: string }>;
export const LOADING = 'loading';
export const LOADED = 'loaded';
export const FAILED = 'failed';
export const DENIED = 'denied';
export type StatusValue = typeof LOADING | typeof LOADED | typeof FAILED | typeof DENIED;

View File

@@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Tabs, Tab } from '@openedx/paragon';
import { useParams } from 'react-router';
@@ -13,8 +13,7 @@ const filterTypes = ['text', 'video', 'sequence'];
const filterOther = 'other';
const validFilters = [filterAll, ...filterTypes, filterOther];
export const CoursewareSearchResultsFilter = () => {
const intl = useIntl();
export const CoursewareSearchResultsFilter = ({ intl }) => {
const { courseId } = useParams();
const lastSearch = useModel('contentSearchResults', courseId);
const { filter: filterKeyword, setFilter } = useCoursewareSearchParams();
@@ -74,4 +73,8 @@ export const CoursewareSearchResultsFilter = () => {
);
};
export default CoursewareSearchResultsFilter;
CoursewareSearchResultsFilter.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearchResultsFilter);

View File

@@ -1,8 +1,8 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect } from 'react';
import { useParams } from 'react-router';
import { useDispatch } from 'react-redux';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Alert, Button, Icon, Spinner,
} from '@openedx/paragon';
@@ -18,8 +18,7 @@ import CoursewareSearchResultsFilterContainer from './CoursewareResultsFilter';
import { updateModel, useModel } from '../../generic/model-store';
import { searchCourseContent } from '../data/thunks';
const CoursewareSearch = ({ ...sectionProps }) => {
const { formatMessage } = useIntl();
const CoursewareSearch = ({ intl, ...sectionProps }) => {
const { courseId } = useParams();
const { query: searchKeyword, setQuery, clearSearchParams } = useCoursewareSearchParams();
const dispatch = useDispatch();
@@ -30,7 +29,6 @@ const CoursewareSearch = ({ ...sectionProps }) => {
errors,
total,
} = useModel('contentSearchResults', courseId);
const dialogRef = useRef();
useLockScroll();
@@ -46,8 +44,7 @@ const CoursewareSearch = ({ ...sectionProps }) => {
searchKeyword: '',
results: [],
errors: undefined,
loading:
false,
loading: false,
},
}));
};
@@ -69,46 +66,20 @@ const CoursewareSearch = ({ ...sectionProps }) => {
setQuery(value);
};
useEffect(() => {
handleSubmit(searchKeyword);
}, []);
const handleOnChange = (value) => {
if (value === searchKeyword) { return; }
if (!value) { clearSearch(); }
};
const close = () => {
const handleSearchCloseClick = () => {
clearSearch();
dispatch(setShowSearch(false));
};
const handlePopState = () => close();
const handleBackdropClick = function (event) {
if (event.target === dialogRef.current) {
dialogRef.current.close();
}
};
useEffect(() => {
// We need this to keep the dialog reference when unmounting.
const dialog = dialogRef.current;
// Open the dialog as a modal on render to confine focus within it.
dialogRef.current.showModal();
if (searchKeyword) {
handleSubmit(searchKeyword); // In case it's opened with a search link, we run the search.
}
const controller = new AbortController();
const { signal } = controller;
window.addEventListener('popstate', handlePopState, { signal });
dialog.addEventListener('click', handleBackdropClick, { signal });
return () => controller.abort(); // Removes event listeners.
}, []);
const handleSearchClose = () => close();
let status = 'idle';
if (loading) {
status = 'loading';
@@ -119,58 +90,59 @@ const CoursewareSearch = ({ ...sectionProps }) => {
}
return (
<dialog ref={dialogRef} className="courseware-search" style={{ '--modal-top-position': top }} data-testid="courseware-search-dialog" onClose={handleSearchClose} {...sectionProps}>
<section className="courseware-search" style={{ '--modal-top-position': top }} data-testid="courseware-search-section" {...sectionProps}>
<div className="courseware-search__close">
<Button
variant="tertiary"
className="p-1"
aria-label={intl.formatMessage(messages.searchCloseAction)}
onClick={handleSearchCloseClick}
data-testid="courseware-search-close-button"
><Icon src={Close} />
</Button>
</div>
<div className="courseware-search__outer-content">
<div className="courseware-search__content" data-testid="courseware-search-content">
<div className="courseware-search__form">
<h1 className="h2">{formatMessage(messages.searchModuleTitle)}</h1>
<CoursewareSearchForm
searchTerm={searchKeyword}
onSubmit={handleSubmit}
onChange={handleOnChange}
placeholder={formatMessage(messages.searchBarPlaceholderText)}
/>
<div className="courseware-search__close">
<Button
variant="tertiary"
className="p-1"
aria-label={formatMessage(messages.searchCloseAction)}
onClick={() => dialogRef.current.close()}
data-testid="courseware-search-close-button"
><Icon src={Close} />
</Button>
<div className="courseware-search__content">
<h1 className="h2">{intl.formatMessage(messages.searchModuleTitle)}</h1>
<CoursewareSearchForm
searchTerm={searchKeyword}
onSubmit={handleSubmit}
onChange={handleOnChange}
placeholder={intl.formatMessage(messages.searchBarPlaceholderText)}
/>
{status === 'loading' ? (
<div className="courseware-search__spinner" data-testid="courseware-search-spinner">
<Spinner animation="border" variant="light" screenReaderText={intl.formatMessage(messages.loading)} />
</div>
</div>
<div className="courseware-search__results" aria-live="polite" data-testid="courseware-search-results">
{status === 'loading' ? (
<div className="courseware-search__spinner" data-testid="courseware-search-spinner">
<Spinner animation="border" variant="light" screenReaderText={formatMessage(messages.loading)} />
</div>
) : null}
{status === 'error' && (
<Alert className="mt-4" variant="danger" data-testid="courseware-search-error">
{formatMessage(messages.searchResultsError)}
</Alert>
)}
{status === 'results' ? (
<>
{total > 0 ? (
<div
className="courseware-search__results-summary"
aria-relevant="all"
aria-atomic="true"
data-testid="courseware-search-summary"
>{formatMessage(messages.searchResultsLabel, { total, keyword: lastSearchKeyword })}
</div>
) : null}
<CoursewareSearchResultsFilterContainer />
</>
) : null}
</div>
) : null}
{status === 'error' && (
<Alert className="mt-4" variant="danger" data-testid="courseware-search-error">
{intl.formatMessage(messages.searchResultsError)}
</Alert>
)}
{status === 'results' ? (
<>
{total > 0 ? (
<div
className="courseware-search__results-summary"
aria-live="polite"
aria-relevant="all"
aria-atomic="true"
data-testid="courseware-search-summary"
>{intl.formatMessage(messages.searchResultsLabel, { total, keyword: lastSearchKeyword })}
</div>
) : null}
<CoursewareSearchResultsFilterContainer />
</>
) : null}
</div>
</div>
</dialog>
</section>
);
};
export default CoursewareSearch;
CoursewareSearch.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearch);

View File

@@ -9,7 +9,6 @@ import {
screen,
waitFor,
fireEvent,
within,
} from '../../setupTest';
import { CoursewareSearch } from './index';
import { useElementBoundingBox, useLockScroll, useCoursewareSearchParams } from './hooks';
@@ -20,7 +19,6 @@ import { updateModel, useModel } from '../../generic/model-store';
jest.mock('./hooks');
jest.mock('../../generic/model-store', () => ({
...jest.requireActual('../../generic/model-store'),
updateModel: jest.fn(),
useModel: jest.fn(),
}));
@@ -58,7 +56,7 @@ const defaultProps = {
total: 0,
};
const defaultSearchParams = {
const coursewareSearch = {
query: '',
filter: '',
setQuery: jest.fn(),
@@ -98,20 +96,14 @@ const mockModels = ((props = defaultProps) => {
});
});
const mockSearchParams = ((params) => {
const props = { ...defaultSearchParams, ...params };
const mockSearchParams = ((props = coursewareSearch) => {
useCoursewareSearchParams.mockReturnValue(props);
});
describe('CoursewareSearch', () => {
beforeAll(() => initializeMockApp());
beforeAll(initializeMockApp);
beforeEach(() => {
mockModels();
mockSearchParams();
});
afterEach(() => {
jest.clearAllMocks();
});
@@ -121,22 +113,27 @@ describe('CoursewareSearch', () => {
});
it('should use useElementBoundingBox() and useLockScroll() hooks', () => {
mockModels();
mockSearchParams();
renderComponent();
expect(useElementBoundingBox).toHaveBeenCalledTimes(1);
expect(useLockScroll).toHaveBeenCalledTimes(1);
expect(useElementBoundingBox).toBeCalledTimes(1);
expect(useLockScroll).toBeCalledTimes(1);
});
it('should have a "--modal-top-position" CSS variable matching the CourseTabsNavigation top position', () => {
mockModels();
mockSearchParams();
renderComponent();
const section = screen.getByTestId('courseware-search-dialog');
const section = screen.getByTestId('courseware-search-section');
expect(section.style.getPropertyValue('--modal-top-position')).toBe(`${tabsTopPosition}px`);
});
});
describe('when clicking on the "Close" button', () => {
it('should close the dialog', async () => {
it('should dispatch setShowSearch(false)', async () => {
mockModels();
renderComponent();
await waitFor(() => {
@@ -144,8 +141,7 @@ describe('CoursewareSearch', () => {
fireEvent.click(close);
});
expect(HTMLDialogElement.prototype.close).toHaveBeenCalled();
expect(setShowSearch).toHaveBeenCalledWith(false);
expect(setShowSearch).toBeCalledWith(false);
});
});
@@ -153,24 +149,29 @@ describe('CoursewareSearch', () => {
it('should use "--modal-top-position: 0" if nce element is not present', () => {
useElementBoundingBox.mockImplementation(() => undefined);
mockModels();
mockSearchParams();
renderComponent();
const section = screen.getByTestId('courseware-search-dialog');
const section = screen.getByTestId('courseware-search-section');
expect(section.style.getPropertyValue('--modal-top-position')).toBe('0');
});
});
describe('when passing extra props', () => {
it('should pass on extra props to section element', () => {
mockModels();
mockSearchParams();
renderComponent({ foo: 'bar' });
const section = screen.getByTestId('courseware-search-dialog');
const section = screen.getByTestId('courseware-search-section');
expect(section).toHaveAttribute('foo', 'bar');
});
});
describe('when submitting an empty search', () => {
it('should clear the search by dispatch updateModel', async () => {
mockModels();
renderComponent();
await waitFor(() => {
@@ -202,6 +203,7 @@ describe('CoursewareSearch', () => {
});
it('should call searchCourseContent', async () => {
mockModels();
renderComponent();
const searchKeyword = 'course';
@@ -244,23 +246,19 @@ describe('CoursewareSearch', () => {
expect(screen.queryByTestId('courseware-search-summary')).not.toBeInTheDocument();
});
it('should show a summary for the results within a container with aria-live="polite"', () => {
it('should show a summary for the results', () => {
mockModels({
searchKeyword: 'fubar',
total: 1,
});
renderComponent();
const results = screen.queryByTestId('courseware-search-results');
expect(results).toHaveAttribute('aria-live', 'polite');
expect(within(results).queryByTestId('courseware-search-summary').textContent).toBe('Results for "fubar":');
expect(screen.queryByTestId('courseware-search-summary').textContent).toBe('Results for "fubar":');
});
});
describe('when clearing the search input', () => {
it('should clear the search by dispatch updateModel', async () => {
mockSearchParams({ query: 'fubar' });
mockModels({
searchKeyword: 'fubar',
total: 2,

View File

@@ -1,14 +1,15 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
const CoursewareSearchEmpty = () => {
const intl = useIntl();
return (
<div className="courseware-search-results">
<p className="courseware-search-results__empty" data-testid="no-results">{intl.formatMessage(messages.searchResultsNone)}</p>
</div>
);
const CoursewareSearchEmpty = ({ intl }) => (
<div className="courseware-search-results">
<p className="courseware-search-results__empty" data-testid="no-results">{intl.formatMessage(messages.searchResultsNone)}</p>
</div>
);
CoursewareSearchEmpty.propTypes = {
intl: intlShape.isRequired,
};
export default CoursewareSearchEmpty;
export default injectIntl(CoursewareSearchEmpty);

View File

@@ -1,44 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import { SearchField } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
const CoursewareSearchForm = ({
intl,
searchTerm,
onSubmit,
onChange,
placeholder,
}) => {
const { formatMessage } = useIntl();
return (
<SearchField.Advanced
value={searchTerm}
onSubmit={onSubmit}
onChange={onChange}
}) => (
<SearchField.Advanced
value={searchTerm}
onSubmit={onSubmit}
onChange={onChange}
submitButtonLocation="external"
className="courseware-search-form"
screenReaderText={{
label: intl.formatMessage(messages.searchSubmitLabel),
clearButton: intl.formatMessage(messages.searchClearAction),
submitButton: null, // Remove the sr-only label in the button.
}}
>
<div className="pgn__searchfield_wrapper" data-testid="courseware-search-form">
<SearchField.Label />
<SearchField.Input placeholder={placeholder} autoFocus />
<SearchField.ClearButton />
</div>
<SearchField.SubmitButton
buttonText={intl.formatMessage(messages.searchSubmitLabel)}
submitButtonLocation="external"
className="courseware-search-form"
screenReaderText={{
label: formatMessage(messages.searchSubmitLabel),
clearButton: formatMessage(messages.searchClearAction),
submitButton: null, // Remove the sr-only label in the button.
}}
>
<div className="pgn__searchfield_wrapper" data-testid="courseware-search-form">
<SearchField.Label />
<SearchField.Input placeholder={placeholder} autoFocus />
<SearchField.ClearButton />
</div>
<SearchField.SubmitButton
buttonText={formatMessage(messages.searchSubmitLabel)}
submitButtonLocation="external"
data-testid="courseware-search-form-submit"
/>
</SearchField.Advanced>
);
};
data-testid="courseware-search-form-submit"
/>
</SearchField.Advanced>
);
CoursewareSearchForm.propTypes = {
intl: intlShape.isRequired,
searchTerm: PropTypes.string,
onSubmit: PropTypes.func,
onChange: PropTypes.func,
@@ -52,4 +51,4 @@ CoursewareSearchForm.defaultProps = {
placeholder: undefined,
};
export default CoursewareSearchForm;
export default injectIntl(CoursewareSearchForm);

View File

@@ -1,14 +1,15 @@
import React, { useEffect } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { ManageSearch } from '@openedx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Icon } from '@openedx/paragon';
import { Search } from '@openedx/paragon/icons';
import { useDispatch } from 'react-redux';
import messages from './messages';
import { useCoursewareSearchFeatureFlag, useCoursewareSearchParams } from './hooks';
import { setShowSearch } from '../data/slice';
const CoursewareSearchToggle = () => {
const intl = useIntl();
const CoursewareSearchToggle = ({
intl,
}) => {
const dispatch = useDispatch();
const enabled = useCoursewareSearchFeatureFlag();
const { query } = useCoursewareSearchParams();
@@ -24,20 +25,23 @@ const CoursewareSearchToggle = () => {
if (!enabled) { return null; }
return (
<div className="courseware-search-toggle">
<div className="courseware-searc-toggle">
<Button
variant="outline-primary"
variant="tertiary"
size="sm"
className="p-1 mt-2 mr-2"
className="p-1 mt-2 mr-2 rounded-lg"
aria-label={intl.formatMessage(messages.searchOpenAction)}
onClick={handleSearchOpenClick}
data-testid="courseware-search-open-button"
iconAfter={ManageSearch}
>
{intl.formatMessage(messages.contentSearchButton)}
<Icon src={Search} />
</Button>
</div>
);
};
export default CoursewareSearchToggle;
CoursewareSearchToggle.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearchToggle);

View File

@@ -1,29 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`mapSearchResponse when the response is correct should match snapshot 1`] = `
{
"filters": [
{
Object {
"filters": Array [
Object {
"count": 7,
"key": "capa",
"label": "CAPA",
},
{
Object {
"count": 2,
"key": "sequence",
"label": "Sequence",
},
{
Object {
"count": 9,
"key": "text",
"label": "Text",
},
{
Object {
"count": 1,
"key": "unknown",
"label": "Unknown",
},
{
Object {
"count": 2,
"key": "video",
"label": "Video",
@@ -31,11 +31,11 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
],
"maxScore": 3.4545178,
"ms": 5,
"results": [
{
"results": Array [
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
"location": [
"location": Array [
"Introduction",
"Demo Course Overview",
],
@@ -44,10 +44,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "sequence",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
"location": [
"location": Array [
"About Exams and Certificates",
"edX Exams",
"Passing a Course",
@@ -57,10 +57,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
"location": [
"location": Array [
"About Exams and Certificates",
"edX Exams",
"Passing a Course",
@@ -70,10 +70,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "sequence",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
"location": [
"location": Array [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Text input",
@@ -83,10 +83,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
"location": [
"location": Array [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Pointing on a Picture",
@@ -96,10 +96,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
"location": [
"location": Array [
"About Exams and Certificates",
"edX Exams",
"Getting Answers",
@@ -109,10 +109,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
"location": [
"location": Array [
"Introduction",
"Demo Course Overview",
"Introduction: Video and Sequences",
@@ -122,10 +122,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "video",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
"location": [
"location": Array [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Multiple Choice Questions",
@@ -135,10 +135,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
"location": [
"location": Array [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Numerical Input",
@@ -148,10 +148,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
"location": [
"location": Array [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"Video Presentation Styles",
@@ -161,10 +161,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "video",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
"location": [
"location": Array [
"Example Week 2: Get Interactive",
"Homework - Labs and Demos",
"Code Grader",
@@ -174,10 +174,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
"location": [
"location": Array [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"Interactive Questions",
@@ -187,10 +187,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
"location": [
"location": Array [
"Introduction",
"Demo Course Overview",
"Introduction: Video and Sequences",
@@ -200,10 +200,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
"location": [
"location": Array [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Discussion Forums",
@@ -213,10 +213,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
"location": [
"location": Array [
"About Exams and Certificates",
"edX Exams",
"Overall Grade Performance",
@@ -226,10 +226,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
"location": [
"location": Array [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Homework - Find Your Study Buddy",
@@ -239,10 +239,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
"location": [
"location": Array [
"Example Week 3: Be Social",
"Homework - Find Your Study Buddy",
"Homework - Find Your Study Buddy",
@@ -252,10 +252,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
"location": [
"location": Array [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Be Social",
@@ -265,10 +265,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
"location": [
"location": Array [
"About Exams and Certificates",
"edX Exams",
"EdX Exams",
@@ -278,10 +278,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
},
{
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
"location": [
"location": Array [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"When Are Your Exams? ",
@@ -291,7 +291,7 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
},
{
Object {
"contentHits": 0,
"id": "random-element-id",
"location": null,

View File

@@ -5,25 +5,13 @@
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
max-width: none;
margin: 0;
border-top: 1px solid $light-300;
z-index: $zindex-modal; // Bootstrap's z-index layer for Modals.
&__form {
position: relative;
.h2 {
margin-right: 2.5rem;
}
}
&__close {
position: absolute !important; // For some reason it gets overridden
top: 0;
right: 0;
top: 0.5rem;
right: 1rem;
font-size: 1.5rem;
line-height: 1;
}
@@ -164,16 +152,9 @@
}
@media (min-width: map-get($grid-breakpoints, 'md')) {
.courseware-search {
&__close {
right: -2.5rem;
}
&__content {
padding-top: 8rem;
}
.courseware-search__content {
padding-top: 8rem;
}
}
body._search-no-scroll {

View File

@@ -1,4 +1,4 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { renderHook, act } from '@testing-library/react-hooks';
import { useParams, useSearchParams } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { fetchCoursewareSearchSettings } from '../data/thunks';
@@ -38,13 +38,13 @@ describe('CoursewareSearch Hooks', () => {
it('should return true if feature is enabled', async () => {
const hook = await renderTestHook();
await waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
await hook.waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
expect(hook.result.current).toBe(true);
});
it('should return false if feature is disabled', async () => {
const hook = await renderTestHook(false);
await waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
await hook.waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
expect(hook.result.current).toBe(false);
});
});
@@ -125,7 +125,7 @@ describe('CoursewareSearch Hooks', () => {
it('should return the element bounding box', async () => {
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
await waitFor(() => expect(getBoundingClientRectSpy).toHaveBeenCalled());
hook.waitFor(() => expect(getBoundingClientRectSpy).toHaveBeenCalled());
expect(hook.result.current).toEqual(mockedInfo);
});

View File

@@ -2,84 +2,79 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
searchOpenAction: {
id: 'learn.coursewareSearch.openAction',
id: 'learn.coursewareSerch.openAction',
defaultMessage: 'Search within this course',
description: 'Aria-label for a button that will pop up Courseware Search.',
},
contentSearchButton: {
id: 'learn.coursewareSearch.contentSearchButton',
defaultMessage: 'Content search',
description: 'Text for a button that will pop up Courseware Search.',
},
searchSubmitLabel: {
id: 'learn.coursewareSearch.submitLabel',
id: 'learn.coursewareSerch.submitLabel',
defaultMessage: 'Search',
description: 'Button label that will submit Courseware Search.',
},
searchClearAction: {
id: 'learn.coursewareSearch.clearAction',
id: 'learn.coursewareSerch.clearAction',
defaultMessage: 'Clear search',
description: 'Button label that will the current Courseware Search input.',
},
searchCloseAction: {
id: 'learn.coursewareSearch.closeAction',
id: 'learn.coursewareSerch.closeAction',
defaultMessage: 'Close the search form',
description: 'Aria-label for a button that will close Courseware Search.',
},
searchModuleTitle: {
id: 'learn.coursewareSearch.searchModuleTitle',
id: 'learn.coursewareSerch.searchModuleTitle',
defaultMessage: 'Search this course',
description: 'Title for the Courseware Search module.',
},
searchBarPlaceholderText: {
id: 'learn.coursewareSearch.searchBarPlaceholderText',
id: 'learn.coursewareSerch.searchBarPlaceholderText',
defaultMessage: 'Search',
description: 'Placeholder text for the Courseware Search input control',
},
loading: {
id: 'learn.coursewareSearch.loading',
id: 'learn.coursewareSerch.loading',
defaultMessage: 'Searching...',
description: 'Screen reader text to use on the spinner while the search is performing.',
},
searchResultsNone: {
id: 'learn.coursewareSearch.searchResultsNone',
id: 'learn.coursewareSerch.searchResultsNone',
defaultMessage: 'No results found.',
description: 'Text to show when the Courseware Search found no results matching the criteria.',
},
searchResultsLabel: {
id: 'learn.coursewareSearch.searchResultsLabel',
id: 'learn.coursewareSerch.searchResultsLabel',
defaultMessage: 'Results for "{keyword}":',
description: 'Text to show above the search results response list.',
},
searchResultsError: {
id: 'learn.coursewareSearch.searchResultsError',
id: 'learn.coursewareSerch.searchResultsError',
defaultMessage: 'There was an error on the search process. Please try again in a few minutes. If the problem persists, please contact the support team.',
description: 'Error message to show to the users when there\'s an error with the endpoint or the returned payload format.',
},
// These are translations for labeling the filters
'filter:all': {
id: 'learn.coursewareSearch.filter:all',
id: 'learn.coursewareSerch.filter:all',
defaultMessage: 'All content',
description: 'Label for the search results filter that shows all content (no filter).',
},
'filter:text': {
id: 'learn.coursewareSearch.filter:text',
id: 'learn.coursewareSerch.filter:text',
defaultMessage: 'Text',
description: 'Label for the search results filter that shows results with text content.',
},
'filter:video': {
id: 'learn.coursewareSearch.filter:video',
id: 'learn.coursewareSerch.filter:video',
defaultMessage: 'Video',
description: 'Label for the search results filter that shows results with video content.',
},
'filter:sequence': {
id: 'learn.coursewareSearch.filter:sequence',
id: 'learn.coursewareSerch.filter:sequence',
defaultMessage: 'Section',
description: 'Label for the search results filter that shows results with section content.',
},
'filter:other': {
id: 'learn.coursewareSearch.filter:other',
id: 'learn.coursewareSerch.filter:other',
defaultMessage: 'Other',
description: 'Label for the search results filter that shows results with other content.',
},

View File

@@ -6,7 +6,6 @@ Factory.define('courseHomeMetadata')
.option('host', 'http://localhost:18000')
.attrs({
title: 'Demonstration Course',
is_new_discussion_sidebar_view_enabled: false,
is_self_paced: false,
is_enrolled: false,
is_staff: false,

View File

@@ -31,6 +31,7 @@ Factory.define('outlineTabData')
course_access_redirect: false,
has_scheduled_content: null,
access_expiration: null,
can_show_upgrade_sock: false,
cert_data: {
cert_status: null,
cert_web_view_url: null,

View File

@@ -1,8 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
{
"courseHome": {
Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
@@ -12,13 +12,9 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"toastBodyText": null,
"toastHeader": "",
},
"courseware": {
"courseware": Object {
"courseId": null,
"courseOutline": {},
"courseOutlineShouldUpdate": false,
"courseOutlineStatus": "loading",
"courseStatus": "loading",
"coursewareOutlineSidebarSettings": {},
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
@@ -26,12 +22,12 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": {
"courseHomeMeta": {
"course-v1:edX+DemoX+Demo_Course": {
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"canViewCertificate": true,
"celebrations": null,
"courseAccess": {
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
@@ -42,40 +38,39 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"id": "course-v1:edX+DemoX+Demo_Course",
"isEnrolled": false,
"isMasquerading": false,
"isNewDiscussionSidebarViewEnabled": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": [
{
"tabs": Array [
Object {
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
{
Object {
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
{
Object {
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
{
Object {
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
{
Object {
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
{
Object {
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
@@ -84,7 +79,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": {
"verifiedMode": Object {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
@@ -94,10 +89,10 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
},
},
},
"dates": {
"course-v1:edX+DemoX+Demo_Course": {
"courseDateBlocks": [
{
"dates": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"courseDateBlocks": Array [
Object {
"date": "2020-05-01T17:59:41Z",
"dateType": "course-start-date",
"description": "",
@@ -106,7 +101,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"link": "",
"title": "Course Starts",
},
{
Object {
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-04T02:59:40.942669Z",
@@ -116,7 +111,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"learnerHasAccess": true,
"title": "Multi Badges Completed",
},
{
Object {
"assignmentType": "Homework",
"date": "2020-05-05T02:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -125,7 +120,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"learnerHasAccess": true,
"title": "Multi Badges Past Due",
},
{
Object {
"assignmentType": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -135,7 +130,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"link": "https://example.com/",
"title": "Both Past Due 1",
},
{
Object {
"assignmentType": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -145,7 +140,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"link": "https://example.com/",
"title": "Both Past Due 2",
},
{
Object {
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-28T08:59:40.942669Z",
@@ -156,7 +151,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"link": "https://example.com/",
"title": "One Completed/Due 1",
},
{
Object {
"assignmentType": "Homework",
"date": "2020-05-28T08:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -166,7 +161,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"link": "https://example.com/",
"title": "One Completed/Due 2",
},
{
Object {
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
@@ -177,7 +172,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"link": "https://example.com/",
"title": "Both Completed 1",
},
{
Object {
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
@@ -188,7 +183,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"link": "https://example.com/",
"title": "Both Completed 2",
},
{
Object {
"date": "2020-06-16T17:59:40.942669Z",
"dateType": "verified-upgrade-deadline",
"description": "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
@@ -197,7 +192,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"link": "https://example.com/",
"title": "Upgrade to Verified Certificate",
},
{
Object {
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -207,7 +202,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"link": "https://example.com/",
"title": "One Verified 1",
},
{
Object {
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -217,7 +212,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"link": "https://example.com/",
"title": "One Verified 2",
},
{
Object {
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -227,7 +222,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"link": "https://example.com/",
"title": "ORA Verified 2",
},
{
Object {
"assignmentType": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -237,7 +232,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"link": "https://example.com/",
"title": "Both Verified 1",
},
{
Object {
"assignmentType": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -247,7 +242,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"link": "https://example.com/",
"title": "Both Verified 2",
},
{
Object {
"assignmentType": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -255,7 +250,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"learnerHasAccess": true,
"title": "One Unreleased 1",
},
{
Object {
"assignmentType": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -265,7 +260,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"link": "https://example.com/",
"title": "One Unreleased 2",
},
{
Object {
"assignmentType": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -274,7 +269,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"learnerHasAccess": true,
"title": "Both Unreleased 1",
},
{
Object {
"assignmentType": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -283,7 +278,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"learnerHasAccess": true,
"title": "Both Unreleased 2",
},
{
Object {
"date": "2030-08-23T00:00:00Z",
"dateType": "course-end-date",
"description": "",
@@ -292,7 +287,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"link": "",
"title": "Course Ends",
},
{
Object {
"date": "2030-09-01T00:00:00Z",
"dateType": "verification-deadline-date",
"description": "You must successfully complete verification before this date to qualify for a Verified Certificate.",
@@ -302,7 +297,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"title": "Verification Deadline",
},
],
"datesBannerInfo": {
"datesBannerInfo": Object {
"contentTypeGatingEnabled": false,
"missedDeadlines": false,
"missedGatedContent": false,
@@ -314,16 +309,16 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
},
},
},
"plugins": {},
"recommendations": {
"plugins": Object {},
"recommendations": Object {
"recommendationsStatus": "loading",
},
"specialExams": {
"specialExams": Object {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
"exam": {
"attempt": {
"exam": Object {
"attempt": Object {
"attempt_code": "",
"attempt_id": null,
"attempt_status": "",
@@ -351,27 +346,27 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"is_active": true,
"is_practice_exam": false,
"is_proctored": false,
"prerequisite_status": {
"prerequisite_status": Object {
"are_prerequisites_satisifed": true,
"declined_prerequisites": [],
"failed_prerequisites": [],
"pending_prerequisites": [],
"satisfied_prerequisites": [],
"declined_prerequisites": Array [],
"failed_prerequisites": Array [],
"pending_prerequisites": Array [],
"satisfied_prerequisites": Array [],
},
"time_limit_mins": null,
"type": "",
},
"examAccessToken": {
"examAccessToken": Object {
"exam_access_token": "",
"exam_access_token_expiration": "",
},
"isLoading": true,
"proctoringSettings": {
"exam_proctoring_backend": {
"proctoringSettings": Object {
"exam_proctoring_backend": Object {
"download_url": "",
"instructions": [],
"instructions": Array [],
"name": "",
"rules": {},
"rules": Object {},
},
"integration_specific_email": "",
"learner_notification_from_email": "",
@@ -382,7 +377,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
},
"timeIsOver": false,
},
"tours": {
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
@@ -393,8 +388,8 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
`;
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
{
"courseHome": {
Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
@@ -404,13 +399,9 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
"toastBodyText": null,
"toastHeader": "",
},
"courseware": {
"courseware": Object {
"courseId": null,
"courseOutline": {},
"courseOutlineShouldUpdate": false,
"courseOutlineStatus": "loading",
"courseStatus": "loading",
"coursewareOutlineSidebarSettings": {},
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
@@ -418,12 +409,12 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": {
"courseHomeMeta": {
"course-v1:edX+DemoX+Demo_Course": {
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"canViewCertificate": true,
"celebrations": null,
"courseAccess": {
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
@@ -434,40 +425,39 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
"id": "course-v1:edX+DemoX+Demo_Course",
"isEnrolled": false,
"isMasquerading": false,
"isNewDiscussionSidebarViewEnabled": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": [
{
"tabs": Array [
Object {
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
{
Object {
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
{
Object {
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
{
Object {
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
{
Object {
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
{
Object {
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
@@ -476,7 +466,7 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": {
"verifiedMode": Object {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
@@ -486,40 +476,41 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
},
},
},
"outline": {
"course-v1:edX+DemoX+Demo_Course": {
"outline": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"accessExpiration": null,
"certData": {
"canShowUpgradeSock": false,
"certData": Object {
"certStatus": null,
"certWebViewUrl": null,
"certificateAvailableDate": null,
},
"courseBlocks": {
"courses": {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": {
"courseBlocks": Object {
"courses": Object {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
"hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"sectionIds": [
"sectionIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
],
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
},
},
"sections": {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": {
"sections": Object {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
"complete": false,
"courseId": "course-v1:edX+DemoX+Demo_Course",
"hideFromTOC": undefined,
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"resumeBlock": false,
"sequenceIds": [
"sequenceIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
],
"title": "Title of Section",
},
},
"sequences": {
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": {
"sequences": Object {
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
"complete": false,
"description": null,
"due": null,
@@ -535,30 +526,30 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
},
},
},
"courseGoals": {
"courseGoals": Object {
"daysPerWeek": null,
"goalOptions": [],
"goalOptions": Array [],
"selectedGoal": null,
"subscribedToReminders": null,
"weeklyLearningGoalEnabled": false,
},
"courseTools": [
{
"courseTools": Array [
Object {
"analyticsId": "edx.bookmarks",
"title": "Bookmarks",
"url": "https://example.com/bookmarks",
},
],
"datesBannerInfo": {
"datesBannerInfo": Object {
"contentTypeGatingEnabled": false,
"missedDeadlines": false,
"missedGatedContent": false,
},
"datesWidget": {
"courseDateBlocks": [],
"datesWidget": Object {
"courseDateBlocks": Array [],
},
"enableProctoredExams": undefined,
"enrollAlert": {
"enrollAlert": Object {
"canEnroll": true,
"extraText": "Contact the administrator.",
},
@@ -568,13 +559,13 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
"hasScheduledContent": null,
"id": "course-v1:edX+DemoX+Demo_Course",
"offer": null,
"resumeCourse": {
"resumeCourse": Object {
"hasVisitedCourse": false,
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
},
"timeOffsetMillis": 0,
"userHasPassingGrade": undefined,
"verifiedMode": {
"verifiedMode": Object {
"accessExpirationDate": "2050-01-01T12:00:00",
"currency": "USD",
"currencySymbol": "$",
@@ -586,16 +577,16 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
},
},
},
"plugins": {},
"recommendations": {
"plugins": Object {},
"recommendations": Object {
"recommendationsStatus": "loading",
},
"specialExams": {
"specialExams": Object {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
"exam": {
"attempt": {
"exam": Object {
"attempt": Object {
"attempt_code": "",
"attempt_id": null,
"attempt_status": "",
@@ -623,27 +614,27 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
"is_active": true,
"is_practice_exam": false,
"is_proctored": false,
"prerequisite_status": {
"prerequisite_status": Object {
"are_prerequisites_satisifed": true,
"declined_prerequisites": [],
"failed_prerequisites": [],
"pending_prerequisites": [],
"satisfied_prerequisites": [],
"declined_prerequisites": Array [],
"failed_prerequisites": Array [],
"pending_prerequisites": Array [],
"satisfied_prerequisites": Array [],
},
"time_limit_mins": null,
"type": "",
},
"examAccessToken": {
"examAccessToken": Object {
"exam_access_token": "",
"exam_access_token_expiration": "",
},
"isLoading": true,
"proctoringSettings": {
"exam_proctoring_backend": {
"proctoringSettings": Object {
"exam_proctoring_backend": Object {
"download_url": "",
"instructions": [],
"instructions": Array [],
"name": "",
"rules": {},
"rules": Object {},
},
"integration_specific_email": "",
"learner_notification_from_email": "",
@@ -654,7 +645,7 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
},
"timeIsOver": false,
},
"tours": {
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
@@ -665,8 +656,8 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
`;
exports[`Data layer integration tests Test fetchProgressTab Should fetch, normalize, and save metadata 1`] = `
{
"courseHome": {
Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
@@ -676,13 +667,9 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"toastBodyText": null,
"toastHeader": "",
},
"courseware": {
"courseware": Object {
"courseId": null,
"courseOutline": {},
"courseOutlineShouldUpdate": false,
"courseOutlineStatus": "loading",
"courseStatus": "loading",
"coursewareOutlineSidebarSettings": {},
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
@@ -690,12 +677,12 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": {
"courseHomeMeta": {
"course-v1:edX+DemoX+Demo_Course": {
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"canViewCertificate": true,
"celebrations": null,
"courseAccess": {
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
@@ -706,40 +693,39 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"id": "course-v1:edX+DemoX+Demo_Course",
"isEnrolled": false,
"isMasquerading": false,
"isNewDiscussionSidebarViewEnabled": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": [
{
"tabs": Array [
Object {
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
{
Object {
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
{
Object {
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
{
Object {
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
{
Object {
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
{
Object {
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
@@ -748,7 +734,7 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": {
"verifiedMode": Object {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
@@ -758,16 +744,16 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
},
},
},
"progress": {
"course-v1:edX+DemoX+Demo_Course": {
"progress": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"accessExpiration": null,
"certificateData": {},
"completionSummary": {
"certificateData": Object {},
"completionSummary": Object {
"completeCount": 1,
"incompleteCount": 1,
"lockedCount": 0,
},
"courseGrade": {
"courseGrade": Object {
"isPassing": true,
"letterGrade": "pass",
"percent": 1,
@@ -778,10 +764,10 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"enrollmentMode": "audit",
"gradesFeatureIsFullyLocked": false,
"gradesFeatureIsPartiallyLocked": false,
"gradingPolicy": {
"assignmentPolicies": [
{
"averageGrade": "1.0000",
"gradingPolicy": Object {
"assignmentPolicies": Array [
Object {
"averageGrade": "1.00",
"numDroppable": 1,
"shortLabel": "HW",
"type": "Homework",
@@ -789,17 +775,17 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"weightedGrade": 1,
},
],
"gradeRange": {
"gradeRange": Object {
"pass": 0.75,
},
},
"hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"sectionScores": [
{
"sectionScores": Array [
Object {
"displayName": "First section",
"subsections": [
{
"subsections": Array [
Object {
"assignmentType": "Homework",
"blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345",
"displayName": "First subsection",
@@ -808,16 +794,16 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"numPointsEarned": 0,
"numPointsPossible": 3,
"percentGraded": 0,
"problemScores": [
{
"problemScores": Array [
Object {
"earned": 0,
"possible": 1,
},
{
Object {
"earned": 0,
"possible": 1,
},
{
Object {
"earned": 0,
"possible": 1,
},
@@ -828,18 +814,18 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
},
],
},
{
Object {
"displayName": "Second section",
"subsections": [
{
"subsections": Array [
Object {
"assignmentType": "Homework",
"displayName": "Second subsection",
"hasGradedAssignment": true,
"numPointsEarned": 1,
"numPointsPossible": 1,
"percentGraded": 1,
"problemScores": [
{
"problemScores": Array [
Object {
"earned": 1,
"possible": 1,
},
@@ -853,7 +839,7 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
],
"studioUrl": "http://studio.edx.org/settings/grading/course-v1:edX+Test+run",
"userHasPassingGrade": false,
"verificationData": {
"verificationData": Object {
"link": null,
"status": "none",
"statusDate": null,
@@ -862,16 +848,16 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
},
},
},
"plugins": {},
"recommendations": {
"plugins": Object {},
"recommendations": Object {
"recommendationsStatus": "loading",
},
"specialExams": {
"specialExams": Object {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
"exam": {
"attempt": {
"exam": Object {
"attempt": Object {
"attempt_code": "",
"attempt_id": null,
"attempt_status": "",
@@ -899,27 +885,27 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"is_active": true,
"is_practice_exam": false,
"is_proctored": false,
"prerequisite_status": {
"prerequisite_status": Object {
"are_prerequisites_satisifed": true,
"declined_prerequisites": [],
"failed_prerequisites": [],
"pending_prerequisites": [],
"satisfied_prerequisites": [],
"declined_prerequisites": Array [],
"failed_prerequisites": Array [],
"pending_prerequisites": Array [],
"satisfied_prerequisites": Array [],
},
"time_limit_mins": null,
"type": "",
},
"examAccessToken": {
"examAccessToken": Object {
"exam_access_token": "",
"exam_access_token_expiration": "",
},
"isLoading": true,
"proctoringSettings": {
"exam_proctoring_backend": {
"proctoringSettings": Object {
"exam_proctoring_backend": Object {
"download_url": "",
"instructions": [],
"instructions": Array [],
"name": "",
"rules": {},
"rules": Object {},
},
"integration_specific_email": "",
"learner_notification_from_email": "",
@@ -930,7 +916,7 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
},
"timeIsOver": false,
},
"tours": {
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,

View File

@@ -18,7 +18,7 @@ const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) =
// Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately
// reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
// exists in edx-platform.
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(4);
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(2);
weightedGrade = averageGrade * assignmentWeight;
}
return { averageGrade, weightedGrade };
@@ -289,17 +289,9 @@ export async function getProgressTabData(courseId, targetUserId) {
}
export async function getProctoringInfoData(courseId, username) {
let url;
if (!getConfig().EXAMS_BASE_URL) {
url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
if (username) {
url += `&username=${encodeURIComponent(username)}`;
}
} else {
url = `${getConfig().EXAMS_BASE_URL}/api/v1/student/course_id/${encodeURIComponent(courseId)}/onboarding`;
if (username) {
url += `?username=${encodeURIComponent(username)}`;
}
let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
if (username) {
url += `&username=${encodeURIComponent(username)}`;
}
try {
const { data } = await getAuthenticatedHttpClient().get(url);
@@ -367,6 +359,7 @@ export async function getOutlineTabData(courseId) {
} = tabData;
const accessExpiration = camelCaseObject(data.access_expiration);
const canShowUpgradeSock = data.can_show_upgrade_sock;
const certData = camelCaseObject(data.cert_data);
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
const courseGoals = camelCaseObject(data.course_goals);
@@ -388,6 +381,7 @@ export async function getOutlineTabData(courseId) {
return {
accessExpiration,
canShowUpgradeSock,
certData,
courseBlocks,
courseGoals,
@@ -455,7 +449,7 @@ export async function unsubscribeFromCourseGoal(token) {
.then(res => camelCaseObject(res));
}
export async function getCoursewareSearchEnabled(courseId) {
export async function getCoursewareSearchEnabledFlag(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`);
const { data } = await getAuthenticatedHttpClient().get(url.href);
return { enabled: data.enabled || false };

View File

@@ -46,6 +46,7 @@ describe('Course Home Service', () => {
willRespondWith: {
status: 200,
body: {
can_show_upgrade_sock: boolean(false),
verified_mode: like({
access_expiration_date: null,
currency: 'USD',
@@ -88,11 +89,11 @@ describe('Course Home Service', () => {
}),
title: string('Demonstration Course'),
username: string('edx'),
has_course_author_access: boolean(true),
},
},
});
const normalizedTabData = {
canShowUpgradeSock: false,
verifiedMode: {
accessExpirationDate: null,
currency: 'USD',
@@ -132,7 +133,6 @@ describe('Course Home Service', () => {
],
title: 'Demonstration Course',
username: 'edx',
hasCourseAuthorAccess: true,
};
const response = getCourseHomeCourseMetadata(courseId, 'outline');
expect(response).toBeTruthy();

View File

@@ -1,12 +1,10 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import {
LOADING,
LOADED,
FAILED,
DENIED,
} from '@src/constants';
export const LOADING = 'loading';
export const LOADED = 'loaded';
export const FAILED = 'failed';
export const DENIED = 'denied';
const slice = createSlice({
name: 'course-home',

View File

@@ -12,7 +12,7 @@ import {
postDismissWelcomeMessage,
postRequestCert,
getLiveTabIframe,
getCoursewareSearchEnabled,
getCoursewareSearchEnabledFlag,
searchCourseContentFromAPI,
} from './api';
@@ -159,7 +159,7 @@ export function processEvent(eventData, getTabData) {
export async function fetchCoursewareSearchSettings(courseId) {
try {
const { enabled } = await getCoursewareSearchEnabled(courseId);
const { enabled } = await getCoursewareSearchEnabledFlag(courseId);
return { enabled };
} catch (e) {
return { enabled: false };

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import Timeline from './timeline/Timeline';
@@ -14,8 +14,7 @@ import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
import UpgradeToCompleteAlert from '../suggested-schedule-messaging/UpgradeToCompleteAlert';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
const DatesTab = () => {
const intl = useIntl();
const DatesTab = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -60,4 +59,8 @@ const DatesTab = () => {
);
};
export default DatesTab;
DatesTab.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(DatesTab);

View File

@@ -135,7 +135,6 @@ describe('DatesTab', () => {
});
it('shows extra info', async () => {
const user = userEvent.setup();
const { items } = await getDay('Sat, Aug 17, 2030');
expect(items).toHaveLength(3);
@@ -143,12 +142,10 @@ describe('DatesTab', () => {
const tipText = "ORA Dates are set by the instructor, and can't be changed";
expect(screen.queryByText(tipText)).toBeNull(); // tooltip does not start in DOM
await user.hover(tipIcon);
screen.getByText(tipText); // now it's there
await user.unhover(tipIcon);
await waitFor(() => {
expect(screen.queryByText(tipText)).toBeNull(); // and it's gone again
});
userEvent.hover(tipIcon);
const tooltip = screen.getByText(tipText); // now it's there
userEvent.unhover(tipIcon);
await waitForElementToBeRemoved(tooltip); // and it's gone again
});
});

View File

@@ -5,7 +5,8 @@ import { useSelector } from 'react-redux';
import {
FormattedDate,
FormattedTime,
useIntl,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { Tooltip, OverlayTrigger } from '@openedx/paragon';
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
@@ -19,10 +20,10 @@ import { isLearnerAssignment } from '../utils';
const Day = ({
date,
first,
intl,
items,
last,
}) => {
const intl = useIntl();
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -107,6 +108,7 @@ const Day = ({
Day.propTypes = {
date: PropTypes.objectOf(Date).isRequired,
first: PropTypes.bool,
intl: intlShape.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({
date: PropTypes.string,
dateType: PropTypes.string,
@@ -124,4 +126,4 @@ Day.defaultProps = {
last: false,
};
export default Day;
export default injectIntl(Day);

View File

@@ -1,4 +1,5 @@
import { getConfig } from '@edx/frontend-platform';
import { injectIntl } from '@edx/frontend-platform/i18n';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { useParams, generatePath, useNavigate } from 'react-router-dom';
@@ -29,4 +30,6 @@ const DiscussionTab = () => {
);
};
export default DiscussionTab;
DiscussionTab.propTypes = {};
export default injectIntl(DiscussionTab);

View File

@@ -1,17 +1,16 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import HeaderSlot from '../../plugin-slots/HeaderSlot';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import PageLoading from '../../generic/PageLoading';
import { unsubscribeFromCourseGoal } from '../data/api';
import messages from './messages';
import ResultPage from './ResultPage';
const GoalUnsubscribe = () => {
const intl = useIntl();
const GoalUnsubscribe = ({ intl }) => {
const { token } = useParams();
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
@@ -39,7 +38,7 @@ const GoalUnsubscribe = () => {
return (
<>
<HeaderSlot showUserDropdown={false} />
<Header showUserDropdown={false} />
<main id="main-content" className="container my-5 text-center">
{isLoading && (
<PageLoading srMessage={`${intl.formatMessage(messages.loading)}`} />
@@ -52,4 +51,8 @@ const GoalUnsubscribe = () => {
);
};
export default GoalUnsubscribe;
GoalUnsubscribe.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(GoalUnsubscribe);

View File

@@ -1,26 +1,28 @@
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Hyperlink } from '@openedx/paragon';
import messages from './messages';
import { ReactComponent as UnsubscribeIcon } from './unsubscribe.svg';
const ResultPage = ({ courseTitle, error }) => {
const intl = useIntl();
const errorDescription = intl.formatMessage(
messages.errorDescription,
{
contactSupport: (
<Hyperlink
className="text-reset"
style={{ textDecoration: 'underline' }}
destination={`${getConfig().CONTACT_URL}`}
>
{intl.formatMessage(messages.contactSupport)}
</Hyperlink>
),
},
const ResultPage = ({ courseTitle, error, intl }) => {
const errorDescription = (
<FormattedMessage
id="learning.goals.unsubscribe.errorDescription"
defaultMessage="We were unable to unsubscribe you from goal reminder emails. Please try again later or {contactSupport} for help."
values={{
contactSupport: (
<Hyperlink
className="text-reset"
style={{ textDecoration: 'underline' }}
destination={`${getConfig().CONTACT_URL}`}
>
{intl.formatMessage(messages.contactSupport)}
</Hyperlink>
),
}}
/>
);
const header = error
@@ -52,6 +54,7 @@ ResultPage.defaultProps = {
ResultPage.propTypes = {
courseTitle: PropTypes.string,
error: PropTypes.bool,
intl: intlShape.isRequired,
};
export default ResultPage;
export default injectIntl(ResultPage);

View File

@@ -16,11 +16,6 @@ const messages = defineMessages({
defaultMessage: 'Something went wrong',
description: 'It indicate that the unsubscribing request has failed',
},
errorDescription: {
id: 'learning.goals.unsubscribe.errorDescription',
defaultMessage: 'We were unable to unsubscribe you from goal reminder emails. Please try again later or {contactSupport} for help.',
description: 'Message that notifies user that unsubscribing failed and to try again',
},
goToDashboard: {
id: 'learning.goals.unsubscribe.goToDashboard',
defaultMessage: 'Go to dashboard',

View File

@@ -1,11 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { CourseOutlineTabNotificationsSlot } from '../../plugin-slots/CourseOutlineTabNotificationsSlot';
import { AlertList } from '../../generic/user-messages';
import CourseDates from './widgets/CourseDates';
@@ -15,7 +14,9 @@ import WeeklyLearningGoalCard from './widgets/WeeklyLearningGoalCard';
import CourseTools from './widgets/CourseTools';
import { fetchOutlineTab } from '../data';
import messages from './messages';
import Section from './Section';
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
import useCertificateAvailableAlert from './alerts/certificate-status-alert';
import useCourseEndAlert from './alerts/course-end-alert';
@@ -26,10 +27,8 @@ import { useModel } from '../../generic/model-store';
import WelcomeMessage from './widgets/WelcomeMessage';
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
import CourseHomeSectionOutlineSlot from '../../plugin-slots/CourseHomeSectionOutlineSlot';
const OutlineTab = () => {
const intl = useIntl();
const OutlineTab = ({ intl }) => {
const {
courseId,
proctoringPanelStatus,
@@ -39,11 +38,11 @@ const OutlineTab = () => {
isSelfPaced,
org,
title,
userTimezone,
} = useModel('courseHomeMeta', courseId);
const expandButtonRef = useRef();
const {
accessExpiration,
courseBlocks: {
courses,
sections,
@@ -52,12 +51,20 @@ const OutlineTab = () => {
selectedGoal,
weeklyLearningGoalEnabled,
} = {},
datesBannerInfo,
datesWidget: {
courseDateBlocks,
},
enableProctoredExams,
offer,
timeOffsetMillis,
verifiedMode,
} = useModel('outline', courseId);
const {
marketingUrl,
} = useModel('coursewareMeta', courseId);
const [expandAll, setExpandAll] = useState(false);
const navigate = useNavigate();
@@ -151,21 +158,27 @@ const OutlineTab = () => {
</>
)}
<StartOrResumeCourseCard />
<WelcomeMessage courseId={courseId} nextElementRef={expandButtonRef} />
<WelcomeMessage courseId={courseId} />
{rootCourseId && (
<>
<div id="expand-button-row" className="row w-100 m-0 mb-3 justify-content-end">
<div className="row w-100 m-0 mb-3 justify-content-end">
<div className="col-12 col-md-auto p-0">
<Button ref={expandButtonRef} variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
<Button variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
{expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)}
</Button>
</div>
</div>
<CourseHomeSectionOutlineSlot
expandAll={expandAll}
sectionIds={courses[rootCourseId].sectionIds}
sections={sections}
/>
<ol id="courseHome-outline" className="list-unstyled">
{courses[rootCourseId].sectionIds.map((sectionId) => (
<Section
key={sectionId}
courseId={courseId}
defaultOpen={sections[sectionId].resumeBlock}
expand={expandAll}
section={sections[sectionId]}
/>
))}
</ol>
</>
)}
</div>
@@ -181,7 +194,19 @@ const OutlineTab = () => {
/>
)}
<CourseTools />
<CourseOutlineTabNotificationsSlot courseId={courseId} />
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="course_home"
userTimezone={userTimezone}
shouldDisplayBorder
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
/>
<CourseDates />
<CourseHandouts />
</div>
@@ -191,4 +216,8 @@ const OutlineTab = () => {
);
};
export default OutlineTab;
OutlineTab.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(OutlineTab);

View File

@@ -5,7 +5,7 @@ import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import Cookies from 'js-cookie';
@@ -54,7 +54,7 @@ describe('Outline Tab', () => {
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
const proctoringInfoUrl = `${getConfig().EXAMS_BASE_URL}/api/v1/student/course_id/${encodeURIComponent(courseId)}/onboarding?username=MockUser`;
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}&username=MockUser`;
const store = initializeStore();
const defaultMetadata = Factory.build('courseHomeMetadata');
@@ -132,18 +132,7 @@ describe('Outline Tab', () => {
expect(expandedSectionNode).toHaveAttribute('aria-expanded', 'true');
});
it('includes outline_tab_notifications_slot', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
expect(screen.getByTestId('org.openedx.frontend.learning.course_outline_tab_notifications.v1')).toBeInTheDocument();
});
it('handles expand/collapse all button click', async () => {
const user = userEvent.setup();
await fetchAndRender();
// Button renders as "Expand All"
const expandButton = screen.getByRole('button', { name: 'Expand all' });
@@ -154,11 +143,11 @@ describe('Outline Tab', () => {
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
// Click to expand section
await user.click(expandButton);
userEvent.click(expandButton);
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true'));
// Click to collapse section
await user.click(expandButton);
userEvent.click(expandButton);
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false'));
});
@@ -168,7 +157,7 @@ describe('Outline Tab', () => {
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
expect(screen.getByLabelText('Completed section')).toBeInTheDocument();
expect(screen.getByTitle('Completed section')).toBeInTheDocument();
});
it('displays correct icon for incomplete assignment', async () => {
@@ -177,7 +166,7 @@ describe('Outline Tab', () => {
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
expect(screen.getByLabelText('Incomplete section')).toBeInTheDocument();
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
});
it('SequenceLink displays link', async () => {
@@ -276,34 +265,21 @@ describe('Outline Tab', () => {
});
it('renders show more/less button and handles click', async () => {
const user = userEvent.setup();
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
let showMoreButton = screen.getByRole('button', { name: 'Show More' });
expect(showMoreButton).toBeInTheDocument();
await user.click(showMoreButton);
userEvent.click(showMoreButton);
let showLessButton = screen.getByRole('button', { name: 'Show Less' });
expect(showLessButton).toBeInTheDocument();
expect(screen.getByTestId('long-welcome-message-iframe')).toBeInTheDocument();
await user.click(showLessButton);
userEvent.click(showLessButton);
showLessButton = screen.queryByRole('button', { name: 'Show Less' });
expect(showLessButton).not.toBeInTheDocument();
showMoreButton = screen.getByRole('button', { name: 'Show More' });
expect(showMoreButton).toBeInTheDocument();
});
it('dismisses message', async () => {
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
const dismissButton = screen.queryByRole('button', { name: 'Dismiss' });
const expandButton = screen.queryByRole('button', { name: 'Expand all' });
fireEvent.click(dismissButton);
expect(expandButton).toHaveFocus();
expect(screen.queryByText('Welcome Message')).toBeNull();
});
});
it('ignores comments and misformatted HTML', async () => {
@@ -1190,6 +1166,80 @@ describe('Outline Tab', () => {
});
});
describe('Upgrade Card', () => {
it('renders title when upgrade is available', async () => {
await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
});
it('displays link to upgrade', async () => {
await fetchAndRender();
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
});
it('viewing upgrade card sends analytics', async () => {
sendTrackEvent.mockClear();
sendTrackingLogEvent.mockClear();
await fetchAndRender();
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('Promotion Viewed', {
org_key: 'edX',
courserun_key: courseId,
creative: 'sidebarupsell',
name: 'In-Course Verification Prompt',
position: 'sidebar-message',
promotion_id: 'courseware_verified_certificate_upsell',
});
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(1);
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.bi.course.upgrade.sidebarupsell.displayed', {
org_key: 'edX',
courserun_key: courseId,
});
});
it('clicking upgrade link sends analytics', async () => {
await fetchAndRender();
// Clearing after render to remove any events sent on view (ex. 'Promotion Viewed')
sendTrackEvent.mockClear();
sendTrackingLogEvent.mockClear();
const upgradeButton = screen.getByRole('link', { name: 'Upgrade for $149' });
fireEvent.click(upgradeButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'Promotion Clicked', {
org_key: 'edX',
courserun_key: courseId,
creative: 'sidebarupsell',
name: 'In-Course Verification Prompt',
position: 'sidebar-message',
promotion_id: 'courseware_verified_certificate_upsell',
});
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: 'green_upgrade',
linkName: 'course_home_green',
linkType: 'button',
pageName: 'course_home',
});
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(2);
expect(sendTrackingLogEvent).toHaveBeenNthCalledWith(1, 'edx.bi.course.upgrade.sidebarupsell.clicked', {
org_key: 'edX',
courserun_key: courseId,
});
expect(sendTrackingLogEvent).toHaveBeenNthCalledWith(2, 'edx.course.enrollment.upgrade.clicked', {
org_key: 'edX',
courserun_key: courseId,
location: 'sidebar-message',
});
});
});
describe('Account Activation Alert', () => {
beforeEach(() => {
const intersectionObserverMock = () => ({

View File

@@ -0,0 +1,137 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Collapsible, IconButton, Icon } from '@openedx/paragon';
import { faCheckCircle as fasCheckCircle, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { DisabledVisible } from '@openedx/paragon/icons';
import SequenceLink from './SequenceLink';
import { useModel } from '../../generic/model-store';
import genericMessages from '../../generic/messages';
import messages from './messages';
const Section = ({
courseId,
defaultOpen,
expand,
intl,
section,
}) => {
const {
complete,
sequenceIds,
title,
hideFromTOC,
} = section;
const {
courseBlocks: {
sequences,
},
} = useModel('outline', courseId);
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
setOpen(expand);
}, [expand]);
useEffect(() => {
setOpen(defaultOpen);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sectionTitle = (
<div className="d-flex row w-100 m-0">
<div className="col-auto p-0">
{complete ? (
<FontAwesomeIcon
icon={fasCheckCircle}
fixedWidth
className="float-left mt-1 text-success"
aria-hidden="true"
title={intl.formatMessage(messages.completedSection)}
/>
) : (
<FontAwesomeIcon
icon={farCheckCircle}
fixedWidth
className="float-left mt-1 text-gray-400"
aria-hidden="true"
title={intl.formatMessage(messages.incompleteSection)}
/>
)}
</div>
<div className="col-7 ml-3 p-0 font-weight-bold text-dark-500">
<span className="align-middle col-6">{title}</span>
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
</span>
</div>
{hideFromTOC && (
<div className="row">
{hideFromTOC && (
<span className="small d-flex align-content-end">
<Icon className="mr-2" src={DisabledVisible} data-testid="hide-from-toc-section-icon" />
<span data-testid="hide-from-toc-section-text">
{intl.formatMessage(messages.hiddenSection)}
</span>
</span>
)}
</div>
)}
</div>
);
return (
<li>
<Collapsible
className="mb-2"
styling="card-lg"
title={sectionTitle}
open={open}
onToggle={() => { setOpen(!open); }}
iconWhenClosed={(
<IconButton
alt={intl.formatMessage(messages.openSection)}
icon={faPlus}
onClick={() => { setOpen(true); }}
size="sm"
/>
)}
iconWhenOpen={(
<IconButton
alt={intl.formatMessage(genericMessages.close)}
icon={faMinus}
onClick={() => { setOpen(false); }}
size="sm"
/>
)}
>
<ol className="list-unstyled">
{sequenceIds.map((sequenceId, index) => (
<SequenceLink
key={sequenceId}
id={sequenceId}
courseId={courseId}
sequence={sequences[sequenceId]}
first={index === 0}
/>
))}
</ol>
</Collapsible>
</li>
);
};
Section.propTypes = {
courseId: PropTypes.string.isRequired,
defaultOpen: PropTypes.bool.isRequired,
expand: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
section: PropTypes.shape().isRequired,
};
export default injectIntl(Section);

View File

@@ -0,0 +1,148 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import {
FormattedMessage,
FormattedTime,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { faCheckCircle as fasCheckCircle } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Icon } from '@openedx/paragon';
import { Block } from '@openedx/paragon/icons';
import EffortEstimate from '../../shared/effort-estimate';
import { useModel } from '../../generic/model-store';
import messages from './messages';
const SequenceLink = ({
id,
intl,
courseId,
first,
sequence,
}) => {
const {
complete,
description,
due,
showLink,
title,
hideFromTOC,
} = sequence;
const {
userTimezone,
} = useModel('outline', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
const displayTitle = showLink ? coursewareUrl : title;
const dueDateMessage = (
<FormattedMessage
id="learning.outline.sequence-due-date-set"
defaultMessage="{description} due {assignmentDue}"
description="Used below an assignment title"
values={{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
}}
/>
);
const noDueDateMessage = (
<FormattedMessage
id="learning.outline.sequence-due-date-not-set"
defaultMessage="{description}"
description="Used below an assignment title"
values={{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
}}
/>
);
return (
<li>
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
<div className="row w-100 m-0">
<div className="col-auto p-0">
{complete ? (
<FontAwesomeIcon
icon={fasCheckCircle}
fixedWidth
className="float-left text-success mt-1"
aria-hidden="true"
title={intl.formatMessage(messages.completedAssignment)}
/>
) : (
<FontAwesomeIcon
icon={farCheckCircle}
fixedWidth
className="float-left text-gray-400 mt-1"
aria-hidden="true"
title={intl.formatMessage(messages.incompleteAssignment)}
/>
)}
</div>
<div className="col-10 p-0 ml-3 text-break">
<span className="align-middle">{displayTitle}</span>
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)}
</span>
<EffortEstimate className="ml-3 align-middle" block={sequence} />
</div>
</div>
{hideFromTOC && (
<div className="row w-100 my-2 mx-4 pl-3">
<span className="small d-flex">
<Icon className="mr-2" src={Block} data-testid="hide-from-toc-sequence-link-icon" />
<span data-testid="hide-from-toc-sequence-link-text">
{intl.formatMessage(messages.hiddenSequenceLink)}
</span>
</span>
</div>
)}
<div className="row w-100 m-0 ml-3 pl-3">
<small className="text-body pl-2">
{due ? dueDateMessage : noDueDateMessage}
</small>
</div>
</div>
</li>
);
};
SequenceLink.propTypes = {
id: PropTypes.string.isRequired,
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
first: PropTypes.bool.isRequired,
sequence: PropTypes.shape().isRequired,
};
export default injectIntl(SequenceLink);

View File

@@ -3,7 +3,8 @@ import PropTypes from 'prop-types';
import {
FormattedDate,
FormattedMessage,
useIntl,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { Alert, Button } from '@openedx/paragon';
import { useDispatch } from 'react-redux';
@@ -24,8 +25,7 @@ export const CERT_STATUS_TYPE = {
UNVERIFIED: 'unverified',
};
const CertificateStatusAlert = ({ payload }) => {
const intl = useIntl();
const CertificateStatusAlert = ({ intl, payload }) => {
const dispatch = useDispatch();
const {
certificateAvailableDate,
@@ -192,6 +192,7 @@ const CertificateStatusAlert = ({ payload }) => {
};
CertificateStatusAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
certificateAvailableDate: PropTypes.string,
certStatus: PropTypes.string,
@@ -209,4 +210,4 @@ CertificateStatusAlert.propTypes = {
}).isRequired,
};
export default CertificateStatusAlert;
export default injectIntl(CertificateStatusAlert);

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { Alert, Button, Hyperlink } from '@openedx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -14,8 +14,7 @@ import outlineMessages from '../../messages';
import useEnrollClickHandler from '../../../../alerts/enrollment-alert/clickHook';
import { useModel } from '../../../../generic/model-store';
const PrivateCourseAlert = ({ payload }) => {
const intl = useIntl();
const PrivateCourseAlert = ({ intl, payload }) => {
const {
anonymousUser,
canEnroll,
@@ -104,6 +103,7 @@ const PrivateCourseAlert = ({ payload }) => {
};
PrivateCourseAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
anonymousUser: PropTypes.bool,
canEnroll: PropTypes.bool,
@@ -111,4 +111,4 @@ PrivateCourseAlert.propTypes = {
}).isRequired,
};
export default PrivateCourseAlert;
export default injectIntl(PrivateCourseAlert);

View File

@@ -341,16 +341,6 @@ const messages = defineMessages({
defaultMessage: 'Onboarding Past Due',
description: 'Text that show when the deadline of proctortrack onboarding exam has passed, it appears on button that start the onboarding exam however for this case the button is disabled for obvious reason',
},
sequenceDueDate: {
id: 'learning.outline.sequence-due-date-set',
defaultMessage: '{description} due {assignmentDue}',
description: 'Used below an assignment title',
},
sequenceNoDueDate: {
id: 'learning.outline.sequence-due-date-not-set',
defaultMessage: '{description}',
description: 'Used below an assignment title',
},
});
export default messages;

View File

@@ -1,25 +0,0 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon } from '@openedx/paragon';
import { Block } from '@openedx/paragon/icons';
import messages from '../messages';
interface Props {}
const HiddenSequenceLink: React.FC<Props> = () => {
const intl = useIntl();
return (
<div className="row w-100 my-2 mx-4 pl-3">
<span className="small d-flex">
<Icon className="mr-2" src={Block} data-testid="hide-from-toc-sequence-link-icon" />
<span data-testid="hide-from-toc-sequence-link-text">
{intl.formatMessage(messages.hiddenSequenceLink)}
</span>
</span>
</div>
);
};
export default HiddenSequenceLink;

View File

@@ -1,94 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Collapsible, IconButton } from '@openedx/paragon';
import { Minus, Plus } from '@openedx/paragon/icons';
import { useModel } from '../../../generic/model-store';
import genericMessages from '../../../generic/messages';
import { useContextId } from '../../../data/hooks';
import messages from '../messages';
import SectionTitle from './SectionTitle';
import SequenceLink from './SequenceLink';
interface Props {
defaultOpen: boolean;
expand: boolean;
section: {
complete: boolean;
sequenceIds: string[];
title: string;
hideFromTOC: boolean;
};
}
const Section: React.FC<Props> = ({
defaultOpen,
expand,
section,
}) => {
const intl = useIntl();
const courseId = useContextId();
const {
complete,
sequenceIds,
title,
hideFromTOC,
} = section;
const {
courseBlocks: {
sequences,
},
} = useModel('outline', courseId);
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
setOpen(expand);
}, [expand]);
useEffect(() => {
setOpen(defaultOpen);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<li>
<Collapsible
className="mb-2"
styling="card-lg"
title={<SectionTitle {...{ complete, hideFromTOC, title }} />}
open={open}
onToggle={() => { setOpen(!open); }}
iconWhenClosed={(
<IconButton
alt={intl.formatMessage(messages.openSection)}
iconAs={Plus}
onClick={() => { setOpen(true); }}
size="sm"
/>
)}
iconWhenOpen={(
<IconButton
alt={intl.formatMessage(genericMessages.close)}
iconAs={Minus}
onClick={() => { setOpen(false); }}
size="sm"
/>
)}
>
<ol className="list-unstyled">
{sequenceIds.map((sequenceId, index) => (
<SequenceLink
key={sequenceId}
id={sequenceId}
sequence={sequences[sequenceId]}
first={index === 0}
/>
))}
</ol>
</Collapsible>
</li>
);
};
export default Section;

View File

@@ -1,59 +0,0 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon } from '@openedx/paragon';
import { CheckCircle, CheckCircleOutline, DisabledVisible } from '@openedx/paragon/icons';
import messages from '../messages';
interface Props {
complete: boolean;
hideFromTOC: boolean;
title: string;
}
const SectionTitle: React.FC<Props> = ({ complete, hideFromTOC, title }) => {
const intl = useIntl();
return (
<div className="d-flex row w-100 m-0">
<div className="col-auto p-0">
{complete ? (
<Icon
src={CheckCircle}
className="float-left mt-1 text-success"
aria-hidden="true"
svgAttrs={{ 'aria-label': intl.formatMessage(messages.completedSection) }}
size="sm"
/>
) : (
<Icon
src={CheckCircleOutline}
className="float-left mt-1 text-gray-400"
aria-hidden="true"
svgAttrs={{ 'aria-label': intl.formatMessage(messages.incompleteSection) }}
size="sm"
/>
)}
</div>
<div className="col-7 ml-3 p-0 font-weight-bold text-dark-500">
<span className="align-middle col-6">{title}</span>
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
</span>
</div>
{hideFromTOC && (
<div className="row">
{hideFromTOC && (
<span className="small d-flex align-content-end">
<Icon className="mr-2" src={DisabledVisible} data-testid="hide-from-toc-section-icon" />
<span data-testid="hide-from-toc-section-text">
{intl.formatMessage(messages.hiddenSection)}
</span>
</span>
)}
</div>
)}
</div>
);
};
export default SectionTitle;

View File

@@ -1,60 +0,0 @@
import React from 'react';
import { FormattedTime, useIntl } from '@edx/frontend-platform/i18n';
import { useModel } from '../../../generic/model-store';
import { useContextId } from '../../../data/hooks';
import messages from '../messages';
interface Props {
due: string;
id: string;
description: string;
}
const SequenceDueDate: React.FC<Props> = ({
due,
id,
description,
}) => {
const intl = useIntl();
const courseId = useContextId();
let dueDateMessage: string | React.ReactNode = intl.formatMessage(
messages.sequenceNoDueDate,
{ description: description || '' },
);
const {
userTimezone,
} = useModel('outline', courseId);
if (due) {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
dueDateMessage = intl.formatMessage(
messages.sequenceDueDate,
{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
},
);
}
return (
<div className="row w-100 m-0 ml-3 pl-3">
<small className="text-body pl-2">
{dueDateMessage}
</small>
</div>
);
};
export default SequenceDueDate;

View File

@@ -1,56 +0,0 @@
import React from 'react';
import classNames from 'classnames';
import SequenceDueDate from './SequenceDueDate';
import HiddenSequenceLink from './HiddenSequenceLink';
import SequenceTitle from './SequenceTitle';
interface Props {
id: string;
first: boolean;
sequence: {
complete: boolean;
description: string;
due: string;
showLink: boolean;
title: string;
hideFromTOC: boolean;
}
}
const SequenceLink: React.FC<Props> = ({
id,
first,
sequence,
}) => {
const {
complete,
description,
due,
showLink,
title,
hideFromTOC,
} = sequence;
return (
<li>
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
<SequenceTitle
{...{
complete,
showLink,
title,
sequence,
id,
}}
/>
{hideFromTOC && (
<HiddenSequenceLink />
)}
<SequenceDueDate {...{ due, id, description }} />
</div>
</li>
);
};
export default SequenceLink;

View File

@@ -1,63 +0,0 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Link } from 'react-router-dom';
import { Icon } from '@openedx/paragon';
import { CheckCircleOutline, CheckCircle } from '@openedx/paragon/icons';
import EffortEstimate from '../../../shared/effort-estimate';
import messages from '../messages';
import { useContextId } from '../../../data/hooks';
interface Props {
complete: boolean;
showLink: boolean;
title: string;
sequence: object;
id: string;
}
const SequenceTitle: React.FC<Props> = ({
complete,
showLink,
title,
sequence,
id,
}) => {
const intl = useIntl();
const courseId = useContextId();
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
const displayTitle = showLink ? coursewareUrl : title;
return (
<div className="row w-100 m-0">
<div className="col-auto p-0">
{complete ? (
<Icon
src={CheckCircle}
className="float-left text-success mt-1"
aria-hidden={complete}
svgAttrs={{ 'aria-label': intl.formatMessage(messages.completedAssignment) }}
size="sm"
/>
) : (
<Icon
src={CheckCircleOutline}
className="float-left text-gray-400 mt-1"
aria-hidden={complete}
svgAttrs={{ 'aria-label': intl.formatMessage(messages.incompleteAssignment) }}
size="sm"
/>
)}
</div>
<div className="col-10 p-0 ml-3 text-break">
<span className="align-middle">{displayTitle}</span>
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)}
</span>
<EffortEstimate className="ml-3 align-middle" block={sequence} />
</div>
</div>
);
};
export default SequenceTitle;

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