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
219 changed files with 10806 additions and 8939 deletions

3
.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=''

View File

@@ -4,7 +4,7 @@
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'
@@ -14,6 +14,7 @@ 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=''

View File

@@ -4,7 +4,7 @@
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'
@@ -14,6 +14,7 @@ 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'

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

@@ -9,35 +9,15 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: make validate.ci
- name: Archive code coverage results
uses: actions/upload-artifact@v4
with:
name: code-coverage-report-${{ matrix.node }}
# When we're only using Node 20, replace the line above with the following:
# name: code-coverage-report
path: coverage/*.*
coverage:
runs-on: ubuntu-latest
needs: tests
steps:
- uses: actions/checkout@v3
- name: Download code coverage results
uses: actions/download-artifact@v4
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- uses: actions/setup-node@v3
with:
name: code-coverage-report-20
# When we're only using Node 20, replace the line above with the following:
# name: code-coverage-report
node-version: ${{ env.NODE_VER }}
- run: make validate.ci
- name: Upload coverage
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v3
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}

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,110 +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 node v20.x.
``git clone https://github.com/openedx/frontend-app-learning.git``
The current version of the micro-frontend build scripts supports 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>`_.
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 = {
/*
@@ -133,14 +100,8 @@ The Learning MFE is similar to all the other Open edX MFEs. Read the Open
edX Developer Guide's section on
`MFE applications <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html>`_.
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>`_.
Environment Variables
=====================
======================
This MFE is configured via environment variables supplied at build time.
All micro-frontends have a shared set of required environment variables,
@@ -166,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
@@ -179,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
@@ -195,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
@@ -219,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

@@ -13,6 +13,6 @@ metadata:
annotations:
openedx.org/arch-interest-groups: ""
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,

12024
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,17 +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",
"postinstall": "patch-package",
"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",
"types": "tsc --noEmit"
"test": "fedx-scripts jest --coverage --passWithNoTests"
},
"author": "edX",
"license": "AGPL-3.0",
@@ -33,34 +29,31 @@
"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.2.0",
"@edx/frontend-component-header": "^5.8.0",
"@edx/frontend-lib-learning-assistant": "^2.2.4",
"@edx/frontend-lib-special-exams": "^3.1.3",
"@edx/frontend-platform": "^8.0.0",
"@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": "3.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.1.2",
"@openedx/frontend-plugin-framework": "^1.2.1",
"@openedx/frontend-slot-footer": "^1.0.2",
"@openedx/paragon": "^22.3.0",
"@openedx/frontend-plugin-framework": "^1.0.2",
"@openedx/paragon": "^22.1.1",
"@popperjs/core": "2.11.8",
"@reduxjs/toolkit": "1.8.1",
"buffer": "^6.0.3",
"classnames": "2.5.1",
"copy-webpack-plugin": "^11.0.0",
"husky": "7.0.4",
"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",
"patch-package": "^8.0.0",
"postcss-loader": "^8.1.1",
"prop-types": "15.8.1",
"query-string": "^7.1.3",
"react": "17.0.2",
@@ -71,34 +64,33 @@
"react-router-dom": "6.15.0",
"react-share": "4.4.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": "5.17.0",
"@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": "2.0.0",
"bundlewatch": "^0.4.0",
"eslint-import-resolver-webpack": "^0.13.9",
"jest": "^29.7.0",
"jest-console-group-reporter": "^1.1.1",
"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": "1300kB"
}
],
"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"
}
}

View File

@@ -1,36 +0,0 @@
diff --git a/node_modules/@openedx/frontend-build/config/webpack.prod.config.js b/node_modules/@openedx/frontend-build/config/webpack.prod.config.js
index 2879dd9..9efd0fc 100644
--- a/node_modules/@openedx/frontend-build/config/webpack.prod.config.js
+++ b/node_modules/@openedx/frontend-build/config/webpack.prod.config.js
@@ -12,6 +12,7 @@ const NewRelicSourceMapPlugin = require('@edx/new-relic-source-map-webpack-plugi
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const path = require('path');
+const fs = require('fs');
const PostCssAutoprefixerPlugin = require('autoprefixer');
const PostCssRTLCSS = require('postcss-rtlcss');
const PostCssCustomMediaCSS = require('postcss-custom-media');
@@ -23,6 +24,23 @@ const HtmlWebpackNewRelicPlugin = require('../lib/plugins/html-webpack-new-relic
const commonConfig = require('./webpack.common.config');
const presets = require('../lib/presets');
+/**
+ * This condition confirms whether the configuration for the MFE has switched to a JS-based configuration
+ * as previously implemented in frontend-build and frontend-platform. If the environment variable JS_CONFIG_FILEPATH
+ * exists, then an env.config.js(x) file will be copied from the location referenced by the environment variable to the
+ * root directory. Its env variables can be accessed with getConfig().
+ *
+ * https://github.com/openedx/frontend-build/blob/master/docs/0002-js-environment-config.md
+ * https://github.com/openedx/frontend-platform/blob/master/docs/decisions/0007-javascript-file-configuration.rst
+ */
+
+const envConfigPath = process.env.JS_CONFIG_FILEPATH;
+
+if (envConfigPath) {
+ const envConfigFilename = envConfigPath.slice(envConfigPath.indexOf('env.config'));
+ fs.copyFileSync(envConfigPath, envConfigFilename);
+}
+
// Add process env vars. Currently used only for setting the PUBLIC_PATH.
dotenv.config({
path: path.resolve(process.cwd(), '.env'),

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

@@ -16,16 +16,15 @@ export const DECODE_ROUTES = {
],
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',
@@ -33,26 +32,4 @@ export const REDIRECT_MODES = {
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 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,7 +1,7 @@
import React, { useEffect } from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { ManageSearch } from '@openedx/paragon/icons';
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';
@@ -25,17 +25,16 @@ 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>
);

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

@@ -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

@@ -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,41 +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,
"canShowUpgradeSock": false,
"certData": {
"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,
@@ -536,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.",
},
@@ -569,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": "$",
@@ -587,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": "",
@@ -624,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": "",
@@ -655,7 +645,7 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
},
"timeIsOver": false,
},
"tours": {
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
@@ -666,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",
@@ -677,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",
@@ -691,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,
@@ -707,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",
@@ -749,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": "$",
@@ -759,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,
@@ -779,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",
@@ -790,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",
@@ -809,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,
},
@@ -829,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,
},
@@ -854,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,
@@ -863,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": "",
@@ -900,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": "",
@@ -931,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 };

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

