Compare commits

..

1 Commits

Author SHA1 Message Date
Brian Smith
4edaa1a2a4 feat: update to header with new FPF pluginProps (#1525) 2024-11-22 15:21:03 -05:00
386 changed files with 13531 additions and 16705 deletions

6
.env
View File

@@ -12,12 +12,10 @@ CREDIT_HELP_LINK_URL=''
CSRF_TOKEN_API_PATH=''
DISCOVERY_API_BASE_URL=''
DISCUSSIONS_MFE_BASE_URL=''
DISCOUNT_CODE_INFO_URL=''
ECOMMERCE_BASE_URL=''
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
ENTERPRISE_LEARNER_PORTAL_URL=''
EXAMS_BASE_URL=''
FAVICON_URL=''
IGNORED_ERROR_REGEX=''
@@ -50,7 +48,3 @@ TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
# Fallback in local style files
PARAGON_THEME_URLS={}
FEATURE_ENABLE_CHAT_V2_ENDPOINT=''

View File

@@ -8,16 +8,14 @@ APP_ID='learning'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'
CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-college-credit-or-credit-hours-for-my-course'
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
DISCOUNT_CODE_INFO_URL=''
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:8734'
EXAMS_BASE_URL=''
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=''
@@ -52,7 +50,3 @@ SESSION_COOKIE_DOMAIN='localhost'
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_KEY=''
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
# Fallback in local style files
PARAGON_THEME_URLS={}
FEATURE_ENABLE_CHAT_V2_ENDPOINT='false'

View File

@@ -8,16 +8,14 @@ APP_ID='learning'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'
CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-college-credit-or-credit-hours-for-my-course'
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
DISCOUNT_CODE_INFO_URL=''
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:8734'
EXAMS_BASE_URL='http://localhost:18740'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=''
@@ -49,6 +47,3 @@ TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:Enterprise'
FEATURE_ENABLE_CHAT_V2_ENDPOINT='false'

View File

@@ -1,18 +0,0 @@
# Run the workflow that adds new tickets that are labelled "release testing"
# to the org-wide BTR project board
name: Add release testing issues to the BTR project board
on:
issues:
types: [labeled]
# This workflow is triggered when an issue is labeled with 'release testing'.
# It adds the issue to the BTR project and applies the 'needs triage' label
# if it doesn't already have it.
jobs:
handle-release-testing:
uses: openedx/.github/.github/workflows/add-issue-to-btr-project.yml@master
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}

View File

@@ -9,28 +9,35 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20]
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
node-version: ${{ matrix.node }}
- run: make validate.ci
- name: Archive code coverage results
uses: actions/upload-artifact@v4
with:
name: code-coverage-report
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@v5
- uses: actions/checkout@v3
- name: Download code coverage results
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
pattern: code-coverage-report
name: code-coverage-report-20
# When we're only using Node 20, replace the line above with the following:
# name: code-coverage-report
- name: Upload coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v4
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}

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

@@ -0,0 +1 @@
*

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

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

4
.husky/pre-commit Executable file
View File

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

2
.nvmrc
View File

@@ -1 +1 @@
24
20

View File

@@ -41,8 +41,9 @@ Cloning and Setup
git clone https://github.com/openedx/frontend-app-learning.git
2. Use the version of Node specified in ``.nvmrc``.
2. Use node v20.x.
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>`_.
@@ -130,7 +131,7 @@ Deployment
The Learning MFE is similar to all the other Open edX MFEs. Read the Open
edX Developer Guide's section on
`MFE applications <https://openedx.github.io/frontend-platform/>`_.
`MFE applications <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html>`_.
Plugins
=======
@@ -144,7 +145,7 @@ Environment Variables
This MFE is configured via environment variables supplied at build time.
All micro-frontends have a shared set of required environment variables,
as documented in the Open edX Developer Guide under
`Required Environment Variables <https://openedx.github.io/frontend-platform/>`_.
`Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`_.
The learning micro-frontend also supports the following additional variables:

View File

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

10
openedx.yaml Normal file
View File

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

