Compare commits

..

1 Commits

Author SHA1 Message Date
Marcos
1a5ae3939b feat: Updated Courseware Search results for SR 2025-03-24 17:28:47 -03:00
263 changed files with 7993 additions and 10710 deletions

4
.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=''
@@ -51,5 +49,3 @@ TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
# Fallback in local style files
PARAGON_THEME_URLS={}

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=''
@@ -53,5 +51,3 @@ 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={}

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=''
@@ -50,5 +48,3 @@ 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

@@ -40,10 +40,9 @@ pull_translations:
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-lib-special-exams/src/i18n/messages:frontend-lib-special-exams \
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning \
$(ATLAS_EXTRA_SOURCES)
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-lib-special-exams frontend-app-learning $(ATLAS_EXTRA_INTL_IMPORTS)
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-lib-special-exams frontend-app-learning
# This target is used by Travis.

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:

9114
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,11 +15,11 @@
"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 .",
"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": "fedx-scripts jest --coverage --passWithNoTests",
"test:watch": "fedx-scripts jest --watch --passWithNoTests",
"types": "tsc --noEmit"
},
@@ -35,20 +35,20 @@
"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": "^6.2.0",
"@edx/frontend-lib-learning-assistant": "^2.24.0",
"@edx/frontend-lib-special-exams": "^4.0.0",
"@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.7.0",
"@edx/react-unit-test-utils": "^4.0.0",
"@edx/frontend-component-header": "^5.8.0",
"@edx/frontend-lib-learning-assistant": "^2.19.2",
"@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.5.0",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.4.5",
"@openedx/frontend-build": "^14.2.0",
"@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",
"buffer": "^6.0.3",
@@ -58,11 +58,12 @@
"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",
@@ -78,8 +79,9 @@
"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/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "14.6.1",
"axios-mock-adapter": "2.1.0",
"bundlewatch": "^0.4.0",
@@ -93,7 +95,7 @@
"files": [
{
"path": "dist/*.js",
"maxSize": "1450kB"
"maxSize": "1400kB"
}
],
"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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,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',

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();
@@ -64,6 +63,7 @@ export const CoursewareSearchResultsFilter = () => {
variant="tabs"
activeKey={activeKey}
onSelect={setFilter}
aria-label={intl.formatMessage(messages.searchResultsFilterDescription)}
>
{filters.filter(({ count }) => (count > 0)).map(({ key, label }) => (
<Tab key={key} eventKey={key} title={label} data-testid={`courseware-search-results-tabs-${key}`}>
@@ -74,4 +74,8 @@ export const CoursewareSearchResultsFilter = () => {
);
};
export default CoursewareSearchResultsFilter;
CoursewareSearchResultsFilter.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearchResultsFilter);

View File

@@ -141,7 +141,15 @@ const CoursewareSearch = ({ ...sectionProps }) => {
</Button>
</div>
</div>
<div className="courseware-search__results" aria-live="polite" data-testid="courseware-search-results">
<div
key={status}
className="courseware-search__results"
aria-live="assertive"
aria-atomic="true"
aria-busy={status === 'loading'}
data-testid="courseware-search-results"
role={status === 'results' ? 'alert' : 'none'}
>
{status === 'loading' ? (
<div className="courseware-search__spinner" data-testid="courseware-search-spinner">
<Spinner animation="border" variant="light" screenReaderText={formatMessage(messages.loading)} />
@@ -157,8 +165,6 @@ const CoursewareSearch = ({ ...sectionProps }) => {
{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>

View File

@@ -244,7 +244,7 @@ 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 wrapper div with proper aria attributes', () => {
mockModels({
searchKeyword: 'fubar',
total: 1,
@@ -253,7 +253,9 @@ describe('CoursewareSearch', () => {
const results = screen.queryByTestId('courseware-search-results');
expect(results).toHaveAttribute('aria-live', 'polite');
expect(results).toHaveAttribute('aria-live', 'assertive');
expect(results).toHaveAttribute('aria-atomic', 'true');
expect(results).toHaveAttribute('role', 'alert');
expect(within(results).queryByTestId('courseware-search-summary').textContent).toBe('Results for "fubar":');
});
});

View File

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

View File

@@ -1,4 +1,4 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Folder, TextFields, VideoCamera, Article,
} from '@openedx/paragon/icons';
@@ -6,6 +6,7 @@ import { getConfig } from '@edx/frontend-platform';
import { Icon } from '@openedx/paragon';
import PropTypes from 'prop-types';
import CoursewareSearchEmpty from './CoursewareSearchEmpty';
import messages from './messages';
const iconTypeMapping = {
text: TextFields,
@@ -21,6 +22,8 @@ const CoursewareSearchResults = ({ results = [] }) => {
return <CoursewareSearchEmpty />;
}
const { formatMessage } = useIntl();
const baseUrl = `${getConfig().LMS_BASE_URL}`;
return (
@@ -42,24 +45,30 @@ const CoursewareSearchResults = ({ results = [] }) => {
rel: 'nofollow',
} : { href: `${baseUrl}${url}` };
const ariaSeaparator = formatMessage(messages.searchResultsBreadcrumbSeparator);
const ariaLocation = location?.length ? formatMessage(messages.searchResultsBreadcrumb, { path: location.join(ariaSeaparator) }) : '';
return (
<a key={id} className="courseware-search-results__item" {...linkProps}>
<div className="courseware-search-results__icon"><Icon src={icon} /></div>
<div className="courseware-search-results__info">
<div className="courseware-search-results__title">
<span>{title}</span>
{contentHits ? (<em>{contentHits}</em>) : null }
<h3>{title}</h3>
{contentHits ? (<em aria-hidden="true">{contentHits}</em>) : null }
</div>
{location?.length ? (
<ul className="courseware-search-results__breadcrumbs">
{
<div aria-label={ariaLocation}>
{location?.length ? (
<ul className="courseware-search-results__breadcrumbs" aria-hidden="true">
{
// This ignore is necessary because the breadcrumb texts might have duplicates.
// The breadcrumbs are not expected to change.
// eslint-disable-next-line react/no-array-index-key
location.map((breadcrumb, i) => (<li key={`${i}:${breadcrumb}`}><div>{breadcrumb}</div></li>))
}
</ul>
) : null}
</ul>
) : null}
</div>
</div>
</a>
);

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

@@ -38,24 +38,29 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Demo Course Overview
</span>
</h3>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: Introduction, then Demo Course Overview."
>
<li>
<div>
Introduction
</div>
</li>
<li>
<div>
Demo Course Overview
</div>
</li>
</ul>
<ul
aria-hidden="true"
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Introduction
</div>
</li>
<li>
<div>
Demo Course Overview
</div>
</li>
</ul>
</div>
</div>
</a>
<a
@@ -91,32 +96,39 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Passing a Course
</span>
<em>
</h3>
<em
aria-hidden="true"
>
1
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: About Exams and Certificates, then edX Exams, then Passing a Course."
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
Passing a Course
</div>
</li>
</ul>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -152,29 +164,34 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Passing a Course
</span>
</h3>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: About Exams and Certificates, then edX Exams, then Passing a Course."
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
Passing a Course
</div>
</li>
</ul>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -210,29 +227,34 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Text Input
</span>
</h3>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: Example Week 1: Getting Started, then Homework - Question Styles, then Text input."
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Homework - Question Styles
</div>
</li>
<li>
<div>
Text input
</div>
</li>
</ul>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -268,29 +290,34 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Pointing on a Picture
</span>
</h3>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: Example Week 1: Getting Started, then Homework - Question Styles, then Pointing on a Picture."
>
<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>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -326,29 +353,34 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Getting Answers
</span>
</h3>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: About Exams and Certificates, then edX Exams, then Getting Answers."
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
Getting Answers
</div>
</li>
</ul>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -384,32 +416,39 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Welcome!
</span>
<em>
</h3>
<em
aria-hidden="true"
>
30
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: Introduction, then Demo Course Overview, then Introduction: Video and Sequences."
>
<li>
<div>
Introduction
</div>
</li>
<li>
<div>
Demo Course Overview
</div>
</li>
<li>
<div>
Introduction: Video and Sequences
</div>
</li>
</ul>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -445,29 +484,34 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Multiple Choice Questions
</span>
</h3>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: Example Week 1: Getting Started, then Homework - Question Styles, then Multiple Choice Questions."
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Homework - Question Styles
</div>
</li>
<li>
<div>
Multiple Choice Questions
</div>
</li>
</ul>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -503,29 +547,34 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Numerical Input
</span>
</h3>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: Example Week 1: Getting Started, then Homework - Question Styles, then Numerical Input."
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Homework - Question Styles
</div>
</li>
<li>
<div>
Numerical Input
</div>
</li>
</ul>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -561,32 +610,39 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Connecting a Circuit and a Circuit Diagram
</span>
<em>
</h3>
<em
aria-hidden="true"
>
3
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: Example Week 1: Getting Started, then Lesson 1 - Getting Started, then Video Presentation Styles."
>
<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>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -622,29 +678,34 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
CAPA
</span>
</h3>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: Example Week 2: Get Interactive, then Homework - Labs and Demos, then Code Grader."
>
<li>
<div>
Example Week 2: Get Interactive
</div>
</li>
<li>
<div>
Homework - Labs and Demos
</div>
</li>
<li>
<div>
Code Grader
</div>
</li>
</ul>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -680,29 +741,34 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Interactive Questions
</span>
</h3>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: Example Week 1: Getting Started, then Lesson 1 - Getting Started, then Interactive Questions."
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Lesson 1 - Getting Started
</div>
</li>
<li>
<div>
Interactive Questions
</div>
</li>
</ul>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -738,32 +804,39 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Blank HTML Page
</span>
<em>
</h3>
<em
aria-hidden="true"
>
6
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: Introduction, then Demo Course Overview, then Introduction: Video and Sequences."
>
<li>
<div>
Introduction
</div>
</li>
<li>
<div>
Demo Course Overview
</div>
</li>
<li>
<div>
Introduction: Video and Sequences
</div>
</li>
</ul>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -799,32 +872,39 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Discussion Forums
</span>
<em>
</h3>
<em
aria-hidden="true"
>
5
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: Example Week 3: Be Social, then Lesson 3 - Be Social, then Discussion Forums."
>
<li>
<div>
Example Week 3: Be Social
</div>
</li>
<li>
<div>
Lesson 3 - Be Social
</div>
</li>
<li>
<div>
Discussion Forums
</div>
</li>
</ul>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -860,32 +940,39 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Overall Grade
</span>
<em>
</h3>
<em
aria-hidden="true"
>
7
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: About Exams and Certificates, then edX Exams, then Overall Grade Performance."
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
Overall Grade Performance
</div>
</li>
</ul>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -921,32 +1008,39 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Blank HTML Page
</span>
<em>
</h3>
<em
aria-hidden="true"
>
3
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: Example Week 3: Be Social, then Lesson 3 - Be Social, then Homework - Find Your Study Buddy."
>
<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>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -982,32 +1076,39 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Find Your Study Buddy
</span>
<em>
</h3>
<em
aria-hidden="true"
>
3
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: Example Week 3: Be Social, then Homework - Find Your Study Buddy, then Homework - Find Your Study Buddy."
>
<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>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -1043,32 +1144,39 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
Be Social
</span>
<em>
</h3>
<em
aria-hidden="true"
>
4
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: Example Week 3: Be Social, then Lesson 3 - Be Social, then Be Social."
>
<li>
<div>
Example Week 3: Be Social
</div>
</li>
<li>
<div>
Lesson 3 - Be Social
</div>
</li>
<li>
<div>
Be Social
</div>
</li>
</ul>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -1104,32 +1212,39 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
EdX Exams
</span>
<em>
</h3>
<em
aria-hidden="true"
>
4
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: About Exams and Certificates, then edX Exams, then EdX Exams."
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
EdX Exams
</div>
</li>
</ul>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -1165,32 +1280,39 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
When Are Your Exams?
</span>
<em>
</h3>
<em
aria-hidden="true"
>
2
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
<div
aria-label="Location: Example Week 1: Getting Started, then Lesson 1 - Getting Started, then When Are Your Exams? ."
>
<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>
<ul
aria-hidden="true"
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>
</div>
</a>
<a
@@ -1228,10 +1350,13 @@ exports[`CoursewareSearchResults when list of results is provided should match t
<div
class="courseware-search-results__title"
>
<span>
<h3>
External Course Link Test
</span>
</h3>
</div>
<div
aria-label=""
/>
</div>
</a>
</div>

View File

@@ -9,8 +9,8 @@
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.
border-top: 1px solid $light-300;
z-index: $zindex-modal; // Bootstrap's z-index layer for Modals.
&__form {
position: relative;
@@ -47,7 +47,7 @@
&__results-summary {
font-size: .9rem;
color: var(--pgn-color-gray-500);
color: $gray-500;
padding: 1rem 0 .5rem;
}
@@ -62,7 +62,7 @@
margin-top: 1.5rem;
&__empty {
color: var(--pgn-color-gray-500);
color: $gray-500;
padding: 6rem 0;
text-align: center;
}
@@ -76,17 +76,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 +99,12 @@
align-items: center;
line-height: 2.5;
font-size: 0.875rem;
color: var(--pgn-color-black);
color: $black;
> h3 {
font-size: inherit;
margin: 0;
}
> span {
display: block;
@@ -113,7 +118,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 +130,7 @@
&__breadcrumbs {
display: flex;
gap: 1.25rem;
color: var(--pgn-color-gray-500);
color: $gray-500;
overflow: hidden;
list-style: none;
padding: 0;
@@ -156,14 +161,14 @@
}
.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) {
@media (min-width: map-get($grid-breakpoints, 'md')) {
.courseware-search {
&__close {
right: -2.5rem;

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

@@ -56,7 +56,21 @@ const messages = defineMessages({
defaultMessage: 'There was an error on the search process. Please try again in a few minutes. If the problem persists, please contact the support team.',
description: 'Error message to show to the users when there\'s an error with the endpoint or the returned payload format.',
},
searchResultsFilterDescription: {
id: 'learn.coursewareSearch.searchResultsFilterDescription',
defaultMessage: 'Search result filters',
description: 'Screen Reader text to describe the filter options.',
},
searchResultsBreadcrumb: {
id: 'learn.coursewareSearch.searchResultsBreadcrumb',
defaultMessage: 'Location: {path}.',
description: 'Screen Reader text to describe the search result breadcrumbs.',
},
searchResultsBreadcrumbSeparator: {
id: 'learn.searchResultsBreadcrumbSeparator',
defaultMessage: ', then ',
description: 'Screen Reader text to connect breadcrumb sections. i.e.: "Introduction, then Register, then Something else.',
},
// These are translations for labeling the filters
'filter:all': {
id: 'learn.coursewareSearch.filter:all',

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

@@ -5,7 +5,6 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"examsData": null,
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
@@ -398,7 +397,6 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
"courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"examsData": null,
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
@@ -491,6 +489,7 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
"outline": {
"course-v1:edX+DemoX+Demo_Course": {
"accessExpiration": null,
"canShowUpgradeSock": false,
"certData": {
"certStatus": null,
"certWebViewUrl": null,
@@ -530,7 +529,6 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
"hideFromTOC": undefined,
"icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"isPreview": false,
"navigationDisabled": undefined,
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"showLink": true,
@@ -672,7 +670,6 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"examsData": null,
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
@@ -765,19 +762,6 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"progress": {
"course-v1:edX+DemoX+Demo_Course": {
"accessExpiration": null,
"assignmentTypeGradeSummary": [
{
"averageGrade": 1,
"hasHiddenContribution": "none",
"lastGradePublishDate": null,
"numDroppable": 1,
"numTotal": 2,
"shortLabel": "HW",
"type": "Homework",
"weight": 1,
"weightedGrade": 1,
},
],
"certificateData": {},
"completionSummary": {
"completeCount": 1,
@@ -793,17 +777,17 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"creditCourseRequirements": null,
"end": "3027-03-31T00:00:00Z",
"enrollmentMode": "audit",
"finalGrades": 0.5,
"gradesFeatureIsFullyLocked": false,
"gradesFeatureIsPartiallyLocked": false,
"gradingPolicy": {
"assignmentPolicies": [
{
"averageGrade": "1.0000",
"numDroppable": 1,
"numTotal": 2,
"shortLabel": "HW",
"type": "Homework",
"weight": 1,
"weightedGrade": 1,
},
],
"gradeRange": {

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
@@ -68,7 +155,6 @@ export function normalizeOutlineBlocks(courseId, blocks) {
title: block.display_name,
hideFromTOC: block.hide_from_toc,
navigationDisabled: block.navigation_disabled,
isPreview: block.is_preview,
};
break;
@@ -150,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.
@@ -276,6 +367,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);
@@ -297,6 +389,7 @@ export async function getOutlineTabData(courseId) {
return {
accessExpiration,
canShowUpgradeSock,
certData,
courseBlocks,
courseGoals,
@@ -380,24 +473,3 @@ export async function searchCourseContentFromAPI(courseId, searchKeyword, option
return camelCaseObject(response);
}
export async function getExamsData(courseId, sequenceId) {
let url;
if (!getConfig().EXAMS_BASE_URL) {
url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
} else {
url = `${getConfig().EXAMS_BASE_URL}/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}`;
}
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
return {};
}
throw error;
}
}

View File

@@ -1,12 +1,4 @@
import { getConfig, setConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import { getTimeOffsetMillis, getExamsData } from './api';
import { initializeMockApp } from '../../setupTest';
initializeMockApp();
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
import { getTimeOffsetMillis } from './api';
describe('Calculate the time offset properly', () => {
it('Should return 0 if the headerDate is not set', async () => {
@@ -22,156 +14,3 @@ describe('Calculate the time offset properly', () => {
expect(offset).toBe(86398750);
});
});
describe('getExamsData', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const sequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345';
let originalConfig;
beforeEach(() => {
axiosMock.reset();
originalConfig = getConfig();
});
afterEach(() => {
axiosMock.reset();
if (originalConfig) {
setConfig(originalConfig);
}
});
it('should use LMS URL when EXAMS_BASE_URL is not configured', async () => {
setConfig({
...originalConfig,
EXAMS_BASE_URL: undefined,
LMS_BASE_URL: 'http://localhost:18000',
});
const mockExamData = {
exam: {
id: 1,
course_id: courseId,
content_id: sequenceId,
exam_name: 'Test Exam',
attempt_status: 'created',
},
};
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
const result = await getExamsData(courseId, sequenceId);
expect(result).toEqual({
exam: {
id: 1,
courseId,
contentId: sequenceId,
examName: 'Test Exam',
attemptStatus: 'created',
},
});
expect(axiosMock.history.get).toHaveLength(1);
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
});
it('should use EXAMS_BASE_URL when configured', async () => {
setConfig({
...originalConfig,
EXAMS_BASE_URL: 'http://localhost:18740',
LMS_BASE_URL: 'http://localhost:18000',
});
const mockExamData = {
exam: {
id: 1,
course_id: courseId,
content_id: sequenceId,
exam_name: 'Test Exam',
attempt_status: 'submitted',
},
};
const expectedUrl = `http://localhost:18740/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}`;
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
const result = await getExamsData(courseId, sequenceId);
expect(result).toEqual({
exam: {
id: 1,
courseId,
contentId: sequenceId,
examName: 'Test Exam',
attemptStatus: 'submitted',
},
});
expect(axiosMock.history.get).toHaveLength(1);
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
});
it('should return empty object when API returns 404', async () => {
setConfig({
...originalConfig,
EXAMS_BASE_URL: undefined,
LMS_BASE_URL: 'http://localhost:18000',
});
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
// Mock a 404 error with the custom error response function to add customAttributes
axiosMock.onGet(expectedUrl).reply(() => {
const error = new Error('Request failed with status code 404');
error.response = { status: 404, data: {} };
error.customAttributes = { httpErrorStatus: 404 };
return Promise.reject(error);
});
const result = await getExamsData(courseId, sequenceId);
expect(result).toEqual({});
expect(axiosMock.history.get).toHaveLength(1);
});
it('should throw error for non-404 HTTP errors', async () => {
setConfig({
...originalConfig,
EXAMS_BASE_URL: undefined,
LMS_BASE_URL: 'http://localhost:18000',
});
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
// Mock a 500 error with custom error response
axiosMock.onGet(expectedUrl).reply(() => {
const error = new Error('Request failed with status code 500');
error.response = { status: 500, data: { error: 'Server Error' } };
error.customAttributes = { httpErrorStatus: 500 };
return Promise.reject(error);
});
await expect(getExamsData(courseId, sequenceId)).rejects.toThrow();
expect(axiosMock.history.get).toHaveLength(1);
});
it('should properly encode URL parameters', async () => {
setConfig({
...originalConfig,
EXAMS_BASE_URL: 'http://localhost:18740',
LMS_BASE_URL: 'http://localhost:18000',
});
const specialCourseId = 'course-v1:edX+Demo X+Demo Course';
const specialSequenceId = 'block-v1:edX+Demo X+Demo Course+type@sequential+block@test sequence';
const mockExamData = { exam: { id: 1 } };
const expectedUrl = `http://localhost:18740/api/v1/student/exam/attempt/course_id/${encodeURIComponent(specialCourseId)}/content_id/${encodeURIComponent(specialSequenceId)}`;
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
await getExamsData(specialCourseId, specialSequenceId);
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
expect(axiosMock.history.get[0].url).toContain('course-v1%3AedX%2BDemo%20X%2BDemo%20Course');
expect(axiosMock.history.get[0].url).toContain('block-v1%3AedX%2BDemo%20X%2BDemo%20Course%2Btype%40sequential%2Bblock%40test%20sequence');
});
});

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',
@@ -93,6 +94,7 @@ describe('Course Home Service', () => {
},
});
const normalizedTabData = {
canShowUpgradeSock: false,
verifiedMode: {
accessExpirationDate: null,
currency: 'USD',

View File

@@ -297,178 +297,4 @@ describe('Data layer integration tests', () => {
expect(enabled).toBe(false);
});
});
describe('Test fetchExamAttemptsData', () => {
const sequenceIds = [
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@67890',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@abcde',
];
beforeEach(() => {
// Mock individual exam endpoints with different responses
sequenceIds.forEach((sequenceId, index) => {
// Handle both LMS and EXAMS service URL patterns
const lmsExamUrl = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceId)}.*`);
const examsServiceUrl = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}.*`);
let attemptStatus = 'ready_to_start';
if (index === 0) {
attemptStatus = 'created';
} else if (index === 1) {
attemptStatus = 'submitted';
}
const mockExamData = {
exam: {
id: index + 1,
course_id: courseId,
content_id: sequenceId,
exam_name: `Test Exam ${index + 1}`,
attempt_status: attemptStatus,
time_remaining_seconds: 3600,
},
};
// Mock both URL patterns
axiosMock.onGet(lmsExamUrl).reply(200, mockExamData);
axiosMock.onGet(examsServiceUrl).reply(200, mockExamData);
});
});
it('should fetch exam data for all sequence IDs and dispatch setExamsData', async () => {
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
const state = store.getState();
// Verify the examsData was set in the store
expect(state.courseHome.examsData).toHaveLength(3);
expect(state.courseHome.examsData).toEqual([
{
id: 1,
courseId,
contentId: sequenceIds[0],
examName: 'Test Exam 1',
attemptStatus: 'created',
timeRemainingSeconds: 3600,
},
{
id: 2,
courseId,
contentId: sequenceIds[1],
examName: 'Test Exam 2',
attemptStatus: 'submitted',
timeRemainingSeconds: 3600,
},
{
id: 3,
courseId,
contentId: sequenceIds[2],
examName: 'Test Exam 3',
attemptStatus: 'ready_to_start',
timeRemainingSeconds: 3600,
},
]);
// Verify all API calls were made
expect(axiosMock.history.get).toHaveLength(3);
});
it('should handle 404 responses and include empty objects in results', async () => {
// Override one endpoint to return 404 for both URL patterns
const examUrl404LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[1])}.*`);
const examUrl404Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[1])}.*`);
axiosMock.onGet(examUrl404LMS).reply(404);
axiosMock.onGet(examUrl404Exams).reply(404);
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
const state = store.getState();
// Verify the examsData includes empty object for 404 response
expect(state.courseHome.examsData).toHaveLength(3);
expect(state.courseHome.examsData[1]).toEqual({});
});
it('should handle API errors and log them while continuing with other requests', async () => {
// Override one endpoint to return 500 error for both URL patterns
const examUrl500LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[0])}.*`);
const examUrl500Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[0])}.*`);
axiosMock.onGet(examUrl500LMS).reply(500, { error: 'Server Error' });
axiosMock.onGet(examUrl500Exams).reply(500, { error: 'Server Error' });
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
const state = store.getState();
// Verify error was logged for the failed request
expect(loggingService.logError).toHaveBeenCalled();
// Verify the examsData still includes results for successful requests
expect(state.courseHome.examsData).toHaveLength(3);
// First item should be the error result (just empty object for API errors)
expect(state.courseHome.examsData[0]).toEqual({});
});
it('should handle empty sequence IDs array', async () => {
await executeThunk(thunks.fetchExamAttemptsData(courseId, []), store.dispatch);
const state = store.getState();
expect(state.courseHome.examsData).toEqual([]);
expect(axiosMock.history.get).toHaveLength(0);
});
it('should handle mixed success and error responses', async () => {
// Setup mixed responses
const examUrl1LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[0])}.*`);
const examUrl1Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[0])}.*`);
const examUrl2LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[1])}.*`);
const examUrl2Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[1])}.*`);
const examUrl3LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[2])}.*`);
const examUrl3Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[2])}.*`);
axiosMock.onGet(examUrl1LMS).reply(200, {
exam: {
id: 1,
exam_name: 'Success Exam',
course_id: courseId,
content_id: sequenceIds[0],
attempt_status: 'created',
time_remaining_seconds: 3600,
},
});
axiosMock.onGet(examUrl1Exams).reply(200, {
exam: {
id: 1,
exam_name: 'Success Exam',
course_id: courseId,
content_id: sequenceIds[0],
attempt_status: 'created',
time_remaining_seconds: 3600,
},
});
axiosMock.onGet(examUrl2LMS).reply(404);
axiosMock.onGet(examUrl2Exams).reply(404);
axiosMock.onGet(examUrl3LMS).reply(500, { error: 'Server Error' });
axiosMock.onGet(examUrl3Exams).reply(500, { error: 'Server Error' });
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
const state = store.getState();
expect(state.courseHome.examsData).toHaveLength(3);
expect(state.courseHome.examsData[0]).toMatchObject({
id: 1,
examName: 'Success Exam',
courseId,
contentId: sequenceIds[0],
});
expect(state.courseHome.examsData[1]).toEqual({});
expect(state.courseHome.examsData[2]).toEqual({});
// Verify error was logged for the 500 error (may be called more than once due to multiple URL patterns)
expect(loggingService.logError).toHaveBeenCalled();
});
});
});