@@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
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';
@@ -38,7 +38,7 @@ const GoalUnsubscribe = ({ intl }) => {
return (
<>
<HeaderSlot showUserDropdown={false} />
<Header showUserDropdown={false} />
<main id="main-content" className="container my-5 text-center">
{isLoading && (
<PageLoading srMessage={`${intl.formatMessage(messages.loading)}`} />

View File

@@ -5,7 +5,6 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import { AlertList } from '../../generic/user-messages';
import CourseDates from './widgets/CourseDates';
@@ -195,27 +194,19 @@ const OutlineTab = ({ intl }) => {
/>
)}
<CourseTools />
<PluginSlot
id="outline_tab_notifications_slot"
pluginProps={{
courseId,
model: 'outline',
}}
>
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="course_home"
userTimezone={userTimezone}
shouldDisplayBorder
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
/>
</PluginSlot>
<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>

View File

@@ -132,16 +132,6 @@ 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('outline_tab_notifications_slot')).toBeInTheDocument();
});
it('handles expand/collapse all button click', async () => {
await fetchAndRender();
// Button renders as "Expand All"

View File

@@ -1,3 +1,4 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
@@ -95,7 +96,7 @@ const SequenceLink = ({
icon={fasCheckCircle}
fixedWidth
className="float-left text-success mt-1"
aria-hidden={complete}
aria-hidden="true"
title={intl.formatMessage(messages.completedAssignment)}
/>
) : (
@@ -103,7 +104,7 @@ const SequenceLink = ({
icon={farCheckCircle}
fixedWidth
className="float-left text-gray-400 mt-1"
aria-hidden={complete}
aria-hidden="true"
title={intl.formatMessage(messages.incompleteAssignment)}
/>
)}
@@ -117,14 +118,14 @@ const SequenceLink = ({
</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>
<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>
</div>
</span>
</div>
)}
<div className="row w-100 m-0 ml-3 pl-3">
<small className="text-body pl-2">

View File

@@ -19,10 +19,6 @@ const CertificateStatus = ({ intl }) => {
courseId,
} = useSelector(state => state.courseHome);
const {
entranceExamData,
} = useModel('coursewareMeta', courseId);
const {
isEnrolled,
org,
@@ -46,8 +42,6 @@ const CertificateStatus = ({ intl }) => {
certificateAvailableDate,
} = certificateData || {};
const entranceExamPassed = entranceExamData?.entranceExamPassed ?? null;
const mode = getCourseExitMode(
certificateData,
hasScheduledContent,
@@ -55,7 +49,6 @@ const CertificateStatus = ({ intl }) => {
userHasPassingGrade,
null, // CourseExitPageIsActive
canViewCertificate,
entranceExamPassed,
);
const eventProperties = {

View File

@@ -36,17 +36,17 @@
}
#passing-grade-tooltip {
background: $success-500;
.arrow::after {
border-top-color: $success-500;
}
background: $success-500;
}
#non-passing-grade-tooltip {
background: $accent-b;
.arrow::after {
border-top-color: $accent-b;
}
background: $accent-b;
}

View File

@@ -16,27 +16,23 @@ const CourseTabsNavigation = ({
return (
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
<div className="container-xl">
<div className="nav-bar">
<div className="nav-menu">
<Tabs
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)}
<Tabs
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)}
>
{tabs.map(({ url, title, slug }) => (
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
href={url}
>
{tabs.map(({ url, title, slug }) => (
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
href={url}
>
{title}
</a>
))}
</Tabs>
</div>
<div className="search-toggle">
<CoursewareSearchToggle />
</div>
</div>
{title}
</a>
))}
</Tabs>
</div>
<div className="course-tabs-navigation__search-toggle">
<CoursewareSearchToggle />
</div>
{show && <CoursewareSearch />}
</div>

View File

@@ -16,23 +16,9 @@
}
}
.nav-bar {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
&__search-toggle {
position: absolute;
top: .05rem;
right: 0;
}
.nav-menu {
flex: 1;
}
.search-toggle {
flex-grow: 0;
text-align: right;
white-space: nowrap;
margin-bottom: 10px;
}
}

View File

@@ -1,23 +1,24 @@
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { breakpoints, useWindowSize } from '@openedx/paragon';
import { AlertList } from '@src/generic/user-messages';
import { useModel } from '@src/generic/model-store';
import { getCoursewareOutlineSidebarSettings } from '../data/selectors';
import { Trigger as CourseOutlineTrigger } from './sidebar/sidebars/course-outline';
import { AlertList } from '../../generic/user-messages';
import Sequence from './sequence';
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
import Chat from './chat/Chat';
import ContentTools from './content-tools';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import SidebarProvider from './sidebar/SidebarContextProvider';
import SidebarTriggers from './sidebar/SidebarTriggers';
import NewSidebarProvider from './new-sidebar/SidebarContextProvider';
import NewSidebarTriggers from './new-sidebar/SidebarTriggers';
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import ContentTools from './content-tools';
import Sequence from './sequence';
import { useModel } from '../../generic/model-store';
const Course = ({
courseId,
@@ -32,12 +33,11 @@ const Course = ({
const {
celebrations,
isStaff,
isNewDiscussionSidebarViewEnabled,
} = useModel('courseHomeMeta', courseId);
const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sequence ? sequence.sectionId : null);
const { enableNavigationSidebar } = useSelector(getCoursewareOutlineSidebarSettings);
const navigationDisabled = enableNavigationSidebar || (sequence?.navigationDisabled ?? false);
const enableNewSidebar = getConfig().ENABLE_NEW_SIDEBAR;
const navigationDisabled = sequence?.navigationDisabled ?? false;
const pageTitleBreadCrumbs = [
sequence,
@@ -54,6 +54,7 @@ const Course = ({
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal,
);
const shouldDisplayTriggers = windowWidth >= breakpoints.small.minWidth;
const shouldDisplayChat = windowWidth >= breakpoints.medium.minWidth;
const daysPerWeek = course?.courseGoals?.selectedGoal?.daysPerWeek;
@@ -68,14 +69,14 @@ const Course = ({
));
}, [sequenceId]);
const SidebarProviderComponent = isNewDiscussionSidebarViewEnabled ? NewSidebarProvider : SidebarProvider;
const SidebarProviderComponent = enableNewSidebar === 'true' ? NewSidebarProvider : SidebarProvider;
return (
<SidebarProviderComponent courseId={courseId} unitId={unitId}>
<Helmet>
<title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title>
</Helmet>
<div className="position-relative d-flex align-items-xl-center mb-4 mt-1 flex-column flex-xl-row">
<div className="position-relative d-flex align-items-center mb-4 mt-1">
{navigationDisabled || (
<>
<CourseBreadcrumbs
@@ -99,10 +100,11 @@ const Course = ({
/>
</>
)}
<div className="w-100 d-flex align-items-center">
<CourseOutlineTrigger isMobileView />
{isNewDiscussionSidebarViewEnabled ? <NewSidebarTriggers /> : <SidebarTriggers /> }
</div>
{shouldDisplayTriggers && (
<>
{enableNewSidebar === 'true' ? <NewSidebarTriggers /> : <SidebarTriggers /> }
</>
)}
</div>
<AlertList topic="sequence" />

View File

@@ -5,7 +5,7 @@ import { Factory } from 'rosie';
import { breakpoints } from '@openedx/paragon';
import {
fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
act, fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
} from '../../setupTest';
import * as celebrationUtils from './celebration/utils';
import { handleNextSectionCelebration } from './celebration';
@@ -32,6 +32,8 @@ celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration;
describe('Course', () => {
let store;
let getItemSpy;
let setItemSpy;
const mockData = {
nextSequenceHandler: () => {},
previousSequenceHandler: () => {},
@@ -50,27 +52,30 @@ describe('Course', () => {
global.innerWidth = breakpoints.extraLarge.minWidth;
});
afterAll(() => {
getItemSpy.mockRestore();
setItemSpy.mockRestore();
});
it('loads learning sequence', async () => {
render(<Course {...mockData} />, { wrapWithRouter: true });
expect(screen.queryByRole('navigation', { name: 'breadcrumb' })).not.toBeInTheDocument();
waitFor(() => {
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Learn About Verified Certificates' })).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Learn About Verified Certificates' })).not.toBeInTheDocument();
loadUnit();
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
const { models } = store.getState();
const sequence = models.sequences[mockData.sequenceId];
const section = models.sections[sequence.sectionId];
const course = models.coursewareMeta[mockData.courseId];
expect(document.title).toMatch(
`${sequence.title} | ${section.title} | ${course.title} | edX`,
);
});
const { models } = store.getState();
const sequence = models.sequences[mockData.sequenceId];
const section = models.sections[sequence.sectionId];
const course = models.coursewareMeta[mockData.courseId];
expect(document.title).toMatch(
`${sequence.title} | ${section.title} | ${course.title} | edX`,
);
});
it('removes breadcrumbs when navigation is disabled', async () => {
@@ -109,11 +114,9 @@ describe('Course', () => {
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
waitFor(() => {
const firstSectionCelebrationModal = screen.getByRole('dialog');
expect(firstSectionCelebrationModal).toBeInTheDocument();
expect(getByRole(firstSectionCelebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
});
const firstSectionCelebrationModal = screen.getByRole('dialog');
expect(firstSectionCelebrationModal).toBeInTheDocument();
expect(getByRole(firstSectionCelebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
});
it('displays weekly goal celebration modal', async () => {
@@ -129,42 +132,37 @@ describe('Course', () => {
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
waitFor(() => {
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
expect(getByRole(weeklyGoalCelebrationModal, 'heading', { name: 'You met your goal!' })).toBeInTheDocument();
});
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
expect(getByRole(weeklyGoalCelebrationModal, 'heading', { name: 'You met your goal!' })).toBeInTheDocument();
});
it('displays notification trigger and toggles active class on click', async () => {
render(<Course {...mockData} />, { wrapWithRouter: true });
waitFor(() => {
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
expect(notificationTrigger).toBeInTheDocument();
expect(notificationTrigger.parentNode).not.toHaveClass('sidebar-active', { exact: true });
fireEvent.click(notificationTrigger);
expect(notificationTrigger.parentNode).toHaveClass('sidebar-active');
});
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
expect(notificationTrigger).toBeInTheDocument();
expect(notificationTrigger.parentNode).not.toHaveClass('mt-3', { exact: true });
fireEvent.click(notificationTrigger);
expect(notificationTrigger.parentNode).toHaveClass('mt-3');
});
it('handles click to open/close discussions sidebar', async () => {
await setupDiscussionSidebar();
const discussionsTrigger = await screen.getByRole('button', { name: /Show discussions tray/i });
const discussionsSideBar = await waitFor(() => screen.findByTestId('sidebar-DISCUSSIONS'));
waitFor(() => {
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
expect(discussionsSideBar).not.toHaveClass('d-none');
const discussionsTrigger = screen.getByRole('button', { name: /Show discussions tray/i });
expect(discussionsTrigger).toBeInTheDocument();
await act(async () => {
fireEvent.click(discussionsTrigger);
expect(screen.queryByTestId('sidebar-DISCUSSIONS')).not.toBeInTheDocument();
fireEvent.click(discussionsTrigger);
expect(screen.queryByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
});
await expect(discussionsSideBar).toHaveClass('d-none');
await act(async () => {
fireEvent.click(discussionsTrigger);
});
await expect(discussionsSideBar).not.toHaveClass('d-none');
});
it('displays discussions sidebar when unit changes', async () => {
@@ -183,9 +181,9 @@ describe('Course', () => {
const { rerender } = render(<Course {...testData} />, { store: testStore });
loadUnit();
waitFor(() => {
expect(screen.findByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
expect(screen.findByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
await waitFor(() => {
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
});
rerender(null);
@@ -193,13 +191,10 @@ describe('Course', () => {
it('handles click to open/close notification tray', async () => {
await setupDiscussionSidebar();
waitFor(() => {
const notificationShowButton = screen.findByRole('button', { name: /Show notification tray/i });
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
fireEvent.click(notificationShowButton);
expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument();
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
});
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
fireEvent.click(notificationShowButton);
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
});
it('renders course breadcrumbs as expected', async () => {
@@ -209,9 +204,7 @@ describe('Course', () => {
{ type: 'vertical' },
{ courseId: courseMetadata.id },
));
const testStore = await initializeTestStore({
courseMetadata, unitBlocks, enableNavigationSidebar: { enable_navigation_sidebar: false },
}, false);
const testStore = await initializeTestStore({ courseMetadata, unitBlocks }, false);
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
@@ -223,14 +216,10 @@ describe('Course', () => {
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => {
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
});
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
// expect the section and sequence "titles" to be loaded in as breadcrumb labels.
waitFor(() => {
expect(screen.findByText(Object.values(models.sections)[0].title)).toBeInTheDocument();
expect(screen.findByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
});
expect(screen.getByText(Object.values(models.sections)[0].title)).toBeInTheDocument();
expect(screen.getByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
});
it('passes handlers to the sequence', async () => {
@@ -259,16 +248,14 @@ describe('Course', () => {
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
loadUnit();
waitFor(() => {
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
screen.getAllByRole('link', { name: /previous/i }).forEach(link => fireEvent.click(link));
screen.getAllByRole('link', { name: /next/i }).forEach(link => fireEvent.click(link));
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
screen.getAllByRole('link', { name: /previous/i }).forEach(link => fireEvent.click(link));
screen.getAllByRole('link', { name: /next/i }).forEach(link => fireEvent.click(link));
// We are in the middle of the sequence, so no
expect(previousSequenceHandler).not.toHaveBeenCalled();
expect(nextSequenceHandler).not.toHaveBeenCalled();
expect(unitNavigationHandler).toHaveBeenCalledTimes(4);
});
// We are in the middle of the sequence, so no
expect(previousSequenceHandler).not.toHaveBeenCalled();
expect(nextSequenceHandler).not.toHaveBeenCalled();
expect(unitNavigationHandler).toHaveBeenCalledTimes(4);
});
describe('Sequence alerts display', () => {
@@ -288,7 +275,7 @@ describe('Course', () => {
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
waitFor(() => expect(screen.findByText('Some random banner text to display.')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
});
it('renders Entrance Exam alert with passing score', async () => {
@@ -322,7 +309,7 @@ describe('Course', () => {
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
waitFor(() => expect(screen.findByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
});
it('renders Entrance Exam alert with non-passing score', async () => {
@@ -356,7 +343,7 @@ describe('Course', () => {
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
waitFor(() => expect(screen.findByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
});
});
@@ -375,7 +362,7 @@ describe('Course', () => {
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const chat = screen.queryByTestId(mockChatTestId);
waitFor(() => expect(chat).toBeInTheDocument());
await expect(chat).toBeInTheDocument();
});
it('does not display chat when screen is too narrow (mobile)', async () => {

View File

@@ -154,7 +154,7 @@ const CourseBreadcrumbs = ({
}, [courseStatus, sequenceStatus, allSequencesInSections]);
return (
<nav aria-label="breadcrumb" className="d-inline-block col-sm-10 mb-3">
<nav aria-label="breadcrumb" className="d-inline-block col-sm-10">
<ol className="list-unstyled d-flex flex-nowrap align-items-center m-0">
<li className="list-unstyled col-auto m-0 p-0">
<Link

View File

@@ -5,7 +5,6 @@ import PropTypes from 'prop-types';
import { Xpert } from '@edx/frontend-lib-learning-assistant';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { VERIFIED_MODES } from '@src/constants';
import { useModel } from '../../../generic/model-store';
const Chat = ({
@@ -21,10 +20,21 @@ const Chat = ({
} = useSelector(state => state.specialExams);
const course = useModel('coursewareMeta', courseId);
const VERIFIED_MODES = [
'professional',
'verified',
'no-id-professional',
'credit',
'masters',
'executive-education',
'paid-executive-education',
'paid-bootcamp',
];
const hasVerifiedEnrollment = (
enrollmentMode !== null
&& enrollmentMode !== undefined
&& VERIFIED_MODES.includes(enrollmentMode)
&& [...VERIFIED_MODES].some(mode => mode === enrollmentMode)
);
const validDates = () => {

View File

@@ -12,9 +12,9 @@ import {
} from '@openedx/paragon';
import PropTypes from 'prop-types';
import truncate from 'truncate-html';
import { FAILED, LOADED, LOADING } from '@src/constants';
import { useModel } from '../../../generic/model-store';
import fetchCourseRecommendations from './data/thunks';
import { FAILED, LOADED, LOADING } from './data/slice';
import CatalogSuggestion from './CatalogSuggestion';
import PageLoading from '../../../generic/PageLoading';
import { logClick } from './utils';

View File

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

View File

@@ -9,7 +9,6 @@ const COURSE_EXIT_MODES = {
celebration: 1,
nonPassing: 2,
inProgress: 3,
entranceExamFail: 4,
};
// These are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
@@ -33,14 +32,9 @@ function getCourseExitMode(
userHasPassingGrade,
courseExitPageIsActive = null,
canImmediatelyViewCertificate = false,
entranceExamPassed = null,
) {
const authenticatedUser = getAuthenticatedUser();
if (entranceExamPassed === false) {
return COURSE_EXIT_MODES.entranceExamFail;
}
if (courseExitPageIsActive === false || !authenticatedUser || !isEnrolled) {
return COURSE_EXIT_MODES.disabled;
}
@@ -79,7 +73,6 @@ function GetCourseExitNavigation(courseId, intl) {
isEnrolled,
userHasPassingGrade,
courseExitPageIsActive,
entranceExamData: { entranceExamPassed },
} = useModel('coursewareMeta', courseId);
const { canViewCertificate } = useModel('courseHomeMeta', courseId);
const exitMode = getCourseExitMode(
@@ -89,15 +82,8 @@ function GetCourseExitNavigation(courseId, intl) {
userHasPassingGrade,
courseExitPageIsActive,
canViewCertificate,
entranceExamPassed,
);
/** exitActive is used to enable/disable the exit i.e. next buttons.
COURSE_EXIT_MODES denote the current status of the course.
Available COURSE_EXIT_MODES: disabled, celebration, nonPassing, inProgress, entranceExamFail
If the user fails the entrance exam,
access to further course sections should not be allowed i.e. disable the next buttons. */
const exitActive = ((exitMode !== COURSE_EXIT_MODES.disabled) && (exitMode !== COURSE_EXIT_MODES.entranceExamFail));
const exitActive = exitMode !== COURSE_EXIT_MODES.disabled;
let exitText;
switch (exitMode) {

View File

@@ -6,8 +6,7 @@ import { SIDEBARS } from './sidebars';
const Sidebar = () => {
const { currentSidebar, isDiscussionbarAvailable, isNotificationbarAvailable } = useContext(SidebarContext);
if (currentSidebar === null || (!isDiscussionbarAvailable && !isNotificationbarAvailable)
|| !SIDEBARS[currentSidebar]) { return null; }
if (currentSidebar === null || (!isDiscussionbarAvailable && !isNotificationbarAvailable)) { return null; }
const SidebarToRender = SIDEBARS[currentSidebar].Sidebar;
return (

View File

@@ -0,0 +1,5 @@
import React from 'react';
const SidebarContext = React.createContext({});
export default SidebarContext;

View File

@@ -1,37 +0,0 @@
import React from 'react';
import type { WIDGETS } from '@src/constants';
import type { SIDEBARS } from './sidebars';
export type SidebarId = keyof typeof SIDEBARS;
export type WidgetId = keyof typeof WIDGETS;
export type UpgradeNotificationState = (
| 'accessLastHour'
| 'accessHoursLeft'
| 'accessDaysLeft'
| 'FPDdaysLeft'
| 'FPDLastHour'
| 'accessDateView'
| 'PastExpirationDate'
);
export interface SidebarContextData {
toggleSidebar: (sidebarId?: SidebarId | null, widgetId?: WidgetId | null) => void;
onNotificationSeen: () => void;
setNotificationStatus: React.Dispatch<'active' | 'inactive'>;
currentSidebar: SidebarId | null;
notificationStatus: 'active' | 'inactive';
upgradeNotificationCurrentState: UpgradeNotificationState;
setUpgradeNotificationCurrentState: React.Dispatch<UpgradeNotificationState>;
shouldDisplaySidebarOpen: boolean;
shouldDisplayFullScreen: boolean;
courseId: string;
unitId: string;
hideDiscussionbar: boolean;
hideNotificationbar: boolean;
isNotificationbarAvailable: boolean;
isDiscussionbarAvailable: boolean;
}
const SidebarContext = React.createContext<SidebarContextData>({} as SidebarContextData);
export default SidebarContext;

View File

@@ -1,6 +1,7 @@
import React, {
useCallback, useEffect, useMemo, useState,
} from 'react';
import PropTypes from 'prop-types';
import isEmpty from 'lodash/isEmpty';
@@ -8,35 +9,20 @@ import { breakpoints, useWindowSize } from '@openedx/paragon';
import { getLocalStorage, setLocalStorage } from '../../../data/localStorage';
import { useModel } from '../../../generic/model-store';
import { WIDGETS } from '../../../constants';
import WIDGETS from './constants';
import SidebarContext from './SidebarContext';
import { SIDEBARS } from './sidebars';
interface Props {
courseId: string;
unitId: string;
children?: React.ReactNode;
}
const SidebarProvider: React.FC<Props> = ({
const SidebarProvider = ({
courseId,
unitId,
children,
}) => {
const { verifiedMode } = useModel('courseHomeMeta', courseId);
const topic = useModel('discussionTopics', unitId);
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
const query = new URLSearchParams(window.location.search);
const isInitiallySidebarOpen = shouldDisplaySidebarOpen || query.get('sidebar') === 'true';
const sidebarKey = `sidebar.${courseId}`;
let initialSidebar = shouldDisplayFullScreen && sidebarKey in localStorage ? getLocalStorage(sidebarKey)
: SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID;
if (!shouldDisplayFullScreen && isInitiallySidebarOpen) {
initialSidebar = SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID;
}
const initialSidebar = (shouldDisplaySidebarOpen || query.get('sidebar') === 'true')
? SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID : null;
const [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
const [hideDiscussionbar, setHideDiscussionbar] = useState(false);
@@ -44,6 +30,8 @@ const SidebarProvider: React.FC<Props> = ({
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(
getLocalStorage(`upgradeNotificationCurrentState.${courseId}`),
);
const topic = useModel('discussionTopics', unitId);
const { verifiedMode } = useModel('courseHomeMeta', courseId);
const isDiscussionbarAvailable = (topic?.id && topic?.enabledInContext) || false;
const isNotificationbarAvailable = !isEmpty(verifiedMode);
@@ -55,9 +43,7 @@ const SidebarProvider: React.FC<Props> = ({
useEffect(() => {
setHideDiscussionbar(!isDiscussionbarAvailable);
setHideNotificationbar(!isNotificationbarAvailable);
if (initialSidebar && currentSidebar !== initialSidebar) {
setCurrentSidebar(SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID);
}
setCurrentSidebar(SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID);
}, [unitId, topic]);
useEffect(() => {
@@ -66,39 +52,16 @@ const SidebarProvider: React.FC<Props> = ({
}
}, [hideDiscussionbar, hideNotificationbar]);
useEffect(() => {
setCurrentSidebar(initialSidebar);
}, [shouldDisplaySidebarOpen, initialSidebar]);
const handleWidgetToggle = useCallback((widgetId, sidebarId) => {
setHideDiscussionbar(prevWidgetId => (widgetId === WIDGETS.DISCUSSIONS ? true : prevWidgetId));
setHideNotificationbar(prevWidgetId => (widgetId === WIDGETS.NOTIFICATIONS ? true : prevWidgetId));
setLocalStorage(sidebarKey, sidebarId);
}, []);
const handleSidebarToggle = useCallback((sidebarId) => {
setCurrentSidebar(prevSidebar => (sidebarId === prevSidebar ? null : sidebarId));
setHideDiscussionbar(!isDiscussionbarAvailable);
setHideNotificationbar(!isNotificationbarAvailable);
setLocalStorage(sidebarKey, sidebarId === currentSidebar ? null : sidebarId);
}, [currentSidebar, isDiscussionbarAvailable, isNotificationbarAvailable]);
const clearSidebarKeyIfWidgetsUnavailable = useCallback((widgetId) => {
if (((!isNotificationbarAvailable || hideNotificationbar) && widgetId === WIDGETS.DISCUSSIONS)
|| ((!isDiscussionbarAvailable || hideDiscussionbar) && widgetId === WIDGETS.NOTIFICATIONS)) {
setLocalStorage(sidebarKey, null);
}
}, [isDiscussionbarAvailable, isNotificationbarAvailable, hideDiscussionbar, hideNotificationbar]);
const toggleSidebar = useCallback((sidebarId = null, widgetId = null) => {
if (widgetId) {
handleWidgetToggle(widgetId, sidebarId);
setHideDiscussionbar(prevWidgetId => (widgetId === WIDGETS.DISCUSSIONS ? true : prevWidgetId));
setHideNotificationbar(prevWidgetId => (widgetId === WIDGETS.NOTIFICATIONS ? true : prevWidgetId));
} else {
handleSidebarToggle(sidebarId);
setCurrentSidebar(prevSidebar => (sidebarId === prevSidebar ? null : sidebarId));
setHideDiscussionbar(!isDiscussionbarAvailable);
setHideNotificationbar(!isNotificationbarAvailable);
}
clearSidebarKeyIfWidgetsUnavailable(widgetId);
}, [handleWidgetToggle, handleSidebarToggle, clearSidebarKeyIfWidgetsUnavailable]);
}, [isDiscussionbarAvailable, isNotificationbarAvailable]);
const contextValue = useMemo(() => ({
toggleSidebar,
@@ -127,4 +90,14 @@ const SidebarProvider: React.FC<Props> = ({
);
};
SidebarProvider.propTypes = {
courseId: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
children: PropTypes.node,
};
SidebarProvider.defaultProps = {
children: null,
};
export default SidebarProvider;

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useContext } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
@@ -7,32 +8,20 @@ import { Icon, IconButton } from '@openedx/paragon';
import { ArrowBackIos, Close } from '@openedx/paragon/icons';
import { useEventListener } from '../../../../generic/hooks';
import { WIDGETS } from '../../../../constants';
import WIDGETS from '../constants';
import messages from '../messages';
import SidebarContext, { type SidebarId } from '../SidebarContext';
import SidebarContext from '../SidebarContext';
interface Props {
title?: string;
ariaLabel: string;
sidebarId: SidebarId;
className?: string;
children: React.ReactNode;
showTitleBar?: boolean;
width?: string;
allowFullHeight?: boolean;
showBorder?: boolean;
}
const SidebarBase: React.FC<Props> = ({
title = '',
const SidebarBase = ({
title,
ariaLabel,
sidebarId,
className,
children,
showTitleBar = true,
width = '45rem',
allowFullHeight = false,
showBorder = true,
showTitleBar,
width,
allowFullHeight,
showBorder,
}) => {
const intl = useIntl();
const {
@@ -52,7 +41,7 @@ const SidebarBase: React.FC<Props> = ({
return (
<section
className={classNames('ml-0 ml-lg-4 h-auto align-top zindex-0', {
className={classNames('ml-0 ml-lg-4 h-auto align-top', {
'min-vh-100': !shouldDisplayFullScreen && allowFullHeight,
'bg-white m-0 border-0 fixed-top vh-100 rounded-0': shouldDisplayFullScreen,
'd-none': currentSidebar !== sidebarId,
@@ -69,7 +58,8 @@ const SidebarBase: React.FC<Props> = ({
onClick={() => toggleSidebar(null)}
onKeyDown={() => toggleSidebar(null)}
role="button"
tabIndex={0}
tabIndex="0"
alt={intl.formatMessage(messages.responsiveCloseSidebarTray)}
>
<Icon src={ArrowBackIos} />
<span className="font-weight-bold m-2 d-inline-block">
@@ -100,4 +90,25 @@ const SidebarBase: React.FC<Props> = ({
);
};
SidebarBase.propTypes = {
title: PropTypes.string,
ariaLabel: PropTypes.string.isRequired,
sidebarId: PropTypes.string.isRequired,
className: PropTypes.string,
children: PropTypes.element.isRequired,
showTitleBar: PropTypes.bool,
width: PropTypes.string,
allowFullHeight: PropTypes.bool,
showBorder: PropTypes.bool,
};
SidebarBase.defaultProps = {
title: '',
width: '50rem',
allowFullHeight: false,
showTitleBar: true,
className: '',
showBorder: true,
};
export default SidebarBase;

View File

@@ -0,0 +1,6 @@
const WIDGETS = {
DISCUSSIONS: 'DISCUSSIONS',
NOTIFICATIONS: 'NOTIFICATIONS',
};
export default WIDGETS;

View File

@@ -1,3 +1,5 @@
import * as React from 'react';
const RightSidebarFilled = (props) => (
<svg
width={24}

View File

@@ -1,3 +1,5 @@
import * as React from 'react';
const RightSidebarOutlined = (props) => (
<svg
width={24}

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