12678
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,11 +15,12 @@
"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 .",
"prepare": "husky install",
"postinstall": "patch-package",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"start:with-theme": "paragon install-theme && npm start && npm install",
"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": "NODE_ENV=test fedx-scripts jest --coverage --passWithNoTests",
"test:watch": "fedx-scripts jest --watch --passWithNoTests",
"test": "fedx-scripts jest --coverage --passWithNoTests",
"types": "tsc --noEmit"
},
"author": "edX",
@@ -33,40 +34,43 @@
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/browserslist-config": "1.5.0",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^8.0.0",
"@edx/frontend-lib-learning-assistant": "^2.23.1",
"@edx/frontend-lib-special-exams": "^4.0.0",
"@edx/frontend-platform": "^8.4.0",
"@edx/openedx-atlas": "^0.7.0",
"@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/openedx-atlas": "^0.6.0",
"@edx/react-unit-test-utils": "3.0.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.6.2",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.4.5",
"@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",
"@popperjs/core": "2.11.8",
"@reduxjs/toolkit": "1.9.7",
"@reduxjs/toolkit": "1.8.1",
"buffer": "^6.0.3",
"classnames": "2.5.1",
"copy-webpack-plugin": "^12.0.0",
"copy-webpack-plugin": "^11.0.0",
"husky": "7.0.4",
"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": "^18.3.1",
"react-dom": "^18.3.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.15.0",
"react-router-dom": "6.15.0",
"react-share": "4.4.1",
"redux": "4.2.1",
"redux": "4.1.2",
"reselect": "4.1.8",
"sass": "^1.79.3",
"sass-loader": "^16.0.2",
@@ -74,11 +78,13 @@
"truncate-html": "1.0.4"
},
"devDependencies": {
"@edx/reactifex": "2.2.0",
"@pact-foundation/pact": "^13.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "14.6.1",
"axios-mock-adapter": "2.1.0",
"@testing-library/jest-dom": "5.17.0",
"@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",
@@ -90,7 +96,7 @@
"files": [
{
"path": "dist/*.js",
"maxSize": "1450kB"
"maxSize": "1300kB"
}
],
"normalizeFilenames": "^.+?(\\..+?)\\.\\w+$"

View File

@@ -0,0 +1,36 @@
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'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,8 +13,6 @@ export const DECODE_ROUTES = {
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
'/preview/course/:courseId/:sequenceId/:unitId',
'/preview/course/:courseId/:sequenceId',
],
REDIRECT_HOME: 'home/:courseId',
REDIRECT_SURVEY: 'survey/:courseId',
@@ -22,7 +20,7 @@ export const DECODE_ROUTES = {
export const ROUTES = {
UNSUBSCRIBE: '/goal-unsubscribe/:token',
PREFERENCES_UNSUBSCRIBE: '/preferences-unsubscribe/:userToken/:updatePatch?',
PREFERENCES_UNSUBSCRIBE: '/preferences-unsubscribe/:userToken/:updatePatch',
REDIRECT: '/redirect/*',
DASHBOARD: 'dashboard',
ENTERPRISE_LEARNER_DASHBOARD: 'enterprise-learner-dashboard',
@@ -48,20 +46,6 @@ export const VERIFIED_MODES = [
'paid-bootcamp',
] as const satisfies readonly string[];
export const AUDIT_MODES = [
'audit',
'honor',
'unpaid-executive-education',
'unpaid-bootcamp',
] as const satisfies readonly string[];
// In sync with CourseMode.UPSELL_TO_VERIFIED_MODES
// https://github.com/openedx/edx-platform/blob/master/common/djangoapps/course_modes/models.py#L231
export const ALLOW_UPSELL_MODES = [
'audit',
'honor',
] as const satisfies readonly string[];
export const WIDGETS = {
DISCUSSIONS: 'DISCUSSIONS',
NOTIFICATIONS: 'NOTIFICATIONS',

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import {
screen,
} from '../../setupTest';
import CoursewareSearchEmpty from './CoursewareSearchEmpty';
import messages from './messages';
function renderComponent() {
const { container } = render(<CoursewareSearchEmpty />);
@@ -17,12 +16,9 @@ describe('CoursewareSearchEmpty', () => {
initializeMockApp();
});
it('render empty results text and corresponding classes', () => {
it('should match the snapshot', () => {
renderComponent();
const emptyText = screen.getByText(messages.searchResultsNone.defaultMessage);
expect(emptyText).toBeInTheDocument();
expect(emptyText).toHaveClass('courseware-search-results__empty');
expect(emptyText).toHaveAttribute('data-testid', 'no-results');
expect(emptyText.parentElement).toHaveClass('courseware-search-results');
expect(screen.getByTestId('no-results')).toMatchSnapshot();
});
});

View File

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

View File

@@ -7,7 +7,6 @@ import {
import CoursewareSearchResults from './CoursewareSearchResults';
import messages from './messages';
import searchResultsFactory from './test-data/search-results-factory';
import * as mock from './test-data/mocked-response.json';
jest.mock('react-redux');
@@ -35,53 +34,8 @@ describe('CoursewareSearchResults', () => {
renderComponent({ results });
});
it('should render complete list', () => {
const courses = screen.getAllByRole('link');
expect(courses.length).toBe(mock.results.length);
});
it('should render correct link for internal course', () => {
const courses = screen.getAllByRole('link');
const firstCourse = courses[0];
const firstCourseTitle = firstCourse.querySelector('.courseware-search-results__title span');
expect(firstCourseTitle.innerHTML).toEqual(mock.results[0].data.content.display_name);
expect(firstCourse.href).toContain(mock.results[0].data.url);
expect(firstCourse).not.toHaveAttribute('target', '_blank');
expect(firstCourse).not.toHaveAttribute('rel', 'nofollow');
});
it('should render correct link if is External url course', () => {
const courses = screen.getAllByRole('link');
const externalCourse = courses[courses.length - 1];
const externalCourseTitle = externalCourse.querySelector('.courseware-search-results__title span');
expect(externalCourseTitle.innerHTML).toEqual(mock.results[mock.results.length - 1].data.content.display_name);
expect(externalCourse.href).toContain(mock.results[mock.results.length - 1].data.url);
expect(externalCourse).toHaveAttribute('target', '_blank');
expect(externalCourse).toHaveAttribute('rel', 'nofollow');
const icon = externalCourse.querySelector('svg');
expect(icon).toBeInTheDocument();
});
it('should render location breadcrumbs', () => {
const breadcrumbs = screen.getAllByText(mock.results[0].data.location[0]);
expect(breadcrumbs.length).toBeGreaterThan(0);
const firstBreadcrumb = breadcrumbs[0].closest('li');
expect(firstBreadcrumb).toBeInTheDocument();
expect(firstBreadcrumb.querySelector('div').textContent).toBe(mock.results[0].data.location[0]);
expect(firstBreadcrumb.nextSibling.querySelector('div').textContent).toBe(mock.results[0].data.location[1]);
});
});
describe('when results are provided with content hits', () => {
beforeEach(() => {
const { results } = searchResultsFactory('Passing');
renderComponent({ results });
});
it('should render content hits', () => {
const contentHits = screen.getByText('1');
expect(contentHits).toBeInTheDocument();
expect(contentHits.tagName).toBe('EM');
it('should match the snapshot', () => {
expect(screen.getByTestId('search-results')).toMatchSnapshot();
});
});
});

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { ManageSearch } from '@openedx/paragon/icons';
import { useDispatch } from 'react-redux';
@@ -7,8 +7,9 @@ import messages from './messages';
import { useCoursewareSearchFeatureFlag, useCoursewareSearchParams } from './hooks';
import { setShowSearch } from '../data/slice';
const CoursewareSearchToggle = () => {
const intl = useIntl();
const CoursewareSearchToggle = ({
intl,
}) => {
const dispatch = useDispatch();
const enabled = useCoursewareSearchFeatureFlag();
const { query } = useCoursewareSearchParams();
@@ -40,4 +41,8 @@ const CoursewareSearchToggle = () => {
);
};
export default CoursewareSearchToggle;
CoursewareSearchToggle.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearchToggle);

View File

@@ -0,0 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CoursewareSearchEmpty should match the snapshot 1`] = `
<p
class="courseware-search-results__empty"
data-testid="no-results"
>
No results found.
</p>
`;

View File

@@ -0,0 +1,1238 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CoursewareSearchResults when list of results is provided should match the snapshot 1`] = `
<div
class="courseware-search-results"
data-testid="search-results"
>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 4H2v16h20V6H12l-2-2z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Demo Course Overview
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Introduction
</div>
</li>
<li>
<div>
Demo Course Overview
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Passing a Course
</span>
<em>
1
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
Passing a Course
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 4H2v16h20V6H12l-2-2z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Passing a Course
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
Passing a Course
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Text Input
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Homework - Question Styles
</div>
</li>
<li>
<div>
Text input
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Pointing on a Picture
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Homework - Question Styles
</div>
</li>
<li>
<div>
Pointing on a Picture
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Getting Answers
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
Getting Answers
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17 10.5V6H3v12h14v-4.5l4 4v-11l-4 4Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Welcome!
</span>
<em>
30
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Introduction
</div>
</li>
<li>
<div>
Demo Course Overview
</div>
</li>
<li>
<div>
Introduction: Video and Sequences
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Multiple Choice Questions
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Homework - Question Styles
</div>
</li>
<li>
<div>
Multiple Choice Questions
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Numerical Input
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Homework - Question Styles
</div>
</li>
<li>
<div>
Numerical Input
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17 10.5V6H3v12h14v-4.5l4 4v-11l-4 4Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Connecting a Circuit and a Circuit Diagram
</span>
<em>
3
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Lesson 1 - Getting Started
</div>
</li>
<li>
<div>
Video Presentation Styles
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
CAPA
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 2: Get Interactive
</div>
</li>
<li>
<div>
Homework - Labs and Demos
</div>
</li>
<li>
<div>
Code Grader
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Interactive Questions
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Lesson 1 - Getting Started
</div>
</li>
<li>
<div>
Interactive Questions
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Blank HTML Page
</span>
<em>
6
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Introduction
</div>
</li>
<li>
<div>
Demo Course Overview
</div>
</li>
<li>
<div>
Introduction: Video and Sequences
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Discussion Forums
</span>
<em>
5
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 3: Be Social
</div>
</li>
<li>
<div>
Lesson 3 - Be Social
</div>
</li>
<li>
<div>
Discussion Forums
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Overall Grade
</span>
<em>
7
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
Overall Grade Performance
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Blank HTML Page
</span>
<em>
3
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 3: Be Social
</div>
</li>
<li>
<div>
Lesson 3 - Be Social
</div>
</li>
<li>
<div>
Homework - Find Your Study Buddy
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Find Your Study Buddy
</span>
<em>
3
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 3: Be Social
</div>
</li>
<li>
<div>
Homework - Find Your Study Buddy
</div>
</li>
<li>
<div>
Homework - Find Your Study Buddy
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Be Social
</span>
<em>
4
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 3: Be Social
</div>
</li>
<li>
<div>
Lesson 3 - Be Social
</div>
</li>
<li>
<div>
Be Social
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
EdX Exams
</span>
<em>
4
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
EdX Exams
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
When Are Your Exams?
</span>
<em>
2
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Lesson 1 - Getting Started
</div>
</li>
<li>
<div>
When Are Your Exams?
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="https://www.edx.org"
rel="nofollow"
target="_blank"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
External Course Link Test
</span>
</div>
</div>
</a>
</div>
`;

View File

@@ -0,0 +1,306 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`mapSearchResponse when the response is correct should match snapshot 1`] = `
{
"filters": [
{
"count": 7,
"key": "capa",
"label": "CAPA",
},
{
"count": 2,
"key": "sequence",
"label": "Sequence",
},
{
"count": 9,
"key": "text",
"label": "Text",
},
{
"count": 1,
"key": "unknown",
"label": "Unknown",
},
{
"count": 2,
"key": "video",
"label": "Video",
},
],
"maxScore": 3.4545178,
"ms": 5,
"results": [
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
"location": [
"Introduction",
"Demo Course Overview",
],
"score": 3.4545178,
"title": "Demo Course Overview",
"type": "sequence",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
"location": [
"About Exams and Certificates",
"edX Exams",
"Passing a Course",
],
"score": 3.4545178,
"title": "Passing a Course",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
"location": [
"About Exams and Certificates",
"edX Exams",
"Passing a Course",
],
"score": 3.4545178,
"title": "Passing a Course",
"type": "sequence",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
"location": [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Text input",
],
"score": 1.5874016,
"title": "Text Input",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
"location": [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Pointing on a Picture",
],
"score": 1.5499392,
"title": "Pointing on a Picture",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
"location": [
"About Exams and Certificates",
"edX Exams",
"Getting Answers",
],
"score": 1.5003732,
"title": "Getting Answers",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
"location": [
"Introduction",
"Demo Course Overview",
"Introduction: Video and Sequences",
],
"score": 1.4792063,
"title": "Welcome!",
"type": "video",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
"location": [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Multiple Choice Questions",
],
"score": 1.4341705,
"title": "Multiple Choice Questions",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
"location": [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Numerical Input",
],
"score": 1.2987298,
"title": "Numerical Input",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
"location": [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"Video Presentation Styles",
],
"score": 1.1870136,
"title": "Connecting a Circuit and a Circuit Diagram",
"type": "video",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
"location": [
"Example Week 2: Get Interactive",
"Homework - Labs and Demos",
"Code Grader",
],
"score": 1.0107487,
"title": "CAPA",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
"location": [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"Interactive Questions",
],
"score": 0.96387196,
"title": "Interactive Questions",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
"location": [
"Introduction",
"Demo Course Overview",
"Introduction: Video and Sequences",
],
"score": 0.8844358,
"title": "Blank HTML Page",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
"location": [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Discussion Forums",
],
"score": 0.8803684,
"title": "Discussion Forums",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
"location": [
"About Exams and Certificates",
"edX Exams",
"Overall Grade Performance",
],
"score": 0.87981963,
"title": "Overall Grade",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
"location": [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Homework - Find Your Study Buddy",
],
"score": 0.84284115,
"title": "Blank HTML Page",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
"location": [
"Example Week 3: Be Social",
"Homework - Find Your Study Buddy",
"Homework - Find Your Study Buddy",
],
"score": 0.84284115,
"title": "Find Your Study Buddy",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
"location": [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Be Social",
],
"score": 0.84210813,
"title": "Be Social",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
"location": [
"About Exams and Certificates",
"edX Exams",
"EdX Exams",
],
"score": 0.8306555,
"title": "EdX Exams",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
"location": [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"When Are Your Exams? ",
],
"score": 0.82610154,
"title": "When Are Your Exams? ",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
},
{
"contentHits": 0,
"id": "random-element-id",
"location": null,
"score": 0.82610154,
"title": "External Course Link Test",
"type": "unknown",
"url": "https://www.edx.org",
},
],
"total": 29,
}
`;

View File

@@ -5,25 +5,13 @@
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
max-width: none;
margin: 0;
border-top: 1px solid var(--pgn-color-light-300);
z-index: var(--pgn-elevation-modal-zindex); // Bootstrap's z-index layer for Modals.
&__form {
position: relative;
.h2 {
margin-right: 2.5rem;
}
}
border-top: 1px solid $light-300;
z-index: $zindex-modal; // Bootstrap's z-index layer for Modals.
&__close {
position: absolute !important; // For some reason it gets overridden
top: 0;
right: 0;
top: 0.5rem;
right: 1rem;
font-size: 1.5rem;
line-height: 1;
}
@@ -47,7 +35,7 @@
&__results-summary {
font-size: .9rem;
color: var(--pgn-color-gray-500);
color: $gray-500;
padding: 1rem 0 .5rem;
}
@@ -62,7 +50,7 @@
margin-top: 1.5rem;
&__empty {
color: var(--pgn-color-gray-500);
color: $gray-500;
padding: 6rem 0;
text-align: center;
}
@@ -76,17 +64,17 @@
&:hover {
text-decoration: none;
background: var(--pgn-color-light-300);
background: $light-300;
}
&:not(:first-child) {
border-top: 1px solid var(--pgn-color-light-300);
border-top: 1px solid $light-300;
}
}
&__icon {
padding: 0.375rem 0 0 0.375rem;
color: var(--pgn-color-gray-300);
color: $gray-300;
}
&__info {
@@ -99,7 +87,7 @@
align-items: center;
line-height: 2.5;
font-size: 0.875rem;
color: var(--pgn-color-black);
color: $black;
> span {
display: block;
@@ -113,7 +101,7 @@
font-variant-numeric: lining-nums tabular-nums;
min-width: 1.25rem;
line-height: 1rem;
background: var(--pgn-color-light-300);
background: $light-300;
border-radius: 99rem;
font-style: normal;
margin-left: 0.375rem;
@@ -125,7 +113,7 @@
&__breadcrumbs {
display: flex;
gap: 1.25rem;
color: var(--pgn-color-gray-500);
color: $gray-500;
overflow: hidden;
list-style: none;
padding: 0;
@@ -156,24 +144,17 @@
}
.courseware-search-results-tabs {
border-bottom-color: var(--pgn-color-gray-400) !important;
border-bottom-color: $gray-400 !important;
&.nav-tabs .nav-link.active {
border-bottom-width: 4px !important;
}
}
@media (--pgn-size-breakpoint-min-width-md) {
.courseware-search {
&__close {
right: -2.5rem;
}
&__content {
padding-top: 8rem;
}
@media (min-width: map-get($grid-breakpoints, 'md')) {
.courseware-search__content {
padding-top: 8rem;
}
}
body._search-no-scroll {

View File

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

View File

@@ -10,8 +10,8 @@ describe('mapSearchResponse', () => {
response = mapSearchResponse(camelCaseObject(mockedResponse));
});
it('should match number of results', () => {
expect(response.results.length).toBe(mockedResponse.results.length);
it('should match snapshot', () => {
expect(response).toMatchSnapshot();
});
it('should match expected filters', () => {
@@ -24,25 +24,6 @@ describe('mapSearchResponse', () => {
];
expect(response.filters).toEqual(expectedFilters);
});
it('should match expected results', () => {
const mockFirstResult = mockedResponse.results[0];
const expectedFirstResult = {
id: mockFirstResult.data.id,
title: mockFirstResult.data.content.display_name,
type: mockFirstResult.data.content_type.toLowerCase(),
location: mockFirstResult.data.location,
url: mockFirstResult.data.url,
contentHits: 0,
score: mockFirstResult.score,
};
expect(response.results[0]).toEqual(expectedFirstResult);
});
it('should match expected ms and max score', () => {
expect(response.maxScore).toBe(mockedResponse.max_score);
expect(response.ms).toBe(mockedResponse.took);
});
});
describe('when the a keyword is provided', () => {

View File

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

View File

@@ -17,21 +17,7 @@ Factory.define('progressTabData')
percent: 1,
is_passing: true,
},
final_grades: 0.5,
credit_course_requirements: null,
assignment_type_grade_summary: [
{
type: 'Homework',
short_label: 'HW',
weight: 1,
average_grade: 1,
weighted_grade: 1,
num_droppable: 1,
num_total: 2,
has_hidden_contribution: 'none',
last_grade_publish_date: null,
},
],
section_scores: [
{
display_name: 'First section',

View File

@@ -0,0 +1,942 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
{
"courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
},
"courseware": {
"courseId": null,
"courseOutline": {},
"courseOutlineShouldUpdate": false,
"courseOutlineStatus": "loading",
"courseStatus": "loading",
"coursewareOutlineSidebarSettings": {},
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": {
"courseHomeMeta": {
"course-v1:edX+DemoX+Demo_Course": {
"canViewCertificate": true,
"celebrations": null,
"courseAccess": {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"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": [
{
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
{
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
{
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
{
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
{
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
{
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
},
},
"dates": {
"course-v1:edX+DemoX+Demo_Course": {
"courseDateBlocks": [
{
"date": "2020-05-01T17:59:41Z",
"dateType": "course-start-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "",
"title": "Course Starts",
},
{
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-04T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Multi Badges Completed",
},
{
"assignmentType": "Homework",
"date": "2020-05-05T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Multi Badges Past Due",
},
{
"assignmentType": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Past Due 1",
},
{
"assignmentType": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Past Due 2",
},
{
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-28T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Completed/Due 1",
},
{
"assignmentType": "Homework",
"date": "2020-05-28T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Completed/Due 2",
},
{
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Completed 1",
},
{
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Completed 2",
},
{
"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.",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Upgrade to Verified Certificate",
},
{
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "One Verified 1",
},
{
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Verified 2",
},
{
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": "ORA Dates are set by the instructor, and can't be changed",
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "ORA Verified 2",
},
{
"assignmentType": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "Both Verified 1",
},
{
"assignmentType": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "Both Verified 2",
},
{
"assignmentType": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"learnerHasAccess": true,
"title": "One Unreleased 1",
},
{
"assignmentType": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Unreleased 2",
},
{
"assignmentType": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Both Unreleased 1",
},
{
"assignmentType": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Both Unreleased 2",
},
{
"date": "2030-08-23T00:00:00Z",
"dateType": "course-end-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "",
"title": "Course Ends",
},
{
"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.",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "Verification Deadline",
},
],
"datesBannerInfo": {
"contentTypeGatingEnabled": false,
"missedDeadlines": false,
"missedGatedContent": false,
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
"hasEnded": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"learnerIsFullAccess": true,
},
},
},
"plugins": {},
"recommendations": {
"recommendationsStatus": "loading",
},
"specialExams": {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
"exam": {
"attempt": {
"attempt_code": "",
"attempt_id": null,
"attempt_status": "",
"course_id": "",
"desktop_application_js_url": "",
"exam_display_name": "",
"exam_started_poll_url": "",
"exam_type": "",
"exam_url_path": "",
"external_id": "",
"in_timed_exam": true,
"ping_interval": null,
"taking_as_proctored": true,
"time_remaining_seconds": null,
"use_legacy_attempt_api": true,
},
"backend": "",
"content_id": "",
"course_id": "",
"due_date": null,
"exam_name": "",
"external_id": "",
"hide_after_due": false,
"id": null,
"is_active": true,
"is_practice_exam": false,
"is_proctored": false,
"prerequisite_status": {
"are_prerequisites_satisifed": true,
"declined_prerequisites": [],
"failed_prerequisites": [],
"pending_prerequisites": [],
"satisfied_prerequisites": [],
},
"time_limit_mins": null,
"type": "",
},
"examAccessToken": {
"exam_access_token": "",
"exam_access_token_expiration": "",
},
"isLoading": true,
"proctoringSettings": {
"exam_proctoring_backend": {
"download_url": "",
"instructions": [],
"name": "",
"rules": {},
},
"integration_specific_email": "",
"learner_notification_from_email": "",
"provider_name": "",
"provider_tech_support_email": "",
"provider_tech_support_phone": "",
"provider_tech_support_url": "",
},
"timeIsOver": false,
},
"tours": {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
}
`;
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
{
"courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
},
"courseware": {
"courseId": null,
"courseOutline": {},
"courseOutlineShouldUpdate": false,
"courseOutlineStatus": "loading",
"courseStatus": "loading",
"coursewareOutlineSidebarSettings": {},
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": {
"courseHomeMeta": {
"course-v1:edX+DemoX+Demo_Course": {
"canViewCertificate": true,
"celebrations": null,
"courseAccess": {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"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": [
{
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
{
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
{
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
{
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
{
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
{
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
},
},
"outline": {
"course-v1:edX+DemoX+Demo_Course": {
"accessExpiration": null,
"canShowUpgradeSock": false,
"certData": {
"certStatus": null,
"certWebViewUrl": null,
"certificateAvailableDate": null,
},
"courseBlocks": {
"courses": {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": {
"hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"sectionIds": [
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
],
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
},
},
"sections": {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": {
"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": [
"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": {
"complete": false,
"description": null,
"due": null,
"effortActivities": 2,
"effortTime": 15,
"hideFromTOC": undefined,
"icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"navigationDisabled": undefined,
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"showLink": true,
"title": "Title of Sequence",
},
},
},
"courseGoals": {
"daysPerWeek": null,
"goalOptions": [],
"selectedGoal": null,
"subscribedToReminders": null,
"weeklyLearningGoalEnabled": false,
},
"courseTools": [
{
"analyticsId": "edx.bookmarks",
"title": "Bookmarks",
"url": "https://example.com/bookmarks",
},
],
"datesBannerInfo": {
"contentTypeGatingEnabled": false,
"missedDeadlines": false,
"missedGatedContent": false,
},
"datesWidget": {
"courseDateBlocks": [],
},
"enableProctoredExams": undefined,
"enrollAlert": {
"canEnroll": true,
"extraText": "Contact the administrator.",
},
"enrollmentMode": undefined,
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
"hasEnded": undefined,
"hasScheduledContent": null,
"id": "course-v1:edX+DemoX+Demo_Course",
"offer": null,
"resumeCourse": {
"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": {
"accessExpirationDate": "2050-01-01T12:00:00",
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "ABCD1234",
"upgradeUrl": "http://localhost:18000/dashboard",
},
"welcomeMessageHtml": "<p>Welcome to this course!</p>",
},
},
},
"plugins": {},
"recommendations": {
"recommendationsStatus": "loading",
},
"specialExams": {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
"exam": {
"attempt": {
"attempt_code": "",
"attempt_id": null,
"attempt_status": "",
"course_id": "",
"desktop_application_js_url": "",
"exam_display_name": "",
"exam_started_poll_url": "",
"exam_type": "",
"exam_url_path": "",
"external_id": "",
"in_timed_exam": true,
"ping_interval": null,
"taking_as_proctored": true,
"time_remaining_seconds": null,
"use_legacy_attempt_api": true,
},
"backend": "",
"content_id": "",
"course_id": "",
"due_date": null,
"exam_name": "",
"external_id": "",
"hide_after_due": false,
"id": null,
"is_active": true,
"is_practice_exam": false,
"is_proctored": false,
"prerequisite_status": {
"are_prerequisites_satisifed": true,
"declined_prerequisites": [],
"failed_prerequisites": [],
"pending_prerequisites": [],
"satisfied_prerequisites": [],
},
"time_limit_mins": null,
"type": "",
},
"examAccessToken": {
"exam_access_token": "",
"exam_access_token_expiration": "",
},
"isLoading": true,
"proctoringSettings": {
"exam_proctoring_backend": {
"download_url": "",
"instructions": [],
"name": "",
"rules": {},
},
"integration_specific_email": "",
"learner_notification_from_email": "",
"provider_name": "",
"provider_tech_support_email": "",
"provider_tech_support_phone": "",
"provider_tech_support_url": "",
},
"timeIsOver": false,
},
"tours": {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
}
`;
exports[`Data layer integration tests Test fetchProgressTab Should fetch, normalize, and save metadata 1`] = `
{
"courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
},
"courseware": {
"courseId": null,
"courseOutline": {},
"courseOutlineShouldUpdate": false,
"courseOutlineStatus": "loading",
"courseStatus": "loading",
"coursewareOutlineSidebarSettings": {},
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": {
"courseHomeMeta": {
"course-v1:edX+DemoX+Demo_Course": {
"canViewCertificate": true,
"celebrations": null,
"courseAccess": {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"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": [
{
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
{
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
{
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
{
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
{
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
{
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
},
},
"progress": {
"course-v1:edX+DemoX+Demo_Course": {
"accessExpiration": null,
"certificateData": {},
"completionSummary": {
"completeCount": 1,
"incompleteCount": 1,
"lockedCount": 0,
},
"courseGrade": {
"isPassing": true,
"letterGrade": "pass",
"percent": 1,
},
"courseId": "course-v1:edX+DemoX+Demo_Course",
"creditCourseRequirements": null,
"end": "3027-03-31T00:00:00Z",
"enrollmentMode": "audit",
"gradesFeatureIsFullyLocked": false,
"gradesFeatureIsPartiallyLocked": false,
"gradingPolicy": {
"assignmentPolicies": [
{
"averageGrade": "1.0000",
"numDroppable": 1,
"shortLabel": "HW",
"type": "Homework",
"weight": 1,
"weightedGrade": 1,
},
],
"gradeRange": {
"pass": 0.75,
},
},
"hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"sectionScores": [
{
"displayName": "First section",
"subsections": [
{
"assignmentType": "Homework",
"blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345",
"displayName": "First subsection",
"hasGradedAssignment": true,
"learnerHasAccess": true,
"numPointsEarned": 0,
"numPointsPossible": 3,
"percentGraded": 0,
"problemScores": [
{
"earned": 0,
"possible": 1,
},
{
"earned": 0,
"possible": 1,
},
{
"earned": 0,
"possible": 1,
},
],
"showCorrectness": "always",
"showGrades": true,
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection",
},
],
},
{
"displayName": "Second section",
"subsections": [
{
"assignmentType": "Homework",
"displayName": "Second subsection",
"hasGradedAssignment": true,
"numPointsEarned": 1,
"numPointsPossible": 1,
"percentGraded": 1,
"problemScores": [
{
"earned": 1,
"possible": 1,
},
],
"showCorrectness": "always",
"showGrades": true,
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection",
},
],
},
],
"studioUrl": "http://studio.edx.org/settings/grading/course-v1:edX+Test+run",
"userHasPassingGrade": false,
"verificationData": {
"link": null,
"status": "none",
"statusDate": null,
},
"verifiedMode": null,
},
},
},
"plugins": {},
"recommendations": {
"recommendationsStatus": "loading",
},
"specialExams": {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
"exam": {
"attempt": {
"attempt_code": "",
"attempt_id": null,
"attempt_status": "",
"course_id": "",
"desktop_application_js_url": "",
"exam_display_name": "",
"exam_started_poll_url": "",
"exam_type": "",
"exam_url_path": "",
"external_id": "",
"in_timed_exam": true,
"ping_interval": null,
"taking_as_proctored": true,
"time_remaining_seconds": null,
"use_legacy_attempt_api": true,
},
"backend": "",
"content_id": "",
"course_id": "",
"due_date": null,
"exam_name": "",
"external_id": "",
"hide_after_due": false,
"id": null,
"is_active": true,
"is_practice_exam": false,
"is_proctored": false,
"prerequisite_status": {
"are_prerequisites_satisifed": true,
"declined_prerequisites": [],
"failed_prerequisites": [],
"pending_prerequisites": [],
"satisfied_prerequisites": [],
},
"time_limit_mins": null,
"type": "",
},
"examAccessToken": {
"exam_access_token": "",
"exam_access_token_expiration": "",
},
"isLoading": true,
"proctoringSettings": {
"exam_proctoring_backend": {
"download_url": "",
"instructions": [],
"name": "",
"rules": {},
},
"integration_specific_email": "",
"learner_notification_from_email": "",
"provider_name": "",
"provider_tech_support_email": "",
"provider_tech_support_phone": "",
"provider_tech_support_url": "",
},
"timeIsOver": false,
},
"tours": {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
}
`;

View File

@@ -3,6 +3,93 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logInfo } from '@edx/frontend-platform/logging';
import { appendBrowserTimezoneToUrl } from '../../utils';
const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) => {
let dropCount = numDroppable;
// Drop the lowest grades
while (dropCount && points.length >= dropCount) {
const lowestScore = Math.min(...points);
const lowestScoreIndex = points.indexOf(lowestScore);
points.splice(lowestScoreIndex, 1);
dropCount--;
}
let averageGrade = 0;
let weightedGrade = 0;
if (points.length) {
// 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);
weightedGrade = averageGrade * assignmentWeight;
}
return { averageGrade, weightedGrade };
};
function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
const gradeByAssignmentType = {};
assignmentPolicies.forEach(assignment => {
// Create an array with the number of total assignments and set the scores to 0
// as placeholders for assignments that have not yet been released
gradeByAssignmentType[assignment.type] = {
grades: Array(assignment.numTotal).fill(0),
numAssignmentsCreated: 0,
numTotalExpectedAssignments: assignment.numTotal,
};
});
sectionScores.forEach((chapter) => {
chapter.subsections.forEach((subsection) => {
if (!(subsection.hasGradedAssignment && subsection.showGrades && subsection.numPointsPossible)) {
return;
}
const {
assignmentType,
numPointsEarned,
numPointsPossible,
} = subsection;
// If a subsection's assignment type does not match an assignment policy in Studio,
// we won't be able to include it in this accumulation of grades by assignment type.
// This may happen if a course author has removed/renamed an assignment policy in Studio and
// neglected to update the subsection's of that assignment type
if (!gradeByAssignmentType[assignmentType]) {
return;
}
let {
numAssignmentsCreated,
} = gradeByAssignmentType[assignmentType];
numAssignmentsCreated++;
if (numAssignmentsCreated <= gradeByAssignmentType[assignmentType].numTotalExpectedAssignments) {
// Remove a placeholder grade so long as the number of recorded created assignments is less than the number
// of expected assignments
gradeByAssignmentType[assignmentType].grades.shift();
}
// Add the graded assignment to the list
gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
// Record the created assignment
gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated;
});
});
return assignmentPolicies.map((assignment) => {
const { averageGrade, weightedGrade } = calculateAssignmentTypeGrades(
gradeByAssignmentType[assignment.type].grades,
assignment.weight,
assignment.numDroppable,
);
return {
averageGrade,
numDroppable: assignment.numDroppable,
shortLabel: assignment.shortLabel,
type: assignment.type,
weight: assignment.weight,
weightedGrade,
};
});
}
/**
* Tweak the metadata for consistency
* @param metadata the data to normalize
@@ -149,6 +236,11 @@ export async function getProgressTabData(courseId, targetUserId) {
const { data } = await getAuthenticatedHttpClient().get(url);
const camelCasedData = camelCaseObject(data);
camelCasedData.gradingPolicy.assignmentPolicies = normalizeAssignmentPolicies(
camelCasedData.gradingPolicy.assignmentPolicies,
camelCasedData.sectionScores,
);
// We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade.
// For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a")
// in order to preserve a course team's desired grade formatting.
@@ -197,17 +289,9 @@ export async function getProgressTabData(courseId, targetUserId) {
}
export async function getProctoringInfoData(courseId, username) {
let url;
if (!getConfig().EXAMS_BASE_URL) {
url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
if (username) {
url += `&username=${encodeURIComponent(username)}`;
}
} else {
url = `${getConfig().EXAMS_BASE_URL}/api/v1/student/course_id/${encodeURIComponent(courseId)}/onboarding`;
if (username) {
url += `?username=${encodeURIComponent(username)}`;
}
let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
if (username) {
url += `&username=${encodeURIComponent(username)}`;
}
try {
const { data } = await getAuthenticatedHttpClient().get(url);
@@ -275,6 +359,7 @@ export async function getOutlineTabData(courseId) {
} = tabData;
const accessExpiration = camelCaseObject(data.access_expiration);
const canShowUpgradeSock = data.can_show_upgrade_sock;
const certData = camelCaseObject(data.cert_data);
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
const courseGoals = camelCaseObject(data.course_goals);
@@ -296,6 +381,7 @@ export async function getOutlineTabData(courseId) {
return {
accessExpiration,
canShowUpgradeSock,
certData,
courseBlocks,
courseGoals,
@@ -363,7 +449,7 @@ export async function unsubscribeFromCourseGoal(token) {
.then(res => camelCaseObject(res));
}
export async function getCoursewareSearchEnabled(courseId) {
export async function getCoursewareSearchEnabledFlag(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`);
const { data } = await getAuthenticatedHttpClient().get(url.href);
return { enabled: data.enabled || false };

View File

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

View File

@@ -90,14 +90,14 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toEqual(expect.objectContaining({
expect(state).toMatchSnapshot({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This UUID is generated on each run.
// Instead, we use an asymmetric matcher here.
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
}));
});
});
it.each([401, 403, 404])(
@@ -137,14 +137,14 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toEqual(expect.objectContaining({
expect(state).toMatchSnapshot({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This UUID is generated on each run.
// Instead, we use an asymmetric matcher here.
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
}));
});
});
it.each([401, 403, 404])(
@@ -185,14 +185,14 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toEqual(expect.objectContaining({
expect(state).toMatchSnapshot({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This UUID is generated on each run.
// Instead, we use an asymmetric matcher here.
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
}));
});
});
it('Should handle the url including a targetUserId', async () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import HeaderSlot from '../../plugin-slots/HeaderSlot';
import PageLoading from '../../generic/PageLoading';
@@ -10,8 +10,7 @@ import { unsubscribeFromCourseGoal } from '../data/api';
import messages from './messages';
import ResultPage from './ResultPage';
const GoalUnsubscribe = () => {
const intl = useIntl();
const GoalUnsubscribe = ({ intl }) => {
const { token } = useParams();
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
@@ -52,4 +51,8 @@ const GoalUnsubscribe = () => {
);
};
export default GoalUnsubscribe;
GoalUnsubscribe.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(GoalUnsubscribe);

View File

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

View File

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

View File

@@ -65,7 +65,6 @@ const DateSummary = ({
)}
{!linkedTitle && dateBlock.link && (
<a
id={dateBlock.dateType === 'verified-upgrade-deadline' ? 'date-verified-upgrade-deadline' : ''}
href={dateBlock.link}
onClick={dateBlock.dateType === 'verified-upgrade-deadline' ? logVerifiedUpgradeClick : () => {}}
className="description-link"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,15 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import DateSummary from '../DateSummary';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
const CourseDates = () => {
const intl = useIntl();
const CourseDates = ({
intl,
}) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -39,7 +40,7 @@ const CourseDates = () => {
/>
))}
</ol>
<a id="dates-tab-link" className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}>
<a className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}>
{intl.formatMessage(messages.allDates)}
</a>
</div>
@@ -47,4 +48,8 @@ const CourseDates = () => {
);
};
export default CourseDates;
CourseDates.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseDates);

View File

@@ -1,14 +1,13 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import LmsHtmlFragment from '../LmsHtmlFragment';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
const CourseHandouts = () => {
const intl = useIntl();
const CourseHandouts = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -32,4 +31,8 @@ const CourseHandouts = () => {
);
};
export default CourseHandouts;
CourseHandouts.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseHandouts);

View File

@@ -3,7 +3,7 @@ import { useSelector } from 'react-redux';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faBookmark, faCertificate, faInfo, faCalendar, faStar,
@@ -14,8 +14,7 @@ import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import LaunchCourseHomeTourButton from '../../../product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton';
const CourseTools = () => {
const intl = useIntl();
const CourseTools = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -82,4 +81,8 @@ const CourseTools = () => {
);
};
export default CourseTools;
CourseTools.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseTools);

View File

@@ -1,18 +1,22 @@
@import "~@edx/brand/paragon/variables";
@import "~@openedx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
.flag-button {
background-color: var(--pgn-color-white);
border: 1px solid var(--pgn-color-light-400);
background-color: $white;
border: 1px solid $light-400;
border-radius: .2rem;
box-shadow: 0 0 0 2px var(--pgn-color-light-400);
box-shadow: 0 0 0 2px $light-400;
&:hover {
border: 1px solid var(--pgn-color-primary-300);
box-shadow: 0 0 0 2px var(--pgn-color-white);
border: 1px solid $primary-300;
box-shadow: 0 0 0 2px $white;
}
}
.flag-button-selected {
border: 1px solid var(--pgn-color-primary-300);
box-shadow: 0 0 0 2px var(--pgn-color-primary-300);
border: 1px solid $primary-300;
box-shadow: 0 0 0 2px $primary-300;
pointer-events: none;
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
// These flag svgs are derivatives of the Flag icon from paragon
import { ReactComponent as FlagIntenseIcon } from './flag_black.svg';
import { ReactComponent as FlagCasualIcon } from './flag_outline.svg';
@@ -13,8 +13,8 @@ const LearningGoalButton = ({
level,
isSelected,
handleSelect,
intl,
}) => {
const intl = useIntl();
const buttonDetails = {
casual: {
daysPerWeek: 1,
@@ -53,6 +53,7 @@ LearningGoalButton.propTypes = {
level: PropTypes.string.isRequired,
isSelected: PropTypes.bool.isRequired,
handleSelect: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
export default LearningGoalButton;
export default injectIntl(LearningGoalButton);

View File

@@ -2,8 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import camelCase from 'lodash.camelcase';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getExternalLinkUrl } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import messages from '../messages';
@@ -11,8 +10,7 @@ import { getProctoringInfoData } from '../../data/api';
import { fetchProctoringInfoResolved } from '../../data/slice';
import { useModel } from '../../../generic/model-store';
const ProctoringInfoPanel = () => {
const intl = useIntl();
const ProctoringInfoPanel = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -208,7 +206,7 @@ const ProctoringInfoPanel = () => {
{isSubmissionRequired(readableStatus) && (
onboardingExamButton
)}
<Button variant="outline-primary" block href={getExternalLinkUrl('https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams')}>
<Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams">
{intl.formatMessage(messages.proctoringReviewRequirementsButton)}
</Button>
</div>
@@ -218,4 +216,8 @@ const ProctoringInfoPanel = () => {
);
};
export default ProctoringInfoPanel;
ProctoringInfoPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(ProctoringInfoPanel);

View File

@@ -1,10 +1,10 @@
.outline-sidebar-proctoring-panel {
border: 1px solid var(--pgn-color-dark-500);
border-top: 5px solid var(--pgn-color-brand-600);
border: 1px solid $dark-500;
border-top: 5px solid $brand-600;
}
.proctoring-onboarding-success {
border-top: 5px solid var(--pgn-color-primary-500);
border-top: 5px solid $primary-500;
}
.proctoring-onboarding-submitted {
border-top: 5px solid var(--pgn-color-dark-500);
border-top: 5px solid $dark-500;
}

View File

@@ -1,14 +1,13 @@
import React from 'react';
import { Button, Card } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
const StartOrResumeCourseCard = () => {
const intl = useIntl();
const StartOrResumeCourseCard = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -63,4 +62,8 @@ const StartOrResumeCourseCard = () => {
);
};
export default StartOrResumeCourseCard;
StartOrResumeCourseCard.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(StartOrResumeCourseCard);

View File

@@ -6,7 +6,7 @@ import { Form, Card, Icon } from '@openedx/paragon';
import { history } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Email } from '@openedx/paragon/icons';
import { useSelector } from 'react-redux';
import messages from '../messages';
@@ -18,8 +18,8 @@ import './FlagButton.scss';
const WeeklyLearningGoalCard = ({
daysPerWeek,
subscribedToReminders,
intl,
}) => {
const intl = useIntl();
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -152,10 +152,11 @@ const WeeklyLearningGoalCard = ({
WeeklyLearningGoalCard.propTypes = {
daysPerWeek: PropTypes.number,
subscribedToReminders: PropTypes.bool,
intl: intlShape.isRequired,
};
WeeklyLearningGoalCard.defaultProps = {
daysPerWeek: null,
subscribedToReminders: false,
};
export default WeeklyLearningGoalCard;
export default injectIntl(WeeklyLearningGoalCard);

View File

@@ -1,7 +1,7 @@
import { useState, useMemo, useRef } from 'react';
import React, { useState, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert, Button, TransitionReplace } from '@openedx/paragon';
import truncate from 'truncate-html';
@@ -11,13 +11,11 @@ import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import { dismissWelcomeMessage } from '../../data/thunks';
const WelcomeMessage = ({ courseId, nextElementRef }) => {
const intl = useIntl();
const WelcomeMessage = ({ courseId, intl }) => {
const {
welcomeMessageHtml,
} = useModel('outline', courseId);
const messageBodyRef = useRef();
const [display, setDisplay] = useState(true);
// welcomeMessageHtml can contain comments or malformatted HTML which can impact the length that determines
@@ -51,20 +49,13 @@ const WelcomeMessage = ({ courseId, nextElementRef }) => {
dismissible
show={display}
onClose={() => {
nextElementRef.current?.focus();
setDisplay(false);
dispatch(dismissWelcomeMessage(courseId));
}}
className="raised-card"
actions={messageCanBeShortened ? [
<Button
onClick={() => {
if (showShortMessage) {
messageBodyRef.current?.focus();
}
setShowShortMessage(!showShortMessage);
}}
onClick={() => setShowShortMessage(!showShortMessage)}
variant="outline-primary"
>
{showShortMessage ? intl.formatMessage(messages.welcomeMessageShowMoreButton)
@@ -72,34 +63,32 @@ const WelcomeMessage = ({ courseId, nextElementRef }) => {
</Button>,
] : []}
>
<div ref={messageBodyRef} tabIndex="-1">
<TransitionReplace className="mb-3" enterDuration={400} exitDuration={200}>
{showShortMessage ? (
<LmsHtmlFragment
className="inline-link"
data-testid="short-welcome-message-iframe"
key="short-html"
html={shortWelcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
) : (
<LmsHtmlFragment
className="inline-link"
data-testid="long-welcome-message-iframe"
key="full-html"
html={cleanedWelcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
)}
</TransitionReplace>
</div>
<TransitionReplace className="mb-3" enterDuration={400} exitDuration={200}>
{showShortMessage ? (
<LmsHtmlFragment
className="inline-link"
data-testid="short-welcome-message-iframe"
key="short-html"
html={shortWelcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
) : (
<LmsHtmlFragment
className="inline-link"
data-testid="long-welcome-message-iframe"
key="full-html"
html={cleanedWelcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
)}
</TransitionReplace>
</Alert>
);
};
WelcomeMessage.propTypes = {
courseId: PropTypes.string.isRequired,
nextElementRef: PropTypes.shape({ current: PropTypes.instanceOf(HTMLInputElement) }),
intl: intlShape.isRequired,
};
export default WelcomeMessage;
export default injectIntl(WelcomeMessage);

View File

@@ -1,14 +1,15 @@
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import React from 'react';
import { useSelector } from 'react-redux';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { useModel } from '../../generic/model-store';
import messages from './messages';
const ProgressHeader = () => {
const intl = useIntl();
const ProgressHeader = ({ intl }) => {
const {
courseId,
targetUserId,
@@ -36,4 +37,8 @@ const ProgressHeader = () => {
);
};
export default ProgressHeader;
ProgressHeader.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(ProgressHeader);

View File

@@ -1,20 +1,27 @@
import React from 'react';
import { useWindowSize } from '@openedx/paragon';
import { useContextId } from '../../data/hooks';
import ProgressTabCertificateStatusSidePanelSlot from '../../plugin-slots/ProgressTabCertificateStatusSidePanelSlot';
import { useSelector } from 'react-redux';
import { breakpoints, useWindowSize } from '@openedx/paragon';
import CertificateStatus from './certificate-status/CertificateStatus';
import CourseCompletion from './course-completion/CourseCompletion';
import CourseGrade from './grades/course-grade/CourseGrade';
import DetailedGrades from './grades/detailed-grades/DetailedGrades';
import GradeSummary from './grades/grade-summary/GradeSummary';
import ProgressHeader from './ProgressHeader';
import RelatedLinks from './related-links/RelatedLinks';
import ProgressTabCertificateStatusMainBodySlot from '../../plugin-slots/ProgressTabCertificateStatusMainBodySlot';
import ProgressTabCourseGradeSlot from '../../plugin-slots/ProgressTabCourseGradeSlot';
import ProgressTabGradeBreakdownSlot from '../../plugin-slots/ProgressTabGradeBreakdownSlot';
import ProgressTabRelatedLinksSlot from '../../plugin-slots/ProgressTabRelatedLinksSlot';
import { useModel } from '../../generic/model-store';
const ProgressTab = () => {
const courseId = useContextId();
const { disableProgressGraph } = useModel('progress', courseId);
const {
courseId,
} = useSelector(state => state.courseHome);
const {
gradesFeatureIsFullyLocked, disableProgressGraph,
} = useModel('progress', courseId);
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
const windowWidth = useWindowSize().width;
if (windowWidth === undefined) {
@@ -24,6 +31,7 @@ const ProgressTab = () => {
return null;
}
const wideScreen = windowWidth >= breakpoints.large.minWidth;
return (
<>
<ProgressHeader />
@@ -31,15 +39,18 @@ const ProgressTab = () => {
{/* Main body */}
<div className="col-12 col-md-8 p-0">
{!disableProgressGraph && <CourseCompletion />}
<ProgressTabCertificateStatusMainBodySlot />
<ProgressTabCourseGradeSlot />
<ProgressTabGradeBreakdownSlot />
{!wideScreen && <CertificateStatus />}
<CourseGrade />
<div className={`grades my-4 p-4 rounded raised-card ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsFullyLocked}>
<GradeSummary />
<DetailedGrades />
</div>
</div>
{/* Side panel */}
<div className="col-12 col-md-4 p-0 px-md-4">
<ProgressTabCertificateStatusSidePanelSlot />
<ProgressTabRelatedLinksSlot />
{wideScreen && <CertificateStatus />}
<RelatedLinks />
</div>
</div>
</>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Factory } from 'rosie';
import { getConfig, setConfig } from '@edx/frontend-platform';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { breakpoints } from '@openedx/paragon';
@@ -111,7 +111,7 @@ describe('Progress Tab', () => {
await fetchAndRender();
sendTrackEvent.mockClear();
const outlineTabLink = screen.getAllByRole('link', { name: 'Course outline' });
const outlineTabLink = screen.getAllByRole('link', { name: 'Course Outline' });
fireEvent.click(outlineTabLink[1]); // outlineTabLink[0] corresponds to the link in the DetailedGrades component
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
@@ -471,12 +471,9 @@ describe('Progress Tab', () => {
await fetchAndRender();
expect(screen.getByText('limited feature')).toBeInTheDocument();
expect(screen.getByText('Unlock to work towards a certificate.')).toBeInTheDocument();
expect(screen.queryAllByText(
'You have limited access to graded assignments as part of the audit track in this course.',
{ exact: false },
)).toHaveLength(2);
expect(screen.queryAllByText('You have limited access to graded assignments as part of the audit track in this course.')).toHaveLength(2);
expect(screen.queryAllByTestId('locked-icon')).toHaveLength(4);
expect(screen.queryAllByTestId('blocked-icon')).toHaveLength(4);
});
it('does not render subsections for which showGrades is false', async () => {
@@ -548,111 +545,6 @@ describe('Progress Tab', () => {
await fetchAndRender();
expect(screen.getByText('Grades & Credit')).toBeInTheDocument();
});
it('does not render ungraded subsections when SHOW_UNGRADED_ASSIGNMENT_PROGRESS is false', async () => {
// The second assignment has has_graded_assignment set to false, so it should not be shown.
setTabData({
section_scores: [
{
display_name: 'First section',
subsections: [
{
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 1,
num_points_possible: 2,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
],
},
{
display_name: 'Second section',
subsections: [
{
assignment_type: 'Homework',
display_name: 'Second subsection',
learner_has_access: true,
has_graded_assignment: false,
num_points_earned: 1,
num_points_possible: 1,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
},
],
},
],
});
await fetchAndRender();
expect(screen.getByText('First subsection')).toBeInTheDocument();
expect(screen.queryByText('Second subsection')).not.toBeInTheDocument();
});
it('renders both graded and ungraded subsections when SHOW_UNGRADED_ASSIGNMENT_PROGRESS is true', async () => {
// The second assignment has has_graded_assignment set to false.
setConfig({
...getConfig(),
SHOW_UNGRADED_ASSIGNMENT_PROGRESS: true,
});
setTabData({
section_scores: [
{
display_name: 'First section',
subsections: [
{
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 1,
num_points_possible: 2,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
],
},
{
display_name: 'Second section',
subsections: [
{
assignment_type: 'Homework',
display_name: 'Second subsection',
learner_has_access: true,
has_graded_assignment: false,
num_points_earned: 1,
num_points_possible: 1,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
},
],
},
],
});
await fetchAndRender();
expect(screen.getByText('First subsection')).toBeInTheDocument();
expect(screen.getByText('Second subsection')).toBeInTheDocument();
// reset config for other tests
setConfig({
...getConfig(),
SHOW_UNGRADED_ASSIGNMENT_PROGRESS: false,
});
});
});
describe('Grade Summary', () => {
@@ -661,133 +553,143 @@ describe('Progress Tab', () => {
expect(screen.getByText('Grade summary')).toBeInTheDocument();
});
it('does not render Grade Summary when assignment type grade summary is not populated', async () => {
it('does not render Grade Summary when assignment policies are not populated', async () => {
setTabData({
assignment_type_grade_summary: [],
grading_policy: {
assignment_policies: [],
grade_range: {
pass: 0.75,
},
},
section_scores: [],
});
await fetchAndRender();
expect(screen.queryByText('Grade summary')).not.toBeInTheDocument();
});
it('shows lock icon when all subsections of assignment type are hidden', async () => {
it('calculates grades correctly when number of droppable assignments equals total number of assignments', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 2,
num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
});
await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 1 100% 0% 0%' })).toBeInTheDocument();
});
it('calculates grades correctly when number of droppable assignments is less than total number of assignments', async () => {
await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument();
});
it('calculates grades correctly when number of droppable assignments is zero', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 0,
num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
});
await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 100% 50% 50%' })).toBeInTheDocument();
});
it('calculates grades correctly when number of total assignments is less than the number of assignments created', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 1,
num_total: 1, // two assignments created in the factory, but 1 is expected per Studio settings
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
});
await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument();
});
it('calculates grades correctly when number of total assignments is greater than the number of assignments created', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 0,
num_total: 5, // two assignments created in the factory, but 5 are expected per Studio settings
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
});
await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 100% 20% 20%' })).toBeInTheDocument();
});
it('calculates weighted grades correctly', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 1,
num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 0.5,
},
{
num_droppable: 0,
num_total: 1,
short_label: 'Final',
type: 'Final Exam',
weight: 1,
short_label: 'Ex',
type: 'Exam',
weight: 0.5,
},
],
grade_range: {
pass: 0.75,
},
},
assignment_type_grade_summary: [
{
type: 'Final Exam',
weight: 0.4,
average_grade: 0.0,
weighted_grade: 0.0,
last_grade_publish_date: '2025-10-15T14:17:04.368903Z',
has_hidden_contribution: 'all',
short_label: 'Final',
num_droppable: 0,
},
],
});
await fetchAndRender();
// Should show lock icon for grade and weighted grade
expect(screen.getAllByTestId('lock-icon')).toHaveLength(2);
});
it('shows percent plus hidden grades when some subsections of assignment type are hidden', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 0,
num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
assignment_type_grade_summary: [
{
type: 'Homework',
weight: 1,
average_grade: 0.25,
weighted_grade: 0.25,
last_grade_publish_date: '2025-10-15T14:17:04.368903Z',
has_hidden_contribution: 'some',
short_label: 'HW',
num_droppable: 0,
},
],
});
await fetchAndRender();
// Should show percent + hidden scores for grade and weighted grade
const hiddenScoresCells = screen.getAllByText(/% \+ Hidden Scores/);
expect(hiddenScoresCells).toHaveLength(2);
// Only correct visible scores should be shown (from subsection2)
// The correct visible score is 1/4 = 0.25 -> 25%
expect(hiddenScoresCells[0]).toHaveTextContent('25% + Hidden Scores');
expect(hiddenScoresCells[1]).toHaveTextContent('25% + Hidden Scores');
});
it('displays a warning message with the latest due date when not all assignment scores are included in the total grade', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 0,
num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
assignment_type_grade_summary: [
{
type: 'Homework',
weight: 1,
average_grade: 1,
weighted_grade: 1,
last_grade_publish_date: tomorrow.toISOString(),
has_hidden_contribution: 'none',
short_label: 'HW',
num_droppable: 0,
},
],
});
await fetchAndRender();
const formattedDateTime = new Intl.DateTimeFormat('en', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short',
}).format(tomorrow);
expect(
screen.getByText(
`Some assignment scores are not yet included in your total grade. These grades will be released by ${formattedDateTime}.`,
),
).toBeInTheDocument();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 1 50% 100% 50%' })).toBeInTheDocument();
expect(screen.getByRole('row', { name: 'Exam 50% 0% 0%' })).toBeInTheDocument();
});
it('renders override notice', async () => {
@@ -886,7 +788,7 @@ describe('Progress Tab', () => {
sendTrackEvent.mockClear();
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
const outlineLink = screen.getAllByRole('link', { name: 'Course outline' })[0];
const outlineLink = screen.getAllByRole('link', { name: 'Course Outline' })[0];
fireEvent.click(outlineLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
@@ -907,7 +809,7 @@ describe('Progress Tab', () => {
// Open the problem score drawer
fireEvent.click(problemScoreDrawerToggle);
expect(screen.getAllByText('Graded Scores:').length).toBeGreaterThan(1);
expect(screen.getByText('Problem Scores:')).toBeInTheDocument();
expect(screen.getAllByText('0/1')).toHaveLength(3);
});
@@ -919,14 +821,6 @@ describe('Progress Tab', () => {
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
expect(screen.getByText('You currently have no graded problem scores.')).toBeInTheDocument();
});
it('renders Detailed Grades table when section scores are populated', async () => {
await fetchAndRender();
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
expect(screen.getByText('First subsection'));
expect(screen.getByText('Second subsection'));
});
});
describe('Certificate Status', () => {

View File

@@ -1,22 +1,23 @@
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { FormattedDate, FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
FormattedDate, FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Button, Card } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { useContextId } from '../../../data/hooks';
import { useModel } from '../../../generic/model-store';
import { COURSE_EXIT_MODES, getCourseExitMode } from '../../../courseware/course/course-exit/utils';
import { DashboardLink, IdVerificationSupportLink, ProfileLink } from '../../../shared/links';
import { requestCert } from '../../data/thunks';
import messages from './messages';
import ProgressCertificateStatusSlot from '../../../plugin-slots/ProgressCertificateStatusSlot';
const CertificateStatus = () => {
const intl = useIntl();
const courseId = useContextId();
const CertificateStatus = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
entranceExamData,
@@ -187,8 +188,7 @@ const CertificateStatus = () => {
// regardless of passing or nonpassing status
if (!canViewCertificate) {
certCase = 'notAvailable';
// use the certificate_available_date if it is available, otherwise use the end date of the course
endDate = intl.formatDate((certificateAvailableDate || end), {
endDate = intl.formatDate(end, {
year: 'numeric',
month: 'long',
day: 'numeric',
@@ -215,6 +215,7 @@ const CertificateStatus = () => {
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!certCase) {
return null;
}
@@ -242,32 +243,32 @@ const CertificateStatus = () => {
return (
<section data-testid="certificate-status-component" className="text-dark-700 mb-4">
<Card className="bg-light-200 raised-card">
<ProgressCertificateStatusSlot courseId={courseId}>
<div id={`${certCase}_certificate_status`}>
<Card.Header title={header} />
<Card.Section className="small text-gray-700">
{body}
</Card.Section>
<Card.Footer>
{buttonText && (buttonLocation || buttonAction) && (
<Button
variant="outline-brand"
onClick={() => {
logCertificateStatusButtonClicked(certStatus);
if (buttonAction) { buttonAction(); }
}}
href={buttonLocation}
block
>
{buttonText}
</Button>
)}
</Card.Footer>
</div>
</ProgressCertificateStatusSlot>
<Card.Header title={header} />
<Card.Section className="small text-gray-700">
{body}
</Card.Section>
<Card.Footer>
{buttonText && (buttonLocation || buttonAction) && (
<Button
variant="outline-brand"
onClick={() => {
logCertificateStatusButtonClicked(certStatus);
if (buttonAction) { buttonAction(); }
}}
href={buttonLocation}
block
>
{buttonText}
</Button>
)}
</Card.Footer>
</Card>
</section>
);
};
export default CertificateStatus;
CertificateStatus.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CertificateStatus);

View File

@@ -1,13 +1,12 @@
import { useState } from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { OverlayTrigger, Popover } from '@openedx/paragon';
import messages from './messages';
const CompleteDonutSegment = ({ completePercentage, lockedPercentage }) => {
const intl = useIntl();
const CompleteDonutSegment = ({ completePercentage, intl, lockedPercentage }) => {
const [showCompletePopover, setShowCompletePopover] = useState(false);
if (!completePercentage) {
@@ -83,7 +82,8 @@ const CompleteDonutSegment = ({ completePercentage, lockedPercentage }) => {
CompleteDonutSegment.propTypes = {
completePercentage: PropTypes.number.isRequired,
intl: intlShape.isRequired,
lockedPercentage: PropTypes.number.isRequired,
};
export default CompleteDonutSegment;
export default injectIntl(CompleteDonutSegment);

View File

@@ -1,5 +1,8 @@
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
import { useContextId } from '../../../data/hooks';
import React from 'react';
import { useSelector } from 'react-redux';
import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { useModel } from '../../../generic/model-store';
import CompleteDonutSegment from './CompleteDonutSegment';
@@ -7,9 +10,10 @@ import IncompleteDonutSegment from './IncompleteDonutSegment';
import LockedDonutSegment from './LockedDonutSegment';
import messages from './messages';
const CompletionDonutChart = () => {
const intl = useIntl();
const courseId = useContextId();
const CompletionDonutChart = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
completionSummary: {
@@ -58,4 +62,8 @@ const CompletionDonutChart = () => {
);
};
export default CompletionDonutChart;
CompletionDonutChart.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CompletionDonutChart);

View File

@@ -7,18 +7,18 @@
.donut-chart-label {
font: {
family: var(--pgn-typography-font-family-sans-serif);
family: $font-family-sans-serif;
size: .2rem;
weight: var(--pgn-typography-font-weight-normal);
weight: $font-weight-normal;
}
text-anchor: middle;
}
.donut-chart-number {
font: {
family: var(--pgn-typography-font-family-monospace);
family: $font-family-monospace;
size: .5rem;
weight: var(--pgn-typography-font-weight-bold);
weight: $font-weight-bold;
}
line-height: 1rem;
text-anchor: middle;
@@ -29,7 +29,7 @@
}
.donut-chart-text {
fill: var(--pgn-color-primary-500);
fill: $primary-500;
-moz-transform: translateY(0.25em);
-ms-transform: translateY(0.25em);
-webkit-transform: translateY(0.25em);
@@ -56,7 +56,7 @@
.donut-ring, .donut-segment, .donut-hole {
&.complete-stroke {
stroke: var(--pgn-color-info-500);
stroke: $info-500;
}
&.divider-stroke {
@@ -65,10 +65,10 @@
}
&.incomplete-stroke {
stroke: var(--pgn-color-light-300);
stroke: $light-300;
}
&.locked-stroke {
stroke: var(--pgn-color-primary-500);
stroke: $primary-500;
}
}

View File

@@ -1,26 +1,27 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import CompletionDonutChart from './CompletionDonutChart';
import messages from './messages';
const CourseCompletion = () => {
const intl = useIntl();
return (
<section className="text-dark-700 mb-4 rounded raised-card p-4">
<div className="row w-100 m-0">
<div className="col-12 col-sm-6 col-md-7 p-0">
<h2>{intl.formatMessage(messages.courseCompletion)}</h2>
<p className="small">
{intl.formatMessage(messages.completionBody)}
</p>
</div>
<div className="col-12 col-sm-6 col-md-5 mt-sm-n3 p-0 text-center">
<CompletionDonutChart />
</div>
const CourseCompletion = ({ intl }) => (
<section className="text-dark-700 mb-4 rounded raised-card p-4">
<div className="row w-100 m-0">
<div className="col-12 col-sm-6 col-md-7 p-0">
<h2>{intl.formatMessage(messages.courseCompletion)}</h2>
<p className="small">
{intl.formatMessage(messages.completionBody)}
</p>
</div>
</section>
);
<div className="col-12 col-sm-6 col-md-5 mt-sm-n3 p-0 text-center">
<CompletionDonutChart />
</div>
</div>
</section>
);
CourseCompletion.propTypes = {
intl: intlShape.isRequired,
};
export default CourseCompletion;
export default injectIntl(CourseCompletion);

View File

@@ -1,13 +1,12 @@
import { useState } from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { OverlayTrigger, Popover } from '@openedx/paragon';
import messages from './messages';
const IncompleteDonutSegment = ({ incompletePercentage }) => {
const intl = useIntl();
const IncompleteDonutSegment = ({ incompletePercentage, intl }) => {
const [showIncompletePopover, setShowIncompletePopover] = useState(false);
if (!incompletePercentage) {
@@ -54,6 +53,7 @@ const IncompleteDonutSegment = ({ incompletePercentage }) => {
IncompleteDonutSegment.propTypes = {
incompletePercentage: PropTypes.number.isRequired,
intl: intlShape.isRequired,
};
export default IncompleteDonutSegment;
export default injectIntl(IncompleteDonutSegment);

View File

@@ -1,13 +1,12 @@
import { useState } from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { OverlayTrigger, Popover } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
const LockedDonutSegment = ({ lockedPercentage }) => {
const intl = useIntl();
const LockedDonutSegment = ({ intl, lockedPercentage }) => {
const [showLockedPopover, setShowLockedPopover] = useState(false);
if (!lockedPercentage) {
@@ -66,7 +65,8 @@ const LockedDonutSegment = ({ lockedPercentage }) => {
};
LockedDonutSegment.propTypes = {
intl: intlShape.isRequired,
lockedPercentage: PropTypes.number.isRequired,
};
export default LockedDonutSegment;
export default injectIntl(LockedDonutSegment);

View File

@@ -1,17 +1,19 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { CheckCircle, WarningFilled, WatchFilled } from '@openedx/paragon/icons';
import { Hyperlink, Icon } from '@openedx/paragon';
import { useContextId } from '../../../data/hooks';
import { useModel } from '../../../generic/model-store';
import { DashboardLink } from '../../../shared/links';
import messages from './messages';
const CreditInformation = () => {
const intl = useIntl();
const courseId = useContextId();
const CreditInformation = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
creditCourseRequirements,
@@ -34,13 +36,36 @@ const CreditInformation = () => {
switch (creditCourseRequirements.eligibilityStatus) {
case 'not_eligible':
eligibilityStatus = intl.formatMessage(messages.creditNotEligibleStatus, { creditLink });
eligibilityStatus = (
<FormattedMessage
id="progress.creditInformation.creditNotEligible"
defaultMessage="You are no longer eligible for credit in this course. Learn more about {creditLink}."
description="Message to learner who are not eligible for course credit, it can because the a requirement deadline have passed"
values={{ creditLink }}
/>
);
break;
case 'eligible':
eligibilityStatus = intl.formatMessage(messages.creditEligibleStatus, { dashboardLink, creditLink });
eligibilityStatus = (
<FormattedMessage
id="progress.creditInformation.creditEligible"
defaultMessage="
You have met the requirements for credit in this course. Go to your
{dashboardLink} to purchase course credit. Or learn more about {creditLink}."
description="After the credit requirements are met, leaners can then do the last step which purchasing the credit. Note that is only doable for leaners after they met all the requirements"
values={{ dashboardLink, creditLink }}
/>
);
break;
case 'partial_eligible':
eligibilityStatus = intl.formatMessage(messages.creditPartialEligibleStatus, { creditLink });
eligibilityStatus = (
<FormattedMessage
id="progress.creditInformation.creditPartialEligible"
defaultMessage="You have not yet met the requirements for credit. Learn more about {creditLink}."
description="This means that one or more requirements is not satisfied yet"
values={{ creditLink }}
/>
);
break;
default:
break;
@@ -83,4 +108,8 @@ const CreditInformation = () => {
);
};
export default CreditInformation;
CreditInformation.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CreditInformation);

View File

@@ -35,22 +35,6 @@ const messages = defineMessages({
defaultMessage: 'Verification submitted',
description: 'It indicate that the learner submitted a requirement but is not graded or reviewed yet',
},
creditNotEligibleStatus: {
id: 'progress.creditInformation.creditNotEligible',
defaultMessage: 'You are no longer eligible for credit in this course. Learn more about {creditLink}.',
description: 'Message to learner who are not eligible for course credit, it can be that a requirement deadline has passed',
},
creditEligibleStatus: {
id: 'progress.creditInformation.creditEligible',
defaultMessage: `You have met the requirements for credit in this course. Go to your
{dashboardLink} to purchase course credit. Or learn more about {creditLink}.`,
description: 'After the credit requirements are met, leaners can then do the last step which purchasing the credit. Note that is only doable for leaners after they met all the requirements',
},
creditPartialEligibleStatus: {
id: 'progress.creditInformation.creditPartialEligible',
defaultMessage: 'You have not yet met the requirements for credit. Learn more about {creditLink}.',
description: 'This means that one or more requirements is not satisfied yet',
},
});
export default messages;

View File

@@ -1,5 +1,6 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { useContextId } from '../../../../data/hooks';
import React from 'react';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useModel } from '../../../../generic/model-store';
@@ -10,9 +11,10 @@ import CreditInformation from '../../credit-information/CreditInformation';
import messages from '../messages';
const CourseGrade = () => {
const intl = useIntl();
const courseId = useContextId();
const CourseGrade = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
creditCourseRequirements,
@@ -52,4 +54,8 @@ const CourseGrade = () => {
);
};
export default CourseGrade;
CourseGrade.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseGrade);

View File

@@ -1,64 +1,35 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { CheckCircle, WarningFilled } from '@openedx/paragon/icons';
import { breakpoints, Icon, useWindowSize } from '@openedx/paragon';
import { useContextId } from '../../../../data/hooks';
import { useModel } from '../../../../generic/model-store';
import GradeRangeTooltip from './GradeRangeTooltip';
import messages from '../messages';
import { getLatestDueDateInFuture } from '../../utils';
const ResponsiveText = ({
wideScreen, children, hasLetterGrades, passingGrade,
}) => {
const className = wideScreen ? 'h4 m-0 align-bottom' : 'h5 align-bottom';
const iconSize = wideScreen ? 'h3' : 'h4';
return (
<span className={className}>
{children}
{hasLetterGrades && (
<span style={{ whiteSpace: 'nowrap' }}>
&nbsp;
<GradeRangeTooltip iconButtonClassName={iconSize} passingGrade={passingGrade} />
</span>
)}
</span>
);
};
const NoticeRow = ({
wideScreen, icon, bgClass, message,
}) => {
const textClass = wideScreen ? 'h4 m-0 align-bottom' : 'h5 align-bottom';
return (
<div className={`row w-100 m-0 px-4 py-3 py-md-4 rounded-bottom ${bgClass}`}>
<div className="col-auto p-0">{icon}</div>
<div className="col-11 pl-2 px-0">
<span className={textClass}>{message}</span>
</div>
</div>
);
};
const CourseGradeFooter = ({ passingGrade }) => {
const intl = useIntl();
const courseId = useContextId();
const CourseGradeFooter = ({ intl, passingGrade }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
assignmentTypeGradeSummary,
courseGrade: { isPassing, letterGrade },
gradingPolicy: { gradeRange },
courseGrade: {
isPassing,
letterGrade,
},
gradingPolicy: {
gradeRange,
},
} = useModel('progress', courseId);
const latestDueDate = getLatestDueDateInFuture(assignmentTypeGradeSummary);
const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth;
const hasLetterGrades = Object.keys(gradeRange).length > 1;
// build footer text
const hasLetterGrades = Object.keys(gradeRange).length > 1; // A pass/fail course will only have one key
let footerText = intl.formatMessage(messages.courseGradeFooterNonPassing, { passingGrade });
if (isPassing) {
if (hasLetterGrades) {
const minGradeRangeCutoff = gradeRange[letterGrade] * 100;
@@ -78,65 +49,45 @@ const CourseGradeFooter = ({ passingGrade }) => {
}
}
const passingIcon = isPassing ? (
<Icon src={CheckCircle} className="text-success-300 d-inline-flex align-bottom" />
) : (
<Icon src={WarningFilled} className="d-inline-flex align-bottom" />
);
const icon = isPassing ? <Icon src={CheckCircle} className="text-success-300 d-inline-flex align-bottom" />
: <Icon src={WarningFilled} className="d-inline-flex align-bottom" />;
return (
<div>
<NoticeRow
wideScreen={wideScreen}
icon={passingIcon}
bgClass={isPassing ? 'bg-success-100' : 'bg-warning-100'}
message={(
<ResponsiveText
wideScreen={wideScreen}
hasLetterGrades={hasLetterGrades}
passingGrade={passingGrade}
>
<div className={`row w-100 m-0 px-4 py-3 py-md-4 rounded-bottom ${isPassing ? 'bg-success-100' : 'bg-warning-100'}`}>
<div className="col-auto p-0">
{icon}
</div>
<div className="col-11 pl-2 px-0">
{!wideScreen && (
<span className="h5 align-bottom">
{footerText}
</ResponsiveText>
{hasLetterGrades && (
<span style={{ whiteSpace: 'nowrap' }}>
&nbsp;
<GradeRangeTooltip iconButtonClassName="h4" passingGrade={passingGrade} />
</span>
)}
</span>
)}
/>
{latestDueDate && (
<NoticeRow
wideScreen={wideScreen}
icon={<Icon src={WarningFilled} className="d-inline-flex align-bottom" />}
bgClass="bg-warning-100"
message={intl.formatMessage(messages.courseGradeFooterDueDateNotice, {
dueDate: intl.formatDate(latestDueDate, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short',
}),
})}
/>
)}
{wideScreen && (
<span className="h4 m-0 align-bottom">
{footerText}
{hasLetterGrades && (
<span style={{ whiteSpace: 'nowrap' }}>
&nbsp;
<GradeRangeTooltip iconButtonClassName="h3" passingGrade={passingGrade} />
</span>
)}
</span>
)}
</div>
</div>
);
};
ResponsiveText.propTypes = {
wideScreen: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired,
hasLetterGrades: PropTypes.bool.isRequired,
passingGrade: PropTypes.number.isRequired,
};
NoticeRow.propTypes = {
wideScreen: PropTypes.bool.isRequired,
icon: PropTypes.element.isRequired,
bgClass: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
};
CourseGradeFooter.propTypes = {
intl: intlShape.isRequired,
passingGrade: PropTypes.number.isRequired,
};
export default CourseGradeFooter;
export default injectIntl(CourseGradeFooter);

View File

@@ -1,16 +1,19 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Locked } from '@openedx/paragon/icons';
import { Button, Icon } from '@openedx/paragon';
import { useContextId } from '../../../../data/hooks';
import { useModel } from '../../../../generic/model-store';
import messages from '../messages';
const CourseGradeHeader = () => {
const intl = useIntl();
const courseId = useContextId();
const CourseGradeHeader = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
org,
} = useModel('courseHomeMeta', courseId);
@@ -48,7 +51,7 @@ const CourseGradeHeader = () => {
previewText = intl.formatMessage(messages.courseGradePreviewUpgradeDeadlinePassedBody);
}
return (
<div id="grade-course-header" className="row w-100 m-0 p-4 rounded-top bg-primary-500 text-white">
<div className="row w-100 m-0 p-4 rounded-top bg-primary-500 text-white">
<div className={`col-12 ${verifiedMode ? 'col-md-9' : ''} p-0`}>
<div className="row w-100 m-0 p-0">
<div className="col-1 p-0">
@@ -71,7 +74,7 @@ const CourseGradeHeader = () => {
</div>
{verifiedMode && (
<div className="col-12 col-md-3 mt-3 mt-md-0 p-0 align-self-center text-right">
<Button id="upgrade-button" variant="brand" size="sm" href={verifiedMode.upgradeUrl} onClick={logUpgradeButtonClick}>
<Button variant="brand" size="sm" href={verifiedMode.upgradeUrl} onClick={logUpgradeButtonClick}>
{intl.formatMessage(messages.courseGradePreviewUpgradeButton)}
</Button>
</div>
@@ -80,4 +83,8 @@ const CourseGradeHeader = () => {
);
};
export default CourseGradeHeader;
CourseGradeHeader.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseGradeHeader);

View File

@@ -1,19 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { OverlayTrigger, Popover } from '@openedx/paragon';
import { useContextId } from '../../../../data/hooks';
import { useModel } from '../../../../generic/model-store';
import messages from '../messages';
const CurrentGradeTooltip = ({ tooltipClassName }) => {
const intl = useIntl();
const courseId = useContextId();
const CurrentGradeTooltip = ({ intl, tooltipClassName }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
assignmentTypeGradeSummary,
courseGrade: {
isPassing,
percent,
@@ -26,8 +29,6 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => {
const isLocaleRtl = isRtl(getLocale());
const hasHiddenGrades = assignmentTypeGradeSummary.some((assignmentType) => assignmentType.hasHiddenContribution !== 'none');
if (isLocaleRtl) {
currentGradeDirection = currentGrade < 50 ? '-' : '';
}
@@ -59,15 +60,6 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => {
>
{intl.formatMessage(messages.currentGradeLabel)}
</text>
<text
className="x-small"
textAnchor={currentGrade < 50 ? 'start' : 'end'}
x={`${Math.min(...[isLocaleRtl ? 100 - currentGrade : currentGrade, 100])}%`}
y="35px"
style={{ transform: `translateX(${currentGradeDirection}3.4em)` }}
>
{hasHiddenGrades ? ` + ${intl.formatMessage(messages.hiddenScoreLabel)}` : ''}
</text>
</>
);
};
@@ -77,7 +69,8 @@ CurrentGradeTooltip.defaultProps = {
};
CurrentGradeTooltip.propTypes = {
intl: intlShape.isRequired,
tooltipClassName: PropTypes.string,
};
export default CurrentGradeTooltip;
export default injectIntl(CurrentGradeTooltip);

View File

@@ -1,16 +1,20 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
import { useContextId } from '../../../../data/hooks';
import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { useModel } from '../../../../generic/model-store';
import CurrentGradeTooltip from './CurrentGradeTooltip';
import PassingGradeTooltip from './PassingGradeTooltip';
import messages from '../messages';
const GradeBar = ({ passingGrade }) => {
const intl = useIntl();
const courseId = useContextId();
const GradeBar = ({ intl, passingGrade }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
courseGrade: {
@@ -48,7 +52,8 @@ const GradeBar = ({ passingGrade }) => {
};
GradeBar.propTypes = {
intl: intlShape.isRequired,
passingGrade: PropTypes.number.isRequired,
};
export default GradeBar;
export default injectIntl(GradeBar);

View File

@@ -4,24 +4,24 @@
}
.grade-bar__base {
fill: var(--pgn-color-light-300);
fill: $light-300;
}
.grade-bar__divider {
fill: var(--pgn-color-primary-500);
fill: $primary-500;
width: 1px;
}
.grade-bar--passing {
fill: var(--pgn-color-primary-500);
fill: $primary-500;
}
.grade-bar--current-passing {
fill: var(--pgn-color-success-500);
fill: $success-500;
}
.grade-bar--current-non-passing {
fill: var(--pgn-color-accent-b);
fill: $accent-b;
}
}
@@ -31,22 +31,22 @@
#minimum-grade-tooltip {
.arrow::after {
border-bottom-color: var(--pgn-color-primary-500);
border-bottom-color: $primary-500;
}
}
#passing-grade-tooltip {
background: var(--pgn-color-success-500);
background: $success-500;
.arrow::after {
border-top-color: var(--pgn-color-success-500);
border-top-color: $success-500;
}
}
#non-passing-grade-tooltip {
background: var(--pgn-color-accent-b);
background: $accent-b;
.arrow::after {
border-top-color: var(--pgn-color-accent-b);
border-top-color: $accent-b;
}
}

View File

@@ -1,19 +1,20 @@
import { useState } from 'react';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { InfoOutline } from '@openedx/paragon/icons';
import {
Icon, IconButton, OverlayTrigger, Popover,
} from '@openedx/paragon';
import { useContextId } from '../../../../data/hooks';
import { useModel } from '../../../../generic/model-store';
import messages from '../messages';
const GradeRangeTooltip = ({ iconButtonClassName, passingGrade }) => {
const intl = useIntl();
const courseId = useContextId();
const GradeRangeTooltip = ({ intl, iconButtonClassName, passingGrade }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
gradesFeatureIsFullyLocked,
@@ -79,7 +80,8 @@ GradeRangeTooltip.defaultProps = {
GradeRangeTooltip.propTypes = {
iconButtonClassName: PropTypes.string,
intl: intlShape.isRequired,
passingGrade: PropTypes.number.isRequired,
};
export default GradeRangeTooltip;
export default injectIntl(GradeRangeTooltip);

View File

@@ -1,12 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { OverlayTrigger, Popover } from '@openedx/paragon';
import messages from '../messages';
const PassingGradeTooltip = ({ passingGrade, tooltipClassName }) => {
const intl = useIntl();
const PassingGradeTooltip = ({ intl, passingGrade, tooltipClassName }) => {
const isLocaleRtl = isRtl(getLocale());
let passingGradeDirection = passingGrade < 50 ? '' : '-';
@@ -52,8 +54,9 @@ PassingGradeTooltip.defaultProps = {
};
PassingGradeTooltip.propTypes = {
intl: intlShape.isRequired,
passingGrade: PropTypes.number.isRequired,
tooltipClassName: PropTypes.string,
};
export default PassingGradeTooltip;
export default injectIntl(PassingGradeTooltip);

View File

@@ -1,20 +1,22 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Locked } from '@openedx/paragon/icons';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Blocked } from '@openedx/paragon/icons';
import { Icon, Hyperlink } from '@openedx/paragon';
import { useContextId } from '../../../../data/hooks';
import { useModel } from '../../../../generic/model-store';
import { showUngradedAssignments } from '../../utils';
import DetailedGradesTable from './DetailedGradesTable';
import messages from '../messages';
const DetailedGrades = () => {
const intl = useIntl();
const DetailedGrades = ({ intl }) => {
const { administrator } = getAuthenticatedUser();
const courseId = useContextId();
const {
courseId,
} = useSelector(state => state.courseHome);
const {
org,
tabs,
@@ -26,8 +28,6 @@ const DetailedGrades = () => {
} = useModel('progress', courseId);
const hasSectionScores = sectionScores.length > 0;
const emptyTableMsg = showUngradedAssignments()
? messages.detailedGradesEmpty : messages.detailedGradesEmptyOnlyGraded;
const logOutlineLinkClick = () => {
sendTrackEvent('edx.ui.lms.course_progress.detailed_grades.course_outline_link.clicked', {
@@ -54,36 +54,35 @@ const DetailedGrades = () => {
return (
<section className="text-dark-700">
<h3 className="h4">{intl.formatMessage(messages.detailedGrades)}</h3>
<ul className="micro mb-3 pl-3 text-gray-700">
<li>
<b>{intl.formatMessage(messages.practiceScoreLabel)} </b>
{intl.formatMessage(messages.practiceScoreInfoText)}
</li>
<li>
<b>{intl.formatMessage(messages.gradedScoreLabel)} </b>
{intl.formatMessage(messages.gradedScoreInfoText)}
</li>
</ul>
<h3 className="h4 mb-3">{intl.formatMessage(messages.detailedGrades)}</h3>
{gradesFeatureIsPartiallyLocked && (
<div className="mb-3 small ml-0 d-inline">
<Icon className="mr-1 mt-1 d-inline-flex" style={{ height: '1rem', width: '1rem' }} src={Locked} data-testid="locked-icon" />
{intl.formatMessage(messages.gradeSummaryLimitedAccessExplanation, { upgradeLink: '' })}
<Icon className="mr-1 mt-1 d-inline-flex" style={{ height: '1rem', width: '1rem' }} src={Blocked} data-testid="blocked-icon" />
{intl.formatMessage(messages.gradeSummaryLimitedAccessExplanation)}
</div>
)}
{hasSectionScores && (
<DetailedGradesTable />
)}
{!hasSectionScores && (
<p className="small">{intl.formatMessage(emptyTableMsg)}</p>
<p className="small">{intl.formatMessage(messages.detailedGradesEmpty)}</p>
)}
{overviewTabUrl && !showUngradedAssignments() && (
{overviewTabUrl && (
<p className="x-small m-0">
{intl.formatMessage(messages.ungradedAlert, { outlineLink })}
<FormattedMessage
id="progress.ungradedAlert"
defaultMessage="For progress on ungraded aspects of the course, view your {outlineLink}."
description="Text that precede link that redirect to course outline page"
values={{ outlineLink }}
/>
</p>
)}
</section>
);
};
export default DetailedGrades;
DetailedGrades.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(DetailedGrades);

View File

@@ -1,15 +1,19 @@
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
import React from 'react';
import { useSelector } from 'react-redux';
import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { DataTable } from '@openedx/paragon';
import { useContextId } from '../../../../data/hooks';
import { useModel } from '../../../../generic/model-store';
import messages from '../messages';
import SubsectionTitleCell from './SubsectionTitleCell';
import { showUngradedAssignments } from '../../utils';
const DetailedGradesTable = () => {
const intl = useIntl();
const courseId = useContextId();
const DetailedGradesTable = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
sectionScores,
@@ -20,10 +24,9 @@ const DetailedGradesTable = () => {
sectionScores.map((chapter) => {
const subsectionScores = chapter.subsections.filter(
(subsection) => !!(
(showUngradedAssignments() || subsection.hasGradedAssignment)
&& subsection.showGrades
&& (subsection.numPointsPossible > 0 || subsection.numPointsEarned > 0)
),
subsection.hasGradedAssignment
&& subsection.showGrades
&& (subsection.numPointsPossible > 0 || subsection.numPointsEarned > 0)),
);
if (subsectionScores.length === 0) {
@@ -63,4 +66,8 @@ const DetailedGradesTable = () => {
);
};
export default DetailedGradesTable;
DetailedGradesTable.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(DetailedGradesTable);

View File

@@ -1,19 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import messages from '../messages';
const ProblemScoreDrawer = ({ problemScores, subsection }) => {
const intl = useIntl();
const ProblemScoreDrawer = ({ intl, problemScores, subsection }) => {
const isLocaleRtl = isRtl(getLocale());
const scoreLabel = subsection.hasGradedAssignment ? messages.gradedScoreLabel : messages.practiceScoreLabel;
return (
<span className="row w-100 m-0 x-small ml-4 pt-2 pl-1 text-gray-700 flex-nowrap">
<span id="problem-score-label" className="col-auto p-0">{intl.formatMessage(scoreLabel)}</span>
<span id="problem-score-label" className="col-auto p-0">{intl.formatMessage(messages.problemScoreLabel)}</span>
<div className={classNames('col', 'p-0', { 'greyed-out': !subsection.learnerHasAccess })}>
<ul className="list-unstyled row w-100 m-0" aria-labelledby="problem-score-label">
{problemScores.map((problemScore, i) => (
@@ -27,14 +26,12 @@ const ProblemScoreDrawer = ({ problemScores, subsection }) => {
};
ProblemScoreDrawer.propTypes = {
intl: intlShape.isRequired,
problemScores: PropTypes.arrayOf(PropTypes.shape({
earned: PropTypes.number.isRequired,
possible: PropTypes.number.isRequired,
})).isRequired,
subsection: PropTypes.shape({
learnerHasAccess: PropTypes.bool,
hasGradedAssignment: PropTypes.bool,
}).isRequired,
subsection: PropTypes.shape({ learnerHasAccess: PropTypes.bool }).isRequired,
};
export default ProblemScoreDrawer;
export default injectIntl(ProblemScoreDrawer);

View File

@@ -1,24 +1,23 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Collapsible, Icon, Row } from '@openedx/paragon';
import {
ArrowDropDown,
ArrowDropUp,
Info,
Locked,
ArrowDropDown, ArrowDropUp, Blocked, Info,
} from '@openedx/paragon/icons';
import { useContextId } from '../../../../data/hooks';
import messages from '../messages';
import { useModel } from '../../../../generic/model-store';
import ProblemScoreDrawer from './ProblemScoreDrawer';
const SubsectionTitleCell = ({ subsection }) => {
const intl = useIntl();
const courseId = useContextId();
const SubsectionTitleCell = ({ intl, subsection }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
org,
} = useModel('courseHomeMeta', courseId);
@@ -62,8 +61,8 @@ const SubsectionTitleCell = ({ subsection }) => {
aria-label={intl.formatMessage(messages.noAccessToSubsection, { displayName })}
className="mr-1 mt-1 d-inline-flex"
style={{ height: '1rem', width: '1rem' }}
src={Locked}
data-testid="locked-icon"
src={Blocked}
data-testid="blocked-icon"
/>
)}
{url ? (
@@ -103,6 +102,7 @@ const SubsectionTitleCell = ({ subsection }) => {
};
SubsectionTitleCell.propTypes = {
intl: intlShape.isRequired,
subsection: PropTypes.shape({
blockKey: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
@@ -119,4 +119,4 @@ SubsectionTitleCell.propTypes = {
}).isRequired,
};
export default SubsectionTitleCell;
export default injectIntl(SubsectionTitleCell);

View File

@@ -1,22 +1,24 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Locked } from '@openedx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Blocked } from '@openedx/paragon/icons';
import { Icon } from '@openedx/paragon';
import { useContextId } from '../../../../data/hooks';
import { useModel } from '../../../../generic/model-store';
import messages from '../messages';
const AssignmentTypeCell = ({
assignmentType, footnoteMarker, footnoteId, locked,
intl, assignmentType, footnoteMarker, footnoteId, locked,
}) => {
const intl = useIntl();
const courseId = useContextId();
const {
courseId,
} = useSelector(state => state.courseHome);
const {
gradesFeatureIsFullyLocked,
} = useModel('progress', courseId);
const lockedIcon = locked ? <Icon id={`assignmentTypeBlockedIcon${assignmentType}`} aria-label={intl.formatMessage(messages.noAccessToAssignmentType, { assignmentType })} className="mr-1 mt-1 d-inline-flex" style={{ height: '1rem', width: '1rem' }} src={Locked} data-testid="locked-icon" /> : '';
const lockedIcon = locked ? <Icon id={`assignmentTypeBlockedIcon${assignmentType}`} aria-label={intl.formatMessage(messages.noAccessToAssignmentType, { assignmentType })} className="mr-1 mt-1 d-inline-flex" style={{ height: '1rem', width: '1rem' }} src={Blocked} data-testid="blocked-icon" /> : '';
return (
<div className="d-flex small">
@@ -43,6 +45,7 @@ const AssignmentTypeCell = ({
};
AssignmentTypeCell.propTypes = {
intl: intlShape.isRequired,
assignmentType: PropTypes.string.isRequired,
footnoteId: PropTypes.string,
footnoteMarker: PropTypes.number,
@@ -55,4 +58,4 @@ AssignmentTypeCell.defaultProps = {
locked: false,
};
export default AssignmentTypeCell;
export default injectIntl(AssignmentTypeCell);

View File

@@ -1,14 +1,16 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useContextId } from '../../../../data/hooks';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import { useModel } from '../../../../generic/model-store';
const DroppableAssignmentFootnote = ({ footnotes }) => {
const intl = useIntl();
const courseId = useContextId();
const DroppableAssignmentFootnote = ({ footnotes, intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
gradesFeatureIsFullyLocked,
} = useModel('progress', courseId);
@@ -19,10 +21,14 @@ const DroppableAssignmentFootnote = ({ footnotes }) => {
{footnotes.map((footnote, index) => (
<li id={`${footnote.id}-footnote`} key={footnote.id} className="x-small mt-1">
<sup>{index + 1}</sup>
{intl.formatMessage(messages.droppableAssignmentsText, {
numDroppable: footnote.numDroppable,
assignmentType: footnote.assignmentType,
})}
<FormattedMessage
id="progress.footnotes.droppableAssignments"
defaultMessage="The lowest {numDroppable, plural, one{# {assignmentType} score is} other{# {assignmentType} scores are}} dropped."
values={{
numDroppable: footnote.numDroppable,
assignmentType: footnote.assignmentType,
}}
/>
<a className="sr-only" href={`#${footnote.id}-ref`} tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}>
{intl.formatMessage(messages.backToContent)}
</a>
@@ -39,6 +45,7 @@ DroppableAssignmentFootnote.propTypes = {
id: PropTypes.string.isRequired,
numDroppable: PropTypes.number.isRequired,
})).isRequired,
intl: intlShape.isRequired,
};
export default DroppableAssignmentFootnote;
export default injectIntl(DroppableAssignmentFootnote);

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