View File

@@ -18,7 +18,6 @@ const slice = createSlice({
toastBodyLink: null,
toastHeader: '',
showSearch: false,
examsData: null,
},
reducers: {
fetchProctoringInfoResolved: (state) => {
@@ -54,9 +53,6 @@ const slice = createSlice({
setShowSearch: (state, { payload }) => {
state.showSearch = payload;
},
setExamsData: (state, { payload }) => {
state.examsData = payload;
},
},
});
@@ -68,7 +64,6 @@ export const {
fetchTabSuccess,
setCallToActionToast,
setShowSearch,
setExamsData,
} = slice.actions;
export const {

View File

@@ -1,145 +0,0 @@
import { reducer, setExamsData } from './slice';
describe('course home data slice', () => {
describe('setExamsData reducer', () => {
it('should set examsData in state', () => {
const initialState = {
courseStatus: 'loading',
courseId: null,
metadataModel: 'courseHomeCourseMetadata',
proctoringPanelStatus: 'loading',
tabFetchStates: {},
toastBodyText: '',
toastBodyLink: null,
toastHeader: '',
showSearch: false,
examsData: null,
};
const mockExamsData = [
{
id: 1,
courseId: 'course-v1:edX+DemoX+Demo_Course',
examName: 'Midterm Exam',
attemptStatus: 'created',
},
{
id: 2,
courseId: 'course-v1:edX+DemoX+Demo_Course',
examName: 'Final Exam',
attemptStatus: 'submitted',
},
];
const action = setExamsData(mockExamsData);
const newState = reducer(initialState, action);
expect(newState.examsData).toEqual(mockExamsData);
expect(newState).toEqual({
...initialState,
examsData: mockExamsData,
});
});
it('should update examsData when state already has data', () => {
const initialState = {
courseStatus: 'loaded',
courseId: 'test-course',
metadataModel: 'courseHomeCourseMetadata',
proctoringPanelStatus: 'loading',
tabFetchStates: {},
toastBodyText: '',
toastBodyLink: null,
toastHeader: '',
showSearch: false,
examsData: [{ id: 1, examName: 'Old Exam' }],
};
const newExamsData = [
{
id: 2,
courseId: 'course-v1:edX+DemoX+Demo_Course',
examName: 'New Exam',
attemptStatus: 'ready_to_start',
},
];
const action = setExamsData(newExamsData);
const newState = reducer(initialState, action);
expect(newState.examsData).toEqual(newExamsData);
expect(newState.examsData).not.toEqual(initialState.examsData);
});
it('should set examsData to empty array', () => {
const initialState = {
courseStatus: 'loaded',
courseId: 'test-course',
metadataModel: 'courseHomeCourseMetadata',
proctoringPanelStatus: 'loading',
tabFetchStates: {},
toastBodyText: '',
toastBodyLink: null,
toastHeader: '',
showSearch: false,
examsData: [{ id: 1, examName: 'Some Exam' }],
};
const action = setExamsData([]);
const newState = reducer(initialState, action);
expect(newState.examsData).toEqual([]);
});
it('should set examsData to null', () => {
const initialState = {
courseStatus: 'loaded',
courseId: 'test-course',
metadataModel: 'courseHomeCourseMetadata',
proctoringPanelStatus: 'loading',
tabFetchStates: {},
toastBodyText: '',
toastBodyLink: null,
toastHeader: '',
showSearch: false,
examsData: [{ id: 1, examName: 'Some Exam' }],
};
const action = setExamsData(null);
const newState = reducer(initialState, action);
expect(newState.examsData).toBeNull();
});
it('should not affect other state properties when setting examsData', () => {
const initialState = {
courseStatus: 'loaded',
courseId: 'test-course-id',
metadataModel: 'courseHomeCourseMetadata',
proctoringPanelStatus: 'complete',
tabFetchStates: { progress: 'loaded' },
toastBodyText: 'Toast message',
toastBodyLink: 'http://example.com',
toastHeader: 'Toast Header',
showSearch: true,
examsData: null,
};
const mockExamsData = [{ id: 1, examName: 'Test Exam' }];
const action = setExamsData(mockExamsData);
const newState = reducer(initialState, action);
// Verify that only examsData changed
expect(newState).toEqual({
...initialState,
examsData: mockExamsData,
});
// Verify other properties remain unchanged
expect(newState.courseStatus).toBe(initialState.courseStatus);
expect(newState.courseId).toBe(initialState.courseId);
expect(newState.showSearch).toBe(initialState.showSearch);
expect(newState.toastBodyText).toBe(initialState.toastBodyText);
});
});
});

View File

@@ -4,7 +4,6 @@ import {
executePostFromPostEvent,
getCourseHomeCourseMetadata,
getDatesTabData,
getExamsData,
getOutlineTabData,
getProgressTabData,
postCourseDeadlines,
@@ -27,7 +26,6 @@ import {
fetchTabRequest,
fetchTabSuccess,
setCallToActionToast,
setExamsData,
} from './slice';
import mapSearchResponse from '../courseware-search/map-search-response';
@@ -225,19 +223,3 @@ export function searchCourseContent(courseId, searchKeyword) {
});
};
}
export function fetchExamAttemptsData(courseId, sequenceIds) {
return async (dispatch) => {
const results = await Promise.all(sequenceIds.map(async (sequenceId) => {
try {
const response = await getExamsData(courseId, sequenceId);
return response.exam || {};
} catch (e) {
logError(e);
return {};
}
}));
dispatch(setExamsData(results));
};
}

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

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

@@ -5,7 +5,7 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } 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';
@@ -16,6 +16,7 @@ import CourseTools from './widgets/CourseTools';
import { fetchOutlineTab } from '../data';
import messages from './messages';
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';
@@ -39,11 +40,13 @@ const OutlineTab = () => {
isSelfPaced,
org,
title,
userTimezone,
} = useModel('courseHomeMeta', courseId);
const expandButtonRef = useRef();
const {
accessExpiration,
courseBlocks: {
courses,
sections,
@@ -52,12 +55,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();
@@ -171,7 +182,6 @@ const OutlineTab = () => {
</div>
{rootCourseId && (
<div className="col col-12 col-md-4">
<CourseOutlineTabNotificationsSlot courseId={courseId} />
<ProctoringInfoPanel />
{ /** Defer showing the goal widget until the ProctoringInfoPanel has resolved or has been determined as
disabled to avoid components bouncing around too much as screen is rendered */ }
@@ -182,6 +192,27 @@ const OutlineTab = () => {
/>
)}
<CourseTools />
<PluginSlot
id="outline_tab_notifications_slot"
pluginProps={{
courseId,
model: 'outline',
}}
>
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="course_home"
userTimezone={userTimezone}
shouldDisplayBorder
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
/>
</PluginSlot>
<CourseDates />
<CourseHandouts />
</div>

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';
@@ -139,7 +139,7 @@ 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 () => {
@@ -1190,6 +1190,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

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

@@ -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,7 +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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import messages from '../messages';
@@ -10,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);
@@ -217,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,6 @@
import React, { useMemo } from 'react';
import React from 'react';
import { useWindowSize } from '@openedx/paragon';
import { useContextId } from '../../data/hooks';
import { useModel } from '../../generic/model-store';
import ProgressTabCertificateStatusSidePanelSlot from '../../plugin-slots/ProgressTabCertificateStatusSidePanelSlot';
import CourseCompletion from './course-completion/CourseCompletion';
@@ -11,17 +10,11 @@ import ProgressTabCertificateStatusMainBodySlot from '../../plugin-slots/Progres
import ProgressTabCourseGradeSlot from '../../plugin-slots/ProgressTabCourseGradeSlot';
import ProgressTabGradeBreakdownSlot from '../../plugin-slots/ProgressTabGradeBreakdownSlot';
import ProgressTabRelatedLinksSlot from '../../plugin-slots/ProgressTabRelatedLinksSlot';
import { useGetExamsData } from './hooks';
import { useModel } from '../../generic/model-store';
const ProgressTab = () => {
const courseId = useContextId();
const { disableProgressGraph, sectionScores } = useModel('progress', courseId);
const sequenceIds = useMemo(() => (
sectionScores.flatMap((section) => (section.subsections)).map((subsection) => subsection.blockKey)
), [sectionScores]);
useGetExamsData(courseId, sequenceIds);
const { disableProgressGraph } = useModel('progress', courseId);
const windowWidth = useWindowSize().width;
if (windowWidth === undefined) {

View File

@@ -661,133 +661,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 () => {
@@ -1490,287 +1500,4 @@ describe('Progress Tab', () => {
expect(screen.getByText('Course progress for otherstudent')).toBeInTheDocument();
});
});
describe('Exam data fetching integration', () => {
const mockSectionScores = [
{
display_name: 'Section 1',
subsections: [
{
assignment_type: 'Exam',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@exam1',
display_name: 'Midterm Exam',
learner_has_access: true,
has_graded_assignment: true,
percent_graded: 0.8,
show_correctness: 'always',
show_grades: true,
url: '/mock-url',
},
{
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@homework1',
display_name: 'Homework 1',
learner_has_access: true,
has_graded_assignment: true,
percent_graded: 0.9,
show_correctness: 'always',
show_grades: true,
url: '/mock-url',
},
],
},
{
display_name: 'Section 2',
subsections: [
{
assignment_type: 'Exam',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
display_name: 'Final Exam',
learner_has_access: true,
has_graded_assignment: true,
percent_graded: 0.85,
show_correctness: 'always',
show_grades: true,
url: '/mock-url',
},
],
},
];
beforeEach(() => {
// Reset any existing handlers to avoid conflicts
axiosMock.reset();
// Re-add the base mocks that other tests expect
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
// Mock exam data endpoints using specific GET handlers
axiosMock.onGet(/.*exam1.*/).reply(200, {
exam: {
id: 1,
course_id: courseId,
content_id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@exam1',
exam_name: 'Midterm Exam',
attempt_status: 'submitted',
time_remaining_seconds: 0,
},
});
axiosMock.onGet(/.*homework1.*/).reply(404);
axiosMock.onGet(/.*final_exam.*/).reply(200, {
exam: {
id: 2,
course_id: courseId,
content_id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
exam_name: 'Final Exam',
attempt_status: 'ready_to_start',
time_remaining_seconds: 7200,
},
});
});
it('should fetch exam data for all subsections when ProgressTab renders', async () => {
setTabData({ section_scores: mockSectionScores });
await fetchAndRender();
// Verify exam API calls were made for all subsections
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(3);
// Verify the exam data is in the Redux store
const state = store.getState();
expect(state.courseHome.examsData).toHaveLength(3);
// Check the exam data structure
expect(state.courseHome.examsData[0]).toEqual({
id: 1,
courseId,
contentId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@exam1',
examName: 'Midterm Exam',
attemptStatus: 'submitted',
timeRemainingSeconds: 0,
});
expect(state.courseHome.examsData[1]).toEqual({}); // 404 response for homework
expect(state.courseHome.examsData[2]).toEqual({
id: 2,
courseId,
contentId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
examName: 'Final Exam',
attemptStatus: 'ready_to_start',
timeRemainingSeconds: 7200,
});
});
it('should handle empty section scores gracefully', async () => {
setTabData({ section_scores: [] });
await fetchAndRender();
// Verify no exam API calls were made
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(0);
// Verify empty exam data in Redux store
const state = store.getState();
expect(state.courseHome.examsData).toEqual([]);
});
it('should re-fetch exam data when section scores change', async () => {
// Initial render with limited section scores
setTabData({
section_scores: [mockSectionScores[0]], // Only first section
});
await fetchAndRender();
// Verify initial API calls (2 subsections in first section)
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(2);
// Clear axios history to track new calls
axiosMock.resetHistory();
// Update with full section scores and re-render
setTabData({ section_scores: mockSectionScores });
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
// Verify additional API calls for all subsections
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(3);
});
it('should handle exam API errors gracefully without breaking ProgressTab', async () => {
// Clear existing mocks and setup specific error scenario
axiosMock.reset();
// Re-add base mocks
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
// Mock first exam to return 500 error
axiosMock.onGet(/.*exam1.*/).reply(500, { error: 'Server Error' });
// Mock other exams to succeed
axiosMock.onGet(/.*homework1.*/).reply(404, { customAttributes: { httpErrorStatus: 404 } });
axiosMock.onGet(/.*final_exam.*/).reply(200, {
exam: {
id: 2,
course_id: courseId,
content_id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
exam_name: 'Final Exam',
attempt_status: 'ready_to_start',
time_remaining_seconds: 7200,
},
});
setTabData({ section_scores: mockSectionScores });
await fetchAndRender();
// Verify ProgressTab still renders successfully despite API error
expect(screen.getByText('Grades')).toBeInTheDocument();
// Verify the exam data includes error placeholder for failed request
const state = store.getState();
expect(state.courseHome.examsData).toHaveLength(3);
expect(state.courseHome.examsData[0]).toEqual({}); // Failed request returns empty object
});
it('should use EXAMS_BASE_URL when configured for exam API calls', async () => {
// Configure EXAMS_BASE_URL
const originalConfig = getConfig();
setConfig({
...originalConfig,
EXAMS_BASE_URL: 'http://localhost:18740',
});
// Override mock to use new base URL
const examUrlWithExamsBase = /http:\/\/localhost:18740\/api\/v1\/student\/exam\/attempt\/course_id.*/;
axiosMock.onGet(examUrlWithExamsBase).reply(200, {
exam: {
id: 1,
course_id: courseId,
exam_name: 'Test Exam',
attempt_status: 'created',
},
});
setTabData({ section_scores: [mockSectionScores[0]] });
await fetchAndRender();
// Verify API calls use EXAMS_BASE_URL
const examApiCalls = axiosMock.history.get.filter(req => req.url.includes('localhost:18740'));
expect(examApiCalls.length).toBeGreaterThan(0);
// Restore original config
setConfig(originalConfig);
});
it('should extract sequence IDs correctly from nested section scores structure', async () => {
const complexSectionScores = [
{
display_name: 'Introduction',
subsections: [
{
assignment_type: 'Lecture',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@intro',
display_name: 'Course Introduction',
},
],
},
{
display_name: 'Assessments',
subsections: [
{
assignment_type: 'Exam',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz1',
display_name: 'Quiz 1',
},
{
assignment_type: 'Exam',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz2',
display_name: 'Quiz 2',
},
],
},
];
// Mock all the expected sequence IDs
const expectedSequenceIds = [
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@intro',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz1',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz2',
];
expectedSequenceIds.forEach((sequenceId, index) => {
const examUrl = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}.*`);
axiosMock.onGet(examUrl).reply(index === 0 ? 404 : 200, {
exam: {
id: index,
course_id: courseId,
content_id: sequenceId,
exam_name: `Test ${index}`,
},
});
});
setTabData({ section_scores: complexSectionScores });
await fetchAndRender();
// Verify API calls were made for all extracted sequence IDs
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(3);
// Verify correct sequence IDs were used in API calls
const apiCalls = axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'));
expectedSequenceIds.forEach(sequenceId => {
expect(apiCalls.some(call => call.url.includes(encodeURIComponent(sequenceId)))).toBe(true);
});
});
});
});

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

@@ -8,57 +8,26 @@ 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 {
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,63 +47,42 @@ 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 = {
passingGrade: PropTypes.number.isRequired,
};

View File

@@ -48,7 +48,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 +71,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>

View File

@@ -13,7 +13,6 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => {
const courseId = useContextId();
const {
assignmentTypeGradeSummary,
courseGrade: {
isPassing,
percent,
@@ -26,8 +25,6 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => {
const isLocaleRtl = isRtl(getLocale());
const hasHiddenGrades = assignmentTypeGradeSummary.some((assignmentType) => assignmentType.hasHiddenContribution !== 'none');
if (isLocaleRtl) {
currentGradeDirection = currentGrade < 50 ? '-' : '';
}
@@ -59,15 +56,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>
</>
);
};

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

@@ -10,12 +10,14 @@ const GradeSummary = () => {
const courseId = useContextId();
const {
assignmentTypeGradeSummary,
gradingPolicy: {
assignmentPolicies,
},
} = useModel('progress', courseId);
const [allOfSomeAssignmentTypeIsLocked, setAllOfSomeAssignmentTypeIsLocked] = useState(false);
if (assignmentTypeGradeSummary.length === 0) {
if (assignmentPolicies.length === 0) {
return null;
}

View File

@@ -2,7 +2,6 @@ import PropTypes from 'prop-types';
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
import { DataTable } from '@openedx/paragon';
import { Lock } from '@openedx/paragon/icons';
import { useContextId } from '../../../../data/hooks';
import { useModel } from '../../../../generic/model-store';
@@ -17,7 +16,9 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
const courseId = useContextId();
const {
assignmentTypeGradeSummary,
gradingPolicy: {
assignmentPolicies,
},
gradesFeatureIsFullyLocked,
sectionScores,
} = useModel('progress', courseId);
@@ -54,7 +55,7 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
return false;
};
const gradeSummaryData = assignmentTypeGradeSummary.map((assignment) => {
const gradeSummaryData = assignmentPolicies.map((assignment) => {
const {
averageGrade,
numDroppable,
@@ -79,24 +80,13 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignmentType);
const isLocaleRtl = isRtl(getLocale());
let weightedGradeDisplay = `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}%`;
let gradeDisplay = `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}%`;
if (assignment.hasHiddenContribution === 'all') {
gradeDisplay = <Lock data-testid="lock-icon" />;
weightedGradeDisplay = <Lock data-testid="lock-icon" />;
} else if (assignment.hasHiddenContribution === 'some') {
gradeDisplay = `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}% + ${intl.formatMessage(messages.hiddenScoreLabel)}`;
weightedGradeDisplay = `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}% + ${intl.formatMessage(messages.hiddenScoreLabel)}`;
}
return {
type: {
footnoteId, footnoteMarker, type: assignmentType, locked,
},
weight: { weight: `${(weight * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
grade: { grade: gradeDisplay, locked },
weightedGrade: { weightedGrade: weightedGradeDisplay, locked },
grade: { grade: `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
weightedGrade: { weightedGrade: `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
};
});
const getAssignmentTypeCell = (value) => (
@@ -112,16 +102,6 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
return (
<>
<ul className="micro mb-3 pl-3 text-gray-700">
<li>
<b>{intl.formatMessage(messages.hiddenScoreLabel)}: </b>
{intl.formatMessage(messages.hiddenScoreInfoText)}
</li>
<li>
<b><Lock style={{ height: '15px' }} />: </b>
{` ${intl.formatMessage(messages.hiddenScoreLockInfoText)}`}
</li>
</ul>
<DataTable
data={gradeSummaryData}
itemCount={gradeSummaryData.length}

View File

@@ -1,6 +1,9 @@
import { useContext } from 'react';
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
import {
DataTable,
DataTableContext,
Icon,
OverlayTrigger,
Stack,
@@ -14,6 +17,18 @@ import messages from '../messages';
const GradeSummaryTableFooter = () => {
const intl = useIntl();
const { data } = useContext(DataTableContext);
const rawGrade = data.reduce(
(grade, currentValue) => {
const { weightedGrade } = currentValue.weightedGrade;
const percent = weightedGrade.replace(/%/g, '').trim();
return grade + parseFloat(percent);
},
0,
).toFixed(2);
const courseId = useContextId();
const {
@@ -21,16 +36,8 @@ const GradeSummaryTableFooter = () => {
isPassing,
percent,
},
finalGrades,
} = useModel('progress', courseId);
const getGradePercent = (grade) => {
const percentage = grade * 100;
return Number.isInteger(percentage) ? percentage.toFixed(0) : percentage.toFixed(2);
};
const rawGrade = getGradePercent(finalGrades);
const bgColor = isPassing ? 'bg-success-100' : 'bg-warning-100';
const totalGrade = (percent * 100).toFixed(0);

View File

@@ -21,11 +21,6 @@ const messages = defineMessages({
defaultMessage: 'Your current grade is {currentGrade}%. A weighted grade of {passingGrade}% is required to pass in this course.',
description: 'Alt text for the grade chart bar',
},
courseGradeFooterDueDateNotice: {
id: 'progress.courseGrade.footer.dueDateNotice',
defaultMessage: 'Some assignment scores are not yet included in your total grade. These grades will be released by {dueDate}.',
description: 'This is shown when there are pending assignments with a due date in the future',
},
courseGradeFooterGenericPassing: {
id: 'progress.courseGrade.footer.generic.passing',
defaultMessage: 'Youre currently passing this course',
@@ -153,21 +148,6 @@ const messages = defineMessages({
+ "Your weighted grade is what's used to determine if you pass the course.",
description: 'The content of (tip box) for the grade summary section',
},
hiddenScoreLabel: {
id: 'progress.hiddenScoreLabel',
defaultMessage: 'Hidden Scores',
description: 'Text to indicate that some scores are hidden',
},
hiddenScoreInfoText: {
id: 'progress.hiddenScoreInfoText',
defaultMessage: 'Scores from assignments that count toward your final grade but some are not shown here.',
description: 'Information text about hidden score label',
},
hiddenScoreLockInfoText: {
id: 'progress.hiddenScoreLockInfoText',
defaultMessage: 'Scores for an assignment type are hidden but still counted toward the course grade.',
description: 'Information text about hidden score label when learners have limited access to grades feature',
},
noAccessToAssignmentType: {
id: 'progress.noAcessToAssignmentType',
defaultMessage: 'You do not have access to assignments of type {assignmentType}',

View File

@@ -1,12 +0,0 @@
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { fetchExamAttemptsData } from '../data/thunks';
export function useGetExamsData(courseId, sequenceIds) {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchExamAttemptsData(courseId, sequenceIds));
}, [dispatch, courseId, sequenceIds]);
}

View File

@@ -1,168 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useDispatch } from 'react-redux';
import { useGetExamsData } from './hooks';
import { fetchExamAttemptsData } from '../data/thunks';
// Mock the dependencies
jest.mock('react-redux', () => ({
useDispatch: jest.fn(),
}));
jest.mock('../data/thunks', () => ({
fetchExamAttemptsData: jest.fn(),
}));
describe('useGetExamsData hook', () => {
const mockDispatch = jest.fn();
const mockFetchExamAttemptsData = jest.fn();
beforeEach(() => {
useDispatch.mockReturnValue(mockDispatch);
fetchExamAttemptsData.mockReturnValue(mockFetchExamAttemptsData);
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
it('should dispatch fetchExamAttemptsData on mount', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const sequenceIds = [
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@67890',
];
renderHook(() => useGetExamsData(courseId, sequenceIds));
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, sequenceIds);
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
});
it('should re-dispatch when courseId changes', () => {
const initialCourseId = 'course-v1:edX+DemoX+Demo_Course';
const newCourseId = 'course-v1:edX+NewCourse+Demo';
const sequenceIds = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
const { rerender } = renderHook(
({ courseId: cId, sequenceIds: sIds }) => useGetExamsData(cId, sIds),
{
initialProps: { courseId: initialCourseId, sequenceIds },
},
);
// Verify initial call
expect(fetchExamAttemptsData).toHaveBeenCalledWith(initialCourseId, sequenceIds);
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
// Clear mocks to isolate the re-render call
jest.clearAllMocks();
// Re-render with new courseId
rerender({ courseId: newCourseId, sequenceIds });
expect(fetchExamAttemptsData).toHaveBeenCalledWith(newCourseId, sequenceIds);
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
});
it('should re-dispatch when sequenceIds changes', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const initialSequenceIds = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
const newSequenceIds = [
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@67890',
];
const { rerender } = renderHook(
({ courseId: cId, sequenceIds: sIds }) => useGetExamsData(cId, sIds),
{
initialProps: { courseId, sequenceIds: initialSequenceIds },
},
);
// Verify initial call
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, initialSequenceIds);
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
// Clear mocks to isolate the re-render call
jest.clearAllMocks();
// Re-render with new sequenceIds
rerender({ courseId, sequenceIds: newSequenceIds });
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, newSequenceIds);
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
});
it('should not re-dispatch when neither courseId nor sequenceIds changes', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const sequenceIds = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
const { rerender } = renderHook(
({ courseId: cId, sequenceIds: sIds }) => useGetExamsData(cId, sIds),
{
initialProps: { courseId, sequenceIds },
},
);
// Verify initial call
expect(fetchExamAttemptsData).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledTimes(1);
// Clear mocks to isolate the re-render call
jest.clearAllMocks();
// Re-render with same props
rerender({ courseId, sequenceIds });
// Should not dispatch again
expect(fetchExamAttemptsData).not.toHaveBeenCalled();
expect(mockDispatch).not.toHaveBeenCalled();
});
it('should handle empty sequenceIds array', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const sequenceIds = [];
renderHook(() => useGetExamsData(courseId, sequenceIds));
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, []);
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
});
it('should handle null/undefined courseId', () => {
const sequenceIds = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
renderHook(() => useGetExamsData(null, sequenceIds));
expect(fetchExamAttemptsData).toHaveBeenCalledWith(null, sequenceIds);
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
});
it('should handle sequenceIds reference change but same content', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const sequenceIds1 = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
const sequenceIds2 = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345']; // Same content, different reference
const { rerender } = renderHook(
({ courseId: cId, sequenceIds: sIds }) => useGetExamsData(cId, sIds),
{
initialProps: { courseId, sequenceIds: sequenceIds1 },
},
);
// Verify initial call
expect(fetchExamAttemptsData).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledTimes(1);
// Clear mocks to isolate the re-render call
jest.clearAllMocks();
// Re-render with different reference but same content
rerender({ courseId, sequenceIds: sequenceIds2 });
// Should dispatch again because the reference changed (useEffect dependency)
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, sequenceIds2);
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
});
});

View File

@@ -5,15 +5,3 @@ export const showUngradedAssignments = () => (
getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === 'true'
|| getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === true
);
export const getLatestDueDateInFuture = (assignmentTypeGradeSummary) => {
let latest = null;
assignmentTypeGradeSummary.forEach((assignment) => {
const assignmentLastGradePublishDate = assignment.lastGradePublishDate;
if (assignmentLastGradePublishDate && (!latest || new Date(assignmentLastGradePublishDate) > new Date(latest))
&& new Date(assignmentLastGradePublishDate) > new Date()) {
latest = assignmentLastGradePublishDate;
}
});
return latest;
};

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useDispatch, 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 {
Alert,
Button,
@@ -14,8 +14,7 @@ import { resetDeadlines } from '../data';
import { useModel } from '../../generic/model-store';
import messages from './messages';
const ShiftDatesAlert = ({ fetch, model }) => {
const intl = useIntl();
const ShiftDatesAlert = ({ fetch, intl, model }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -60,7 +59,8 @@ const ShiftDatesAlert = ({ fetch, model }) => {
ShiftDatesAlert.propTypes = {
fetch: PropTypes.func.isRequired,
intl: intlShape.isRequired,
model: PropTypes.string.isRequired,
};
export default ShiftDatesAlert;
export default injectIntl(ShiftDatesAlert);

View File

@@ -1,15 +1,16 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
const SuggestedScheduleHeader = () => {
const intl = useIntl();
return (
<p className="large">
{intl.formatMessage(messages.suggestedSchedule)}
</p>
);
const SuggestedScheduleHeader = ({ intl }) => (
<p className="large">
{intl.formatMessage(messages.suggestedSchedule)}
</p>
);
SuggestedScheduleHeader.propTypes = {
intl: intlShape.isRequired,
};
export default SuggestedScheduleHeader;
export default injectIntl(SuggestedScheduleHeader);

View File

@@ -1,7 +1,7 @@
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 {
Alert,
Button,
@@ -12,8 +12,7 @@ import {
import { useModel } from '../../generic/model-store';
import messages from './messages';
const UpgradeToCompleteAlert = ({ logUpgradeLinkClick }) => {
const intl = useIntl();
const UpgradeToCompleteAlert = ({ intl, logUpgradeLinkClick }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -34,7 +33,7 @@ const UpgradeToCompleteAlert = ({ logUpgradeLinkClick }) => {
}
return (
<Alert id="upgrade-complete-alert" className="bg-light-200">
<Alert className="bg-light-200">
<Row className="w-100 m-0">
<Col xs={12} md={9} className="small p-0 pr-md-2">
<Alert.Heading>{intl.formatMessage(messages.upgradeToCompleteHeader)}</Alert.Heading>
@@ -59,6 +58,7 @@ const UpgradeToCompleteAlert = ({ logUpgradeLinkClick }) => {
};
UpgradeToCompleteAlert.propTypes = {
intl: intlShape.isRequired,
logUpgradeLinkClick: PropTypes.func,
};
@@ -66,4 +66,4 @@ UpgradeToCompleteAlert.defaultProps = {
logUpgradeLinkClick: () => {},
};
export default UpgradeToCompleteAlert;
export default injectIntl(UpgradeToCompleteAlert);

View File

@@ -2,7 +2,7 @@ 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 {
Alert,
Button,
@@ -13,8 +13,7 @@ import {
import { useModel } from '../../generic/model-store';
import messages from './messages';
const UpgradeToShiftDatesAlert = ({ logUpgradeLinkClick, model }) => {
const intl = useIntl();
const UpgradeToShiftDatesAlert = ({ intl, logUpgradeLinkClick, model }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -36,7 +35,7 @@ const UpgradeToShiftDatesAlert = ({ logUpgradeLinkClick, model }) => {
}
return (
<Alert id="upgrade-shift-dates-alert" className="bg-light-200">
<Alert className="bg-light-200">
<Row className="w-100 m-0">
<Col xs={12} md={9} className="small p-0 pr-md-2">
<strong>{intl.formatMessage(messages.missedDeadlines)}</strong>
@@ -61,6 +60,7 @@ const UpgradeToShiftDatesAlert = ({ logUpgradeLinkClick, model }) => {
};
UpgradeToShiftDatesAlert.propTypes = {
intl: intlShape.isRequired,
logUpgradeLinkClick: PropTypes.func,
model: PropTypes.string.isRequired,
};
@@ -69,4 +69,4 @@ UpgradeToShiftDatesAlert.defaultProps = {
logUpgradeLinkClick: () => {},
};
export default UpgradeToShiftDatesAlert;
export default injectIntl(UpgradeToShiftDatesAlert);

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';
import messages from './messages';
@@ -9,9 +9,8 @@ import { CoursewareSearch, CoursewareSearchToggle } from '../course-home/coursew
import { useCoursewareSearchState } from '../course-home/courseware-search/hooks';
const CourseTabsNavigation = ({
activeTabSlug, className, tabs,
activeTabSlug, className, tabs, intl,
}) => {
const intl = useIntl();
const { show } = useCoursewareSearchState();
return (
@@ -52,6 +51,7 @@ CourseTabsNavigation.propTypes = {
slug: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
})).isRequired,
intl: intlShape.isRequired,
};
CourseTabsNavigation.defaultProps = {
@@ -59,4 +59,4 @@ CourseTabsNavigation.defaultProps = {
className: null,
};
export default CourseTabsNavigation;
export default injectIntl(CourseTabsNavigation);

View File

@@ -5,13 +5,13 @@
.nav a,
.nav button {
&:hover {
background-color: var(--pgn-color-light-400);
background-color: $light-400;
}
}
.nav a {
&:not(.active):hover {
background-color: var(--pgn-color-light-400);
background-color: $light-400;
border-bottom: none;
}
}

View File

@@ -2,7 +2,7 @@ import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { waitForElementToBeRemoved, fireEvent } from '@testing-library/dom';
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/extend-expect';
import { render, screen } from '@testing-library/react';
import React from 'react';
import {
@@ -221,7 +221,7 @@ describe('CoursewareContainer', () => {
});
history.push(`/course/${courseId}`);
const container = await loadContainer();
const container = await waitFor(() => loadContainer());
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -244,7 +244,7 @@ describe('CoursewareContainer', () => {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
history.push(`/course/${courseId}`);
const container = await loadContainer();
const container = await waitFor(() => loadContainer());
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -294,7 +294,7 @@ describe('CoursewareContainer', () => {
describe('when the URL does not contain a unit ID', () => {
it('should choose a unit within the section\'s first sequence', async () => {
setUrl(sectionTree[1].id);
const container = await loadContainer();
const container = await waitFor(() => loadContainer());
assertLoadedHeader(container);
assertSequenceNavigation(container, 2);
assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id);
@@ -369,7 +369,7 @@ describe('CoursewareContainer', () => {
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}`);
const container = await loadContainer();
const container = await waitFor(() => loadContainer());
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -388,7 +388,7 @@ describe('CoursewareContainer', () => {
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
history.push(`/course/${courseId}/${sequenceBlock.id}`);
const container = await loadContainer();
const container = await waitFor(() => loadContainer());
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -405,7 +405,7 @@ describe('CoursewareContainer', () => {
it('should load the specified unit', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
const container = await loadContainer();
const container = await waitFor(() => loadContainer());
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -421,7 +421,7 @@ describe('CoursewareContainer', () => {
});
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
const container = await loadContainer();
const container = await waitFor(() => loadContainer());
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button');
const sequenceNextButton = sequenceNavButtons[4];

View File

@@ -1,20 +1,18 @@
import PropTypes from 'prop-types';
import {
generatePath, useParams, useLocation, useSearchParams,
generatePath, useParams, useLocation,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import queryString from 'query-string';
import { REDIRECT_MODES } from '../constants';
interface Props {
pattern: string;
mode: string;
}
const RedirectPage = ({ pattern = '', mode }: Props) => {
const RedirectPage = ({
pattern, mode,
}) => {
const { courseId } = useParams();
const location = useLocation();
const [searchParams] = useSearchParams();
const consentPath = searchParams.get('consentPath') ?? '';
const { consentPath } = queryString.parse(location?.search);
const {
LMS_BASE_URL,
@@ -41,4 +39,13 @@ const RedirectPage = ({ pattern = '', mode }: Props) => {
return null;
};
RedirectPage.propTypes = {
pattern: PropTypes.string,
mode: PropTypes.string.isRequired,
};
RedirectPage.defaultProps = {
pattern: null,
};
export default RedirectPage;

View File

@@ -16,7 +16,6 @@ jest.mock('react-router-dom', () => ({
useLocation: () => ({
search: '?consentPath=/some-path',
}),
useSearchParams: () => [new URLSearchParams('?consentPath=/some-path'), () => {}],
}));
describe('RedirectPage component', () => {

View File

@@ -9,7 +9,7 @@ import { breakpoints, useWindowSize } from '@openedx/paragon';
import { AlertList } from '@src/generic/user-messages';
import { useModel } from '@src/generic/model-store';
import { getCoursewareOutlineSidebarSettings } from '../data/selectors';
import { LearnerToolsSlot } from '../../plugin-slots/LearnerToolsSlot';
import Chat from './chat/Chat';
import SidebarProvider from './sidebar/SidebarContextProvider';
import NewSidebarProvider from './new-sidebar/SidebarContextProvider';
import { NotificationsDiscussionsSidebarTriggerSlot } from '../../plugin-slots/NotificationsDiscussionsSidebarTriggerSlot';
@@ -62,7 +62,7 @@ const Course = ({
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal,
);
const shouldDisplayLearnerTools = windowWidth >= breakpoints.medium.minWidth;
const shouldDisplayChat = windowWidth >= breakpoints.medium.minWidth;
const daysPerWeek = course?.courseGoals?.selectedGoal?.daysPerWeek;
useEffect(() => {
@@ -95,13 +95,17 @@ const Course = ({
/>
</>
)}
{shouldDisplayLearnerTools && (
<LearnerToolsSlot
enrollmentMode={course.enrollmentMode}
isStaff={isStaff}
courseId={courseId}
unitId={unitId}
/>
{shouldDisplayChat && (
<>
<Chat
enabled={course.learningAssistantEnabled}
enrollmentMode={course.enrollmentMode}
isStaff={isStaff}
courseId={courseId}
contentToolsEnabled={course.showCalculator || course.notes.enabled}
unitId={unitId}
/>
</>
)}
<div className="w-100 d-flex align-items-center">
<CourseOutlineMobileSidebarTriggerSlot />

View File

@@ -13,25 +13,17 @@ import Course from './Course';
import setupDiscussionSidebar from './test-utils';
jest.mock('@edx/frontend-platform/analytics');
jest.mock('@edx/frontend-lib-special-exams', () => {
const actual = jest.requireActual('@edx/frontend-lib-special-exams');
return {
...actual,
__esModule: true,
// Mock the default export (SequenceExamWrapper) to just render children
// eslint-disable-next-line react/prop-types
default: ({ children }) => <div data-testid="sequence-exam-wrapper">{children}</div>,
};
});
const mockLearnerToolsTestId = 'fake-learner-tools';
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'),
checkExamEntry: () => jest.fn(),
}));
const mockChatTestId = 'fake-chat';
jest.mock(
'../../plugin-slots/LearnerToolsSlot',
() => ({
// eslint-disable-next-line react/prop-types
LearnerToolsSlot({ courseId }) {
return <div className="fake-learner-tools" data-testid={mockLearnerToolsTestId}>LearnerTools contents {courseId} </div>;
},
}),
'./chat/Chat',
// eslint-disable-next-line react/prop-types
() => function ({ courseId }) {
return <div className="fake-chat" data-testid={mockChatTestId}>Chat contents {courseId} </div>;
},
);
const recordFirstSectionCelebration = jest.fn();
@@ -58,11 +50,7 @@ describe('Course', () => {
global.innerWidth = breakpoints.extraLarge.minWidth;
});
// This was passing when it shouldn't have been because of improper
// waitFor use. With the React 18 upgrade it no longer improperly passes
// so we are skipping it. See https://github.com/openedx/frontend-app-learning/issues/1669
// for details.
it.skip('loads learning sequence', () => {
it('loads learning sequence', async () => {
render(<Course {...mockData} />, { wrapWithRouter: true });
expect(screen.queryByRole('navigation', { name: 'breadcrumb' })).not.toBeInTheDocument();
waitFor(() => {
@@ -106,11 +94,7 @@ describe('Course', () => {
expect(screen.queryByRole('navigation', { name: 'breadcrumb' })).not.toBeInTheDocument();
});
// This was passing when it shouldn't have been because of improper
// waitFor use. With the React 18 upgrade it no longer improperly passes
// so we are skipping it. See https://github.com/openedx/frontend-app-learning/issues/1669
// for details.
it.skip('displays first section celebration modal', async () => {
it('displays first section celebration modal', async () => {
const courseHomeMetadata = Factory.build('courseHomeMetadata', { celebrations: { firstSection: true } });
const testStore = await initializeTestStore({ courseHomeMetadata }, false);
const { courseware, models } = testStore.getState();
@@ -132,11 +116,7 @@ describe('Course', () => {
});
});
// This was passing when it shouldn't have been because of improper
// waitFor use. With the React 18 upgrade it no longer improperly passes
// so we are skipping it. See https://github.com/openedx/frontend-app-learning/issues/1669
// for details.
it.skip('displays weekly goal celebration modal', async () => {
it('displays weekly goal celebration modal', async () => {
const courseHomeMetadata = Factory.build('courseHomeMetadata', { celebrations: { weeklyGoal: true } });
const testStore = await initializeTestStore({ courseHomeMetadata }, false);
const { courseware, models } = testStore.getState();
@@ -156,6 +136,18 @@ describe('Course', () => {
});
});
it('displays notification trigger and toggles active class on click', async () => {
render(<Course {...mockData} />, { wrapWithRouter: true });
waitFor(() => {
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
expect(notificationTrigger).toBeInTheDocument();
expect(notificationTrigger.parentNode).not.toHaveClass('sidebar-active', { exact: true });
fireEvent.click(notificationTrigger);
expect(notificationTrigger.parentNode).toHaveClass('sidebar-active');
});
});
it('handles click to open/close discussions sidebar', async () => {
await setupDiscussionSidebar();
@@ -368,27 +360,9 @@ describe('Course', () => {
});
});
it('displays learner tools when screen is wide enough (browser)', async () => {
const courseMetadata = Factory.build('courseMetadata', {
enrollment: { mode: 'verified' },
});
const testStore = await initializeTestStore({ courseMetadata }, false);
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
unitId: Object.values(models.units)[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
await waitFor(() => expect(learnerTools).toBeInTheDocument());
});
it('does not display learner tools when screen is too narrow (mobile)', async () => {
global.innerWidth = breakpoints.extraSmall.minWidth;
it('displays chat when screen is wide enough (browser)', async () => {
const courseMetadata = Factory.build('courseMetadata', {
learning_assistant_enabled: true,
enrollment: { mode: 'verified' },
});
const testStore = await initializeTestStore({ courseMetadata }, false);
@@ -400,7 +374,26 @@ describe('Course', () => {
sequenceId,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
await expect(learnerTools).not.toBeInTheDocument();
const chat = screen.queryByTestId(mockChatTestId);
waitFor(() => expect(chat).toBeInTheDocument());
});
it('does not display chat when screen is too narrow (mobile)', async () => {
global.innerWidth = breakpoints.extraSmall.minWidth;
const courseMetadata = Factory.build('courseMetadata', {
learning_assistant_enabled: true,
enrollment: { mode: 'verified' },
});
const testStore = await initializeTestStore({ courseMetadata }, false);
const { courseware } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const chat = screen.queryByTestId(mockChatTestId);
await expect(chat).not.toBeInTheDocument();
});
});

View File

@@ -1,13 +1,107 @@
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHome } from '@fortawesome/free-solid-svg-icons';
import { useSelector } from 'react-redux';
import { useToggle, ModalPopup, Menu } from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { useModel, useModels } from '../../generic/model-store';
import JumpNavMenuItem from './JumpNavMenuItem';
import { useModel, useModels } from '../../../generic/model-store';
import BreadcrumbItem from './BreadcrumbItem';
const CourseBreadcrumb = ({
content,
withSeparator,
courseId,
sequenceId,
unitId,
isStaff,
}) => {
const defaultContent = content.filter(
(destination) => destination.default,
)[0] || { id: courseId, label: '', sequences: [] };
const showRegularLink = getConfig().ENABLE_JUMPNAV !== 'true' || content.length < 2 || !isStaff;
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
return (
<>
{withSeparator && (
<li className="col-auto p-0 mx-2 text-primary-500 text-truncate text-nowrap" role="presentation" aria-hidden>/</li>
)}
<li
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
data-testid="breadcrumb-item"
>
{showRegularLink ? (
<Link
className="text-primary-500"
to={
defaultContent.sequences.length
? `/course/${courseId}/${defaultContent.sequences[0].id}`
: `/course/${courseId}/${defaultContent.id}`
}
>
{defaultContent.label}
</Link>
) : (
<>
{
// eslint-disable-next-line
<a className="text-primary-500" onClick={open} ref={setTarget}>
{defaultContent.label}
</a>
}
<ModalPopup positionRef={target} isOpen={isOpen} onClose={close}>
<Menu>
{content.map((item) => (
<JumpNavMenuItem
key={item.label}
isDefault={item.default}
sequences={item.sequences}
courseId={courseId}
title={item.label}
currentSequence={sequenceId}
currentUnit={unitId}
onClick={close}
/>
))}
</Menu>
</ModalPopup>
</>
)}
</li>
</>
);
};
CourseBreadcrumb.propTypes = {
content: PropTypes.arrayOf(
PropTypes.shape({
default: PropTypes.bool,
id: PropTypes.string,
label: PropTypes.string,
}),
).isRequired,
sequenceId: PropTypes.string,
unitId: PropTypes.string,
withSeparator: PropTypes.bool,
courseId: PropTypes.string,
isStaff: PropTypes.bool,
};
CourseBreadcrumb.defaultProps = {
withSeparator: false,
sequenceId: null,
unitId: null,
courseId: null,
isStaff: null,
};
const CourseBreadcrumbs = ({
courseId,
@@ -23,7 +117,7 @@ const CourseBreadcrumbs = ({
);
const allSequencesInSections = Object.fromEntries(
useModels('sections', course.sectionIds)?.map((section) => [
useModels('sections', course.sectionIds).map((section) => [
section.id,
{
default: section.id === sectionId,
@@ -77,7 +171,7 @@ const CourseBreadcrumbs = ({
</Link>
</li>
{links.map((content, i) => (
<BreadcrumbItem
<CourseBreadcrumb
// eslint-disable-next-line react/no-array-index-key
key={i}
courseId={courseId}
@@ -85,7 +179,6 @@ const CourseBreadcrumbs = ({
content={content}
unitId={unitId}
withSeparator
separator="/"
isStaff={isStaff}
/>
))}

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { screen, render } from '@testing-library/react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import { BrowserRouter } from 'react-router-dom';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useModel, useModels } from '../../generic/model-store';
import CourseBreadcrumbs from './CourseBreadcrumbs';
jest.mock('@edx/frontend-platform');
jest.mock('@edx/frontend-platform/analytics');
// Remove When Fully rolled out>>>
jest.mock('../../generic/model-store');
jest.mock('@edx/frontend-platform/auth');
getConfig.mockImplementation(() => ({ ENABLE_JUMPNAV: 'true' }));
getAuthenticatedUser.mockImplementation(() => ({ administrator: true }));
// ^^^^Remove When Fully rolled out
jest.mock('react-redux', () => ({
connect: (mapStateToProps, mapDispatchToProps) => (ReactComponent) => ({
mapStateToProps,
mapDispatchToProps,
ReactComponent,
}),
Provider: ({ children }) => children,
useSelector: () => 'loaded',
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
Link: jest.fn().mockImplementation(({ to, children }) => (
<a href={to}>{children}</a>
)),
}));
useModels.mockImplementation((name) => {
if (name === 'sections') {
return [
{
courseId: 'course-v1:edX+DemoX+Demo_Course',
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b',
sequenceIds: ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction'],
title: 'Introduction',
},
{
courseId: 'course-v1:edX+DemoX+Demo_Course',
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
sequenceIds: ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions'],
title: 'Example Week 1: Getting Started',
},
];
}
return [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5',
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
title: 'Lesson 1 - Getting Started',
unitIds: [
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4f6c1b4e316a419ab5b6bf30e6c708e9',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@3dc16db8d14842e38324e95d4030b8a0',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4a1bba2a403f40bca5ec245e945b0d76',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@256f17a44983429fb1a60802203ee4e0',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@e3601c0abee6427d8c17e6d6f8fdddd1',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@134df56c516a4a0dbb24dd5facef746e',
],
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
title: 'Homework - Question Styles',
unitIds: [
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@47dbd5f836544e61877a483c0b75606c',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@54bb9b142c6c4c22afc62bcb628f0e68',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0c92347a5c00',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_1fef54c2b23b',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2889db1677a549abb15eb4d886f95d1c',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@e8a5cc2aed424838853defab7be45e42',
],
},
];
});
useModel.mockImplementation(() => ({
sectionIds: ['block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b',
'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations'],
}));
describe('CourseBreadcrumbs', () => {
jest.spyOn(React, 'useMemo').mockImplementation(() => [
[
{
default: false,
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b',
label: 'Introduction',
url: 'http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction',
},
{
default: true,
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
label: 'Example Week 1: Getting Started',
url: 'http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course/block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5',
},
],
[
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@simulations', label: "Lesson 2 - Let's Get Interactive!", default: true, url: 'http://localhost:2000/course/course-v1:edX+DemoX+D…e@vertical+block@d0d804e8863c4a95a659c04d8a2b2bc0',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e', label: 'Homework - Essays', default: false, url: 'http://localhost:2000/course/course-v1:edX+DemoX+D…e@vertical+block@fb79dcbad35b466a8c6364f8ffee9050',
},
],
]);
render(
<IntlProvider>
<BrowserRouter>
<CourseBreadcrumbs
courseId="course-v1:edX+DemoX+Demo_Course"
sectionId="block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations"
sequenceId="block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions"
isStaff
/>
</BrowserRouter>,
</IntlProvider>,
);
it('renders course breadcrumbs as expected', async () => {
expect(screen.queryAllByRole('link')).toHaveLength(1);
const courseHomeButtonDestination = screen.getAllByRole('link')[0].href;
expect(courseHomeButtonDestination).toBe('http://localhost/course/course-v1:edX+DemoX+Demo_Course/home');
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(screen.queryAllByTestId('breadcrumb-item')).toHaveLength(2);
});
});

View File

@@ -1,104 +0,0 @@
import React, { useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import {
useToggle,
ModalPopup,
Menu,
Button,
} from '@openedx/paragon';
import { Link, useLocation } from 'react-router-dom';
import JumpNavMenuItem from '../JumpNavMenuItem';
interface Props {
content: {
default: boolean,
id: string,
label: string,
sequences: {
id: string,
}[],
} [];
withSeparator: boolean | false,
separator: string | '';
courseId: string;
sequenceId: string | '';
unitId: string | '';
isStaff: boolean | false;
}
const BreadcrumbItem: React.FC<Props> = ({
content,
withSeparator,
separator,
courseId,
sequenceId,
unitId,
isStaff,
}) => {
const defaultContent = content.filter(
(destination: { default: boolean }) => destination.default,
)[0] || { id: courseId, label: '', sequences: [] };
const showRegularLink = getConfig().ENABLE_JUMPNAV !== 'true' || content.length < 2 || !isStaff;
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const { pathname } = useLocation();
const isPreview = pathname.startsWith('/preview');
const baseUrl = defaultContent.sequences.length
? `/course/${courseId}/${defaultContent.sequences[0].id}`
: `/course/${courseId}/${defaultContent.id}`;
const link = isPreview ? `/preview${baseUrl}` : baseUrl;
return (
<>
{withSeparator && separator && (
<li className="col-auto p-0 mx-2 text-primary-500 text-truncate text-nowrap" role="presentation" aria-hidden>{separator}</li>
)}
<li
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
data-testid="breadcrumb-item"
>
{showRegularLink ? (
<Link
className="text-primary-500"
to={link}
>
{defaultContent.label}
</Link>
) : (
<>
{
// @ts-ignore
<Button className="text-primary-500 m-0 p-0" variant="link" onClick={open} ref={setTarget}>
{defaultContent.label}
</Button>
}
<ModalPopup positionRef={target} isOpen={isOpen} onClose={close}>
<Menu>
{content.map((item) => (
<JumpNavMenuItem
key={item.label}
isDefault={item.default}
sequences={item.sequences}
courseId={courseId}
title={item.label}
currentSequence={sequenceId}
currentUnit={unitId}
onClick={close}
/>
))}
</Menu>
</ModalPopup>
</>
)}
</li>
</>
);
};
export default BreadcrumbItem;

View File

@@ -1,101 +0,0 @@
import { Factory } from 'rosie';
import { AppProvider } from '@edx/frontend-platform/react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp, initializeTestStore } from '@src/setupTest';
import CourseBreadcrumbs from './CourseBreadcrumbs';
const props = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
sequenceId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
isStaff: true,
};
const courseMetadata = Factory.build('courseMetadata', { courseId: props.courseId, sectionIds: [props.sectionId] });
const sequenceBlocks = [Factory.build(
'block',
{ type: 'sequential', id: props.sequenceId, title: 'Subsection' },
{ courseId: props.courseId },
)];
const sectionBlocks = [Factory.build(
'block',
{
type: 'chapter',
id: props.sectionId,
title: 'Section',
children: [{ id: props.sequenceId }],
},
{ courseId: props.courseId },
)];
initializeMockApp();
describe('CourseBreadcrumbs', () => {
let store = {};
const initTestStore = async () => {
store = await initializeTestStore({ courseMetadata, sectionBlocks, sequenceBlocks });
};
function renderWithProvider(pathname = '/course') {
const { container } = render(
<AppProvider store={store} wrapWithRouter={false}>
<IntlProvider locale="en">
<MemoryRouter initialEntries={[{ pathname }]}>
<CourseBreadcrumbs {...props} />
</MemoryRouter>
</IntlProvider>
</AppProvider>,
);
return container;
}
describe('in live view', () => {
it('renders course breadcrumbs as expected', async () => {
await initTestStore();
renderWithProvider();
const courseHomeButtonDestination = screen.getAllByRole('link')[0].href;
expect(courseHomeButtonDestination).toBe('http://localhost/course/course-v1:edX+DemoX+Demo_Course/home');
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(screen.queryAllByTestId('breadcrumb-item')).toHaveLength(2);
});
it('section link does not include /preview/', async () => {
await initTestStore();
renderWithProvider();
const sectionBreadcrumb = screen.getByText(sectionBlocks[0].block_id);
const sectionLink = sectionBreadcrumb.closest('a').href;
expect(sectionLink.includes('/preview/')).toBeFalsy();
});
});
describe('in live view', () => {
it('renders course breadcrumbs as expected', async () => {
await initTestStore();
renderWithProvider('/preview/courses');
const courseHomeButtonDestination = screen.getAllByRole('link')[0].href;
expect(courseHomeButtonDestination).toBe('http://localhost/course/course-v1:edX+DemoX+Demo_Course/home');
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(screen.queryAllByTestId('breadcrumb-item')).toHaveLength(2);
});
it('section link does includes /preview/', async () => {
await initTestStore();
renderWithProvider('/preview/courses');
const sectionBreadcrumb = screen.getByText(sectionBlocks[0].block_id);
const sectionLink = sectionBreadcrumb.closest('a').href;
expect(sectionLink.includes('/preview/')).toBeTruthy();
});
});
});

View File

@@ -1,3 +0,0 @@
import CourseBreadcrumbs from './CourseBreadcrumbs';
export default CourseBreadcrumbs;

View File

@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
ActionRow,
breakpoints,
@@ -18,9 +18,8 @@ import { recordFirstSectionCelebration } from './utils';
import { useModel } from '../../../generic/model-store';
const CelebrationModal = ({
courseId, isOpen, onClose, ...rest
courseId, intl, isOpen, onClose, ...rest
}) => {
const intl = useIntl();
const { org, celebrations } = useModel('courseHomeMeta', courseId);
const dispatch = useDispatch();
const wideScreen = useWindowSize().width >= breakpoints.small.minWidth;
@@ -67,8 +66,9 @@ const CelebrationModal = ({
CelebrationModal.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
};
export default CelebrationModal;
export default injectIntl(CelebrationModal);

View File

@@ -22,6 +22,7 @@
justify-content: center;
button {
@extend .btn-primary;
font-size: 1.2rem;
width: 50%;
}

View File

@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
ActionRow, Button, Icon, StandardModal,
} from '@openedx/paragon';
@@ -12,9 +12,8 @@ import { recordWeeklyGoalCelebration } from './utils';
import { useModel } from '../../../generic/model-store';
const WeeklyGoalCelebrationModal = ({
courseId, daysPerWeek, isOpen, onClose, ...rest
courseId, daysPerWeek, intl, isOpen, onClose, ...rest
}) => {
const intl = useIntl();
const { org } = useModel('courseHomeMeta', courseId);
useEffect(() => {
@@ -78,8 +77,9 @@ const WeeklyGoalCelebrationModal = ({
WeeklyGoalCelebrationModal.propTypes = {
courseId: PropTypes.string.isRequired,
daysPerWeek: PropTypes.number.isRequired,
intl: intlShape.isRequired,
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
};
export default WeeklyGoalCelebrationModal;
export default injectIntl(WeeklyGoalCelebrationModal);

View File

@@ -0,0 +1,83 @@
import { createPortal } from 'react-dom';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { Xpert } from '@edx/frontend-lib-learning-assistant';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { ALLOW_UPSELL_MODES, VERIFIED_MODES } from '@src/constants';
import { useModel } from '../../../generic/model-store';
const Chat = ({
enabled,
enrollmentMode,
isStaff,
courseId,
contentToolsEnabled,
unitId,
}) => {
const {
activeAttempt, exam,
} = useSelector(state => state.specialExams);
const course = useModel('coursewareMeta', courseId);
// If is disabled or taking an exam, we don't show the chat.
if (!enabled || activeAttempt?.attempt_id || exam?.id) { return null; }
// If is not staff and doesn't have an enrollment, we don't show the chat.
if (!isStaff && !enrollmentMode) { return null; }
const verifiedMode = VERIFIED_MODES.includes(enrollmentMode); // Enrollment verified
const auditMode = (
!isStaff
&& !verifiedMode
&& ALLOW_UPSELL_MODES.includes(enrollmentMode) // Can upgrade course
&& getConfig().ENABLE_XPERT_AUDIT
);
// If user has no access, we don't show the chat.
if (!isStaff && !(verifiedMode || auditMode)) { return null; }
// Date validation
const {
accessExpiration,
start,
end,
} = course;
const utcDate = (new Date()).toISOString();
const expiration = accessExpiration?.expirationDate || utcDate;
const validDate = (
(start ? start <= utcDate : true)
&& (end ? end >= utcDate : true)
&& (auditMode ? expiration >= utcDate : true)
);
// If date is invalid, we don't show the chat.
if (!validDate) { return null; }
// Use a portal to ensure that component overlay does not compete with learning MFE styles.
return createPortal(
<Xpert
courseId={courseId}
contentToolsEnabled={contentToolsEnabled}
unitId={unitId}
isUpgradeEligible={auditMode}
/>,
document.body,
);
};
Chat.propTypes = {
isStaff: PropTypes.bool.isRequired,
enabled: PropTypes.bool.isRequired,
enrollmentMode: PropTypes.string,
courseId: PropTypes.string.isRequired,
contentToolsEnabled: PropTypes.bool.isRequired,
unitId: PropTypes.string.isRequired,
};
Chat.defaultProps = {
enrollmentMode: null,
};
export default injectIntl(Chat);

View File

@@ -0,0 +1,286 @@
import { BrowserRouter } from 'react-router-dom';
import React from 'react';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import {
initializeMockApp,
initializeTestStore,
render,
screen,
} from '../../../setupTest';
import Chat from './Chat';
// We do a partial mock to avoid mocking out other exported values (e.g. the reducer).
// We mock out the Xpert component, because the Xpert component has its own rules for whether it renders
// or not, and this includes the results of API calls it makes. We don't want to test those rules here, just
// whether the Xpert is rendered by the Chat component in certain conditions. Instead of actually rendering
// Xpert, we render and assert on a mocked component.
const mockXpertTestId = 'xpert';
jest.mock('@edx/frontend-lib-learning-assistant', () => {
const originalModule = jest.requireActual('@edx/frontend-lib-learning-assistant');
return {
__esModule: true,
...originalModule,
Xpert: () => (<div data-testid={mockXpertTestId}>mocked Xpert</div>),
};
});
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn().mockReturnValue({ ENABLE_XPERT_AUDIT: false }),
}));
initializeMockApp();
const courseId = 'course-v1:edX+DemoX+Demo_Course';
let testCases = [];
let enabledTestCases = [];
let disabledTestCases = [];
const enabledModes = [
'professional', 'verified', 'no-id-professional', 'credit', 'masters', 'executive-education',
'paid-executive-education', 'paid-bootcamp',
];
const disabledModes = [null, undefined, 'xyz', 'audit', 'honor', 'unpaid-executive-education', 'unpaid-bootcamp'];
describe('Chat', () => {
let store;
beforeAll(async () => {
store = await initializeTestStore({
specialExams: {
activeAttempt: {
attempt_id: null,
},
exam: {
id: null,
},
},
});
});
// Generate test cases.
enabledTestCases = enabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: true }));
disabledTestCases = disabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: false }));
testCases = enabledTestCases.concat(disabledTestCases);
testCases.forEach(test => {
it(
`visibility determined by ${test.enrollmentMode} enrollment mode when enabled and not isStaff`,
async () => {
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff={false}
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId(mockXpertTestId);
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
},
);
});
// Generate test cases.
testCases = enabledModes.concat(disabledModes).map((mode) => ({ enrollmentMode: mode, isVisible: true }));
testCases.forEach(test => {
it('visibility determined by isStaff when enabled and any enrollment mode', async () => {
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId(mockXpertTestId);
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
});
});
// Generate the map function used for generating test cases by currying the map function.
// In this test suite, visibility depends on whether the enrollment mode is a valid or invalid
// enrollment mode for enabling the Chat when the user is not a staff member and the Chat is enabled. Instead of
// defining two separate map functions that differ in only one case, curry the function.
const generateMapFunction = (areEnabledModes) => (
(mode) => (
[
{
enrollmentMode: mode, isStaff: true, enabled: true, isVisible: true,
},
{
enrollmentMode: mode, isStaff: true, enabled: false, isVisible: false,
},
{
enrollmentMode: mode, isStaff: false, enabled: true, isVisible: areEnabledModes,
},
{
enrollmentMode: mode, isStaff: false, enabled: false, isVisible: false,
},
]
)
);
// Generate test cases.
enabledTestCases = enabledModes.map(generateMapFunction(true));
disabledTestCases = disabledModes.map(generateMapFunction(false));
testCases = enabledTestCases.concat(disabledTestCases);
testCases = testCases.flat();
testCases.forEach(test => {
it(
`visibility determined by ${test.enabled} enabled when ${test.isStaff} isStaff
and ${test.enrollmentMode} enrollment mode`,
async () => {
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff={test.isStaff}
enabled={test.enabled}
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId(mockXpertTestId);
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
},
);
});
it('if course end date has passed, component should not be visible', async () => {
store = await initializeTestStore({
specialExams: {
activeAttempt: {
attempt_id: 1,
},
},
courseMetadata: Factory.build('courseMetadata', {
start: '2014-02-03T05:00:00Z',
end: '2014-02-05T05:00:00Z',
}),
});
render(
<BrowserRouter>
<Chat
enrollmentMode="verified"
isStaff
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId(mockXpertTestId);
expect(chat).not.toBeInTheDocument();
});
it('if learner has active exam attempt, component should not be visible', async () => {
store = await initializeTestStore({
specialExams: {
activeAttempt: {
attempt_id: 1,
},
},
});
render(
<BrowserRouter>
<Chat
enrollmentMode="verified"
isStaff
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId(mockXpertTestId);
expect(chat).toBeInTheDocument();
});
it('displays component for audit learner if explicitly enabled', async () => {
getConfig.mockImplementation(() => ({ ENABLE_XPERT_AUDIT: true }));
store = await initializeTestStore({
courseMetadata: Factory.build('courseMetadata', {
access_expiration: { expiration_date: '' },
}),
});
render(
<BrowserRouter>
<Chat
enrollmentMode="audit"
isStaff={false}
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId(mockXpertTestId);
expect(chat).toBeInTheDocument();
});
it('does not display component for audit learner if access deadline has passed', async () => {
getConfig.mockImplementation(() => ({ ENABLE_XPERT_AUDIT: true }));
store = await initializeTestStore({
courseMetadata: Factory.build('courseMetadata', {
access_expiration: { expiration_date: '2014-02-03T05:00:00Z' },
}),
});
render(
<BrowserRouter>
<Chat
enrollmentMode="audit"
isStaff={false}
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId(mockXpertTestId);
expect(chat).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1 @@
export { default } from './Chat';

View File

@@ -1,389 +1,403 @@
import React, { useState } from 'react';
import React, { Component } from 'react';
import { Collapsible } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCalculator, faQuestionCircle, faTimesCircle, faEquals,
} from '@fortawesome/free-solid-svg-icons';
import messages from './messages';
const Calculator = () => {
const intl = useIntl();
const [equation, setEquation] = useState('');
const [result, setResult] = useState('');
class Calculator extends Component {
constructor(props) {
super(props);
this.state = {
equation: '',
result: '',
};
this.handleSubmit = this.handleSubmit.bind(this);
}
const handleSubmit = async (event) => {
async handleSubmit(event) {
event.preventDefault();
event.stopPropagation();
const urlEncoded = new URLSearchParams();
urlEncoded.append('equation', equation);
urlEncoded.append('equation', this.state.equation);
const response = await getAuthenticatedHttpClient().get(
`${getConfig().LMS_BASE_URL}/calculate?${urlEncoded.toString()}`,
);
setResult(response.data.result);
};
this.setState(() => ({ result: response.data.result }));
}
const changeEquation = (value) => {
setEquation(value);
};
changeEquation(value) {
this.setState(() => ({ equation: value }));
}
return (
<Collapsible.Advanced className="calculator">
<div className="text-right">
<Collapsible.Trigger tag="a" className="trigger btn">
<Collapsible.Visible whenOpen>
<FontAwesomeIcon icon={faTimesCircle} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
<Collapsible.Visible whenClosed>
<FontAwesomeIcon icon={faCalculator} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
{intl.formatMessage(messages['calculator.button.label'])}
</Collapsible.Trigger>
</div>
<Collapsible.Body className="calculator-content pt-4">
<form onSubmit={handleSubmit} className="container-xl form-inline flex-nowrap">
<input
type="text"
placeholder={intl.formatMessage(messages['calculator.input.field.label'])}
aria-label={intl.formatMessage(messages['calculator.input.field.label'])}
className="form-control w-100"
onChange={(event) => changeEquation(event.target.value)}
/>
<button
className="btn btn-primary mx-3"
aria-label={intl.formatMessage(messages['calculator.submit.button.label'])}
type="submit"
>
<FontAwesomeIcon icon={faEquals} aria-hidden="true" />
</button>
<input
type="text"
tabIndex="-1"
readOnly
aria-live="polite"
placeholder={intl.formatMessage(messages['calculator.result.field.placeholder'])}
aria-label={intl.formatMessage(messages['calculator.result.field.label'])}
className="form-control w-50"
value={result}
/>
</form>
<Collapsible.Advanced>
<div className="container-xl">
<Collapsible.Trigger className="btn btn-link btn-sm px-0 d-inline-flex align-items-center">
<Collapsible.Visible whenOpen>
<FontAwesomeIcon icon={faTimesCircle} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
<Collapsible.Visible whenClosed>
<FontAwesomeIcon icon={faQuestionCircle} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
<FormattedMessage
id="calculator.instructions.button.label"
defaultMessage="Calculator Instructions"
/>
</Collapsible.Trigger>
</div>
<Collapsible.Body className="container-xl pt-3" style={{ maxHeight: '50vh', overflow: 'auto' }}>
<FormattedMessage
tagName="h6"
id="calculator.instructions"
defaultMessage="For detailed information, see the {expressions_link}."
description="Text that precedes the link which redirects to help page calculator"
values={{
expressions_link: (
<a href={getConfig().SUPPORT_URL_CALCULATOR_MATH}>
<FormattedMessage
id="calculator.instructions.support.title"
defaultMessage="Help Center"
description="Anchor text for link which redirects to help page calculator"
/>
</a>
),
}}
render() {
return (
<Collapsible.Advanced className="calculator">
<div className="text-right">
<Collapsible.Trigger tag="a" className="trigger btn">
<Collapsible.Visible whenOpen>
<FontAwesomeIcon icon={faTimesCircle} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
<Collapsible.Visible whenClosed>
<FontAwesomeIcon icon={faCalculator} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
{this.props.intl.formatMessage(messages['calculator.button.label'])}
</Collapsible.Trigger>
</div>
<Collapsible.Body className="calculator-content pt-4">
<form onSubmit={this.handleSubmit} className="container-xl form-inline flex-nowrap">
<input
type="text"
placeholder={this.props.intl.formatMessage(messages['calculator.input.field.label'])}
aria-label={this.props.intl.formatMessage(messages['calculator.input.field.label'])}
className="form-control w-100"
onChange={(event) => this.changeEquation(event.target.value)}
/>
<p>
<strong>
<button
className="btn btn-primary mx-3"
aria-label={this.props.intl.formatMessage(messages['calculator.submit.button.label'])}
type="submit"
>
<FontAwesomeIcon icon={faEquals} aria-hidden="true" />
</button>
<input
type="text"
tabIndex="-1"
readOnly
aria-live="polite"
placeholder={this.props.intl.formatMessage(messages['calculator.result.field.placeholder'])}
aria-label={this.props.intl.formatMessage(messages['calculator.result.field.label'])}
className="form-control w-50"
value={this.state.result}
/>
</form>
<Collapsible.Advanced>
<div className="container-xl">
<Collapsible.Trigger className="btn btn-link btn-sm px-0 d-inline-flex align-items-center">
<Collapsible.Visible whenOpen>
<FontAwesomeIcon icon={faTimesCircle} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
<Collapsible.Visible whenClosed>
<FontAwesomeIcon icon={faQuestionCircle} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
<FormattedMessage
id="calculator.instructions.useful.tips"
defaultMessage="Useful tips:"
description="Headline for the (list of tips) about using the calculator"
id="calculator.instructions.button.label"
defaultMessage="Calculator Instructions"
/>
</strong>
</p>
<ul>
<li className="hint-item" id="hint-paren">
<FormattedMessage
id="calculator.hint1"
defaultMessage="Use parentheses () to make expressions clear. You can use parentheses inside other parentheses."
description="The text indicate that the calculator supports parentheses"
/>
</li>
<li className="hint-item" id="hint-spaces">
<FormattedMessage
id="calculator.hint2"
defaultMessage="Do not use spaces in expressions."
description="It indicate that using a space might cause un expected behavior"
/>
</li>
<li className="hint-item" id="hint-howto-constants">
<FormattedMessage
id="calculator.hint3"
defaultMessage="For constants, indicate multiplication explicitly (example: 5*c)."
description="It indicate the style of math notation"
/>
</li>
<li className="hint-item" id="hint-howto-maffixes">
<FormattedMessage
id="calculator.hint4"
defaultMessage="For affixes, type the number and affix without a space (example: 5c)."
/>
</li>
<li className="hint-item" id="hint-howto-functions">
<FormattedMessage
id="calculator.hint5"
defaultMessage="For functions, type the name of the function, then the expression in parentheses."
description="It indicate how to use a math function, e.g. exp(4)."
/>
</li>
</ul>
<table className="pgn__data-table small">
<thead>
<tr>
<th scope="col">
<FormattedMessage
id="calculator.instruction.table.to.use.heading"
defaultMessage="To Use"
description="Column header which indicate calculator functionality"
/>
</th>
<th scope="col">
<FormattedMessage
id="calculator.instruction.table.type.heading"
defaultMessage="Type"
description="Column header which indicate the supported type(s) of a the calculator functionality"
/>
</th>
<th scope="col">
<FormattedMessage
id="calculator.instruction.table.examples.heading"
defaultMessage="Examples"
description="Column header which list examples of calculator functionality"
/>
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.numbers"
defaultMessage="Numbers"
description="A calculator functionality"
/>
</th>
<td>
<ul className="list-unstyled m-0">
<li>
<FormattedMessage
id="calculator.instruction.table.to.use.numbers.type1"
defaultMessage="Integers"
description="Type of numbers that is supported the calculator"
/>
</li>
<li>
<FormattedMessage
id="calculator.instruction.table.to.use.numbers.type2"
defaultMessage="Fractions"
description="Type of numbers that is supported by the calculator"
/>
</li>
<li>
<FormattedMessage
id="calculator.instruction.table.to.use.numbers.type3"
defaultMessage="Decimals"
description="Type of numbers that is supported by the calculator"
/>
</li>
</ul>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>2520</li>
<li>2/3</li>
<li>3.14, .98</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.operators"
defaultMessage="Operators"
description="A calculator functionality"
/>
</th>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>
{' + - * / '}
<FormattedMessage
id="calculator.instruction.table.to.use.operators.type1"
defaultMessage="(add, subtract, multiply, divide)"
description="Type of opprators that are supported by the calculator"
/>
</li>
<li>
{'^ '}
<FormattedMessage
id="calculator.instruction.table.to.use.operators.type2"
defaultMessage="(raise to a power)"
description="It indicate that symbol (^) is being used to raise power, e.g. 2^2 = 4"
/>
</li>
<li>
{'|| '}
<FormattedMessage
id="calculator.instruction.table.to.use.operators.type3"
defaultMessage="(parallel resistors)"
description="It indicate that the sympol (||) is being used to calculate (parallel resistor), it is a concept in electrical/electronic engineering"
/>
</li>
</ul>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>x+(2*y)/x-1</li>
<li>x^(n+1)</li>
<li>v_IN+v_OUT</li>
<li>1||2</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.constants"
defaultMessage="Constants"
description="It indicate that the calculator support constants, e.g. the speed of light"
/>
</th>
<td dir="auto">e, pi</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>20*e</li>
<li>418*pi</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.affixes"
defaultMessage="Affixes"
/>
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.affixes.type"
defaultMessage="Percent sign (%)"
/>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>20%</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.basic.functions"
defaultMessage="Basic functions"
description="It indicate that calculator supports mathematical function"
/>
</th>
<td dir="auto">abs, exp, fact, factorial, ln, log2, log10, sqrt</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>abs(x+y)</li>
<li>sqrt(x^2-y)</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.trig.functions"
defaultMessage="Trigonometric functions"
description="Type of mathematical function that is supported by the calculator"
/>
</th>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>sin, cos, tan, sec, csc, cot</li>
<li>arcsin, sinh, arcsinh</li>
</ul>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>sin(4x+y)</li>
<li>arccsch(4x+y)</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation"
defaultMessage="Scientific notation"
description="It indicate that calculator supports scientific notation"
/>
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type1"
defaultMessage="{exponentSyntax} and the exponent"
description="Type of scientific notation that is supported by the calculator"
values={{
exponentSyntax: '10^',
}}
/>
</td>
<td dir="auto">10^-9</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type2"
defaultMessage="{notationSyntax} notation"
description="It indicate that calculator supports (e) to be used in notation"
values={{
notationSyntax: 'e',
}}
/>
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type3"
defaultMessage="{notationSyntax} and the exponent"
description="An example for using (e) in notation"
values={{
notationSyntax: '1e',
}}
/>
</td>
<td dir="auto">1e-9</td>
</tr>
</tbody>
</table>
</Collapsible.Body>
</Collapsible.Advanced>
</Collapsible.Body>
</Collapsible.Advanced>
);
</Collapsible.Trigger>
</div>
<Collapsible.Body className="container-xl pt-3" style={{ maxHeight: '50vh', overflow: 'auto' }}>
<FormattedMessage
tagName="h6"
id="calculator.instructions"
defaultMessage="For detailed information, see the {expressions_link}."
description="Text that precedes the link which redirects to help page calculator"
values={{
expressions_link: (
<a href={getConfig().SUPPORT_URL_CALCULATOR_MATH}>
<FormattedMessage
id="calculator.instructions.support.title"
defaultMessage="Help Center"
description="Anchor text for link which redirects to help page calculator"
/>
</a>
),
}}
/>
<p>
<strong>
<FormattedMessage
id="calculator.instructions.useful.tips"
defaultMessage="Useful tips:"
description="Headline for the (list of tips) about using the calculator"
/>
</strong>
</p>
<ul>
<li className="hint-item" id="hint-paren">
<FormattedMessage
id="calculator.hint1"
defaultMessage="Use parentheses () to make expressions clear. You can use parentheses inside other parentheses."
description="The text indicate that the calculator supports parentheses"
/>
</li>
<li className="hint-item" id="hint-spaces">
<FormattedMessage
id="calculator.hint2"
defaultMessage="Do not use spaces in expressions."
description="It indicate that using a space might cause un expected behavior"
/>
</li>
<li className="hint-item" id="hint-howto-constants">
<FormattedMessage
id="calculator.hint3"
defaultMessage="For constants, indicate multiplication explicitly (example: 5*c)."
description="It indicate the style of math notation"
/>
</li>
<li className="hint-item" id="hint-howto-maffixes">
<FormattedMessage
id="calculator.hint4"
defaultMessage="For affixes, type the number and affix without a space (example: 5c)."
/>
</li>
<li className="hint-item" id="hint-howto-functions">
<FormattedMessage
id="calculator.hint5"
defaultMessage="For functions, type the name of the function, then the expression in parentheses."
description="It indicate how to use a math function, e.g. exp(4)."
/>
</li>
</ul>
<table className="table small">
<thead>
<tr>
<th scope="col">
<FormattedMessage
id="calculator.instruction.table.to.use.heading"
defaultMessage="To Use"
description="Column header which indicate calculator functionality"
/>
</th>
<th scope="col">
<FormattedMessage
id="calculator.instruction.table.type.heading"
defaultMessage="Type"
description="Column header which indicate the supported type(s) of a the calculator functionality"
/>
</th>
<th scope="col">
<FormattedMessage
id="calculator.instruction.table.examples.heading"
defaultMessage="Examples"
description="Column header which list examples of calculator functionality"
/>
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.numbers"
defaultMessage="Numbers"
description="A calculator functionality"
/>
</th>
<td>
<ul className="list-unstyled m-0">
<li>
<FormattedMessage
id="calculator.instruction.table.to.use.numbers.type1"
defaultMessage="Integers"
description="Type of numbers that is supported the calculator"
/>
</li>
<li>
<FormattedMessage
id="calculator.instruction.table.to.use.numbers.type2"
defaultMessage="Fractions"
description="Type of numbers that is supported by the calculator"
/>
</li>
<li>
<FormattedMessage
id="calculator.instruction.table.to.use.numbers.type3"
defaultMessage="Decimals"
description="Type of numbers that is supported by the calculator"
/>
</li>
</ul>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>2520</li>
<li>2/3</li>
<li>3.14, .98</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.operators"
defaultMessage="Operators"
description="A calculator functionality"
/>
</th>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>
{' + - * / '}
<FormattedMessage
id="calculator.instruction.table.to.use.operators.type1"
defaultMessage="(add, subtract, multiply, divide)"
description="Type of opprators that are supported by the calculator"
/>
</li>
<li>
{'^ '}
<FormattedMessage
id="calculator.instruction.table.to.use.operators.type2"
defaultMessage="(raise to a power)"
description="It indicate that symbol (^) is being used to raise power, e.g. 2^2 = 4"
/>
</li>
<li>
{'|| '}
<FormattedMessage
id="calculator.instruction.table.to.use.operators.type3"
defaultMessage="(parallel resistors)"
description="It indicate that the sympol (||) is being used to calculate (parallel resistor), it is a concept in electrical/electronic engineering"
/>
</li>
</ul>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>x+(2*y)/x-1</li>
<li>x^(n+1)</li>
<li>v_IN+v_OUT</li>
<li>1||2</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.constants"
defaultMessage="Constants"
description="It indicate that the calculator support constants, e.g. the speed of light"
/>
</th>
<td dir="auto">e, pi</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>20*e</li>
<li>418*pi</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.affixes"
defaultMessage="Affixes"
/>
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.affixes.type"
defaultMessage="Percent sign (%)"
/>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>20%</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.basic.functions"
defaultMessage="Basic functions"
description="It indicate that calculator supports mathematical function"
/>
</th>
<td dir="auto">abs, exp, fact, factorial, ln, log2, log10, sqrt</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>abs(x+y)</li>
<li>sqrt(x^2-y)</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.trig.functions"
defaultMessage="Trigonometric functions"
description="Type of mathematical function that is supported by the calculator"
/>
</th>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>sin, cos, tan, sec, csc, cot</li>
<li>arcsin, sinh, arcsinh</li>
</ul>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>sin(4x+y)</li>
<li>arccsch(4x+y)</li>
</ul>
</td>
<td dir="auto" />
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation"
defaultMessage="Scientific notation"
description="It indicate that calculator supports scientific notation"
/>
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type1"
defaultMessage="{exponentSyntax} and the exponent"
description="Type of scientific notation that is supported by the calculator"
values={{
exponentSyntax: '10^',
}}
/>
</td>
<td dir="auto">10^-9</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type2"
defaultMessage="{notationSyntax} notation"
description="It indicate that calculator supports (e) to be used in notation"
values={{
notationSyntax: 'e',
}}
/>
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type3"
defaultMessage="{notationSyntax} and the exponent"
description="An example for using (e) in notation"
values={{
notationSyntax: '1e',
}}
/>
</td>
<td dir="auto">1e-9</td>
</tr>
</tbody>
</table>
</Collapsible.Body>
</Collapsible.Advanced>
</Collapsible.Body>
</Collapsible.Advanced>
);
}
}
Calculator.propTypes = {
intl: intlShape.isRequired,
};
export default Calculator;
export default injectIntl(Calculator);

View File

@@ -4,19 +4,4 @@
background-color: #f1f1f1;
box-shadow: 0 -1px 0 0 #ddd;
}
table {
tr {
border-bottom: var(--pgn-size-border-width) solid var(--pgn-color-border);
}
thead tr {
border-bottom: calc(2 * var(--pgn-size-border-width)) solid var(--pgn-color-border);
border-top: var(--pgn-size-border-width) solid var(--pgn-color-border);
}
tbody tr {
vertical-align: top;
}
}
}

View File

@@ -8,8 +8,8 @@
display: inline-block;
position: relative;
z-index: 2;
background-color: #f1f1f1 !important;
border: solid 1px #ddd !important;
background-color: #f1f1f1;
border: solid 1px #ddd;
border-bottom: none;
border-top-left-radius: .3rem;
border-top-right-radius: .3rem;

View File

@@ -1,9 +1,11 @@
import React, { useState } from 'react';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } 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 { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import messages from './messages';
@@ -13,41 +15,47 @@ function toggleNotes() {
iframe.contentWindow.postMessage('tools.toggleNotes', getConfig().LMS_BASE_URL);
}
const NotesVisibility = ({ course }) => {
const intl = useIntl();
const [visible, setVisible] = useState(course.notes.visible);
const visibilityUrl = `${getConfig().LMS_BASE_URL}/courses/${course.id}/edxnotes/visibility/`;
class NotesVisibility extends Component {
constructor(props) {
super(props);
this.state = {
visible: props.course.notes.visible,
};
this.visibilityUrl = `${getConfig().LMS_BASE_URL}/courses/${props.course.id}/edxnotes/visibility/`;
}
const handleClick = () => {
const data = { visibility: !visible };
handleClick = () => {
const data = { visibility: !this.state.visible };
getAuthenticatedHttpClient().put(
visibilityUrl,
this.visibilityUrl,
data,
).then(() => {
setVisible(!visible);
this.setState((state) => ({ visible: !state.visible }));
toggleNotes();
});
};
const message = visible ? 'notes.button.hide' : 'notes.button.show';
return (
<button
className={`trigger btn ${visible ? 'text-secondary' : 'text-success'} mx-2 `}
role="switch"
type="button"
onClick={handleClick}
onKeyDown={handleClick}
tabIndex="-1"
aria-checked={visible ? 'true' : 'false'}
>
<FontAwesomeIcon icon={faPencilAlt} aria-hidden="true" className="mr-2" />
{intl.formatMessage(messages[message])}
</button>
);
};
render() {
const message = this.state.visible ? 'notes.button.hide' : 'notes.button.show';
return (
<button
className={`trigger btn ${this.state.visible ? 'text-secondary' : 'text-success'} mx-2 `}
role="switch"
type="button"
onClick={this.handleClick}
onKeyDown={this.handleClick}
tabIndex="-1"
aria-checked={this.state.visible ? 'true' : 'false'}
>
<FontAwesomeIcon icon={faPencilAlt} aria-hidden="true" className="mr-2" />
{this.props.intl.formatMessage(messages[message])}
</button>
);
}
}
NotesVisibility.propTypes = {
intl: intlShape.isRequired,
course: PropTypes.shape({
id: PropTypes.string.isRequired,
notes: PropTypes.shape({
@@ -56,4 +64,4 @@ NotesVisibility.propTypes = {
}).isRequired,
};
export default NotesVisibility;
export default injectIntl(NotesVisibility);

View File

@@ -4,7 +4,9 @@ import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@openedx/paragon';
import { faSearch } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -14,8 +16,7 @@ import { useModel } from '../../../generic/model-store';
import messages from './messages';
import { logClick } from './utils';
const CatalogSuggestion = ({ variant }) => {
const intl = useIntl();
const CatalogSuggestion = ({ intl, variant }) => {
const { courseId } = useSelector(state => state.courseware);
const { org } = useModel('courseHomeMeta', courseId);
const { administrator } = getAuthenticatedUser();
@@ -47,7 +48,8 @@ const CatalogSuggestion = ({ variant }) => {
};
CatalogSuggestion.propTypes = {
intl: intlShape.isRequired,
variant: PropTypes.string.isRequired,
};
export default CatalogSuggestion;
export default injectIntl(CatalogSuggestion);

View File

@@ -2,7 +2,9 @@ import React, { useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLinkedinIn } from '@fortawesome/free-brands-svg-icons';
import { FormattedDate, FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
FormattedDate, FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { useDispatch, useSelector } from 'react-redux';
import {
@@ -25,17 +27,16 @@ import messages from './messages';
import { useModel } from '../../../generic/model-store';
import { requestCert } from '../../../course-home/data/thunks';
import ProgramCompletion from './ProgramCompletion';
import DashboardFootnote from './DashboardFootnote';
import UpgradeFootnote from './UpgradeFootnote';
import SocialIcons from '../../social-share/SocialIcons';
import { logClick, logVisit } from './utils';
import { DashboardLink, IdVerificationSupportLink, ProfileLink } from '../../../shared/links';
import DashboardFootnote from './DashboardFootnote';
import { CourseRecommendationsSlot } from '../../../plugin-slots/CourseExitPluginSlots';
import CourseRecommendationsSlot from '../../../plugin-slots/CourseRecommendationsSlot';
const LINKEDIN_BLUE = '#2867B2';
const CourseCelebration = () => {
const intl = useIntl();
const CourseCelebration = ({ intl }) => {
const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth;
const { courseId } = useSelector(state => state.courseware);
const dispatch = useDispatch();
@@ -363,4 +364,8 @@ const CourseCelebration = () => {
);
};
export default CourseCelebration;
CourseCelebration.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseCelebration);

View File

@@ -1,5 +1,8 @@
import { useEffect } from 'react';
import React, { useEffect } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { useSelector } from 'react-redux';
import { Navigate } from 'react-router-dom';
@@ -7,12 +10,12 @@ import CourseCelebration from './CourseCelebration';
import CourseInProgress from './CourseInProgress';
import CourseNonPassing from './CourseNonPassing';
import { COURSE_EXIT_MODES, getCourseExitMode } from './utils';
import messages from './messages';
import { unsubscribeFromGoalReminders } from './data/thunks';
import { CourseExitViewCoursesPluginSlot } from '../../../plugin-slots/CourseExitPluginSlots';
import { useModel } from '../../../generic/model-store';
const CourseExit = () => {
const CourseExit = ({ intl }) => {
const { courseId } = useSelector(state => state.courseware);
const {
certificateData,
@@ -60,10 +63,21 @@ const CourseExit = () => {
return (
<>
<CourseExitViewCoursesPluginSlot />
<div className="row w-100 mt-2 mb-4 justify-content-end">
<Button
variant="outline-primary"
href={`${getConfig().LMS_BASE_URL}/dashboard`}
>
{intl.formatMessage(messages.viewCoursesButton)}
</Button>
</div>
{body}
</>
);
};
export default CourseExit;
CourseExit.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseExit);

View File

@@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { useSelector } from 'react-redux';
import { Alert, Button } from '@openedx/paragon';
@@ -14,8 +14,7 @@ import DashboardFootnote from './DashboardFootnote';
import messages from './messages';
import { logClick, logVisit } from './utils';
const CourseInProgress = () => {
const intl = useIntl();
const CourseInProgress = ({ intl }) => {
const { courseId } = useSelector(state => state.courseware);
const {
org,
@@ -61,4 +60,8 @@ const CourseInProgress = () => {
);
};
export default CourseInProgress;
CourseInProgress.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseInProgress);

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