Compare commits

..

5 Commits

Author SHA1 Message Date
ilee2u
55dd7a84c6 temp!: attempted broader Jest tests
Attempted to create unit tests for the changes to SequenceLink.js, only to discover that doing so would require some broader end to end testing. That is to say, it would be unproductive to dig any deeper into testing. I am making this commit to at least document the work I did, for my own records.

BREAKING CHANGE: Don't merge this commit. I will be making another commit that removes the breaking tests.
2023-02-16 12:05:58 -05:00
ilee2u
91ced0050d test: draft of sequencelink subtitle unit test 2023-02-16 10:33:14 -05:00
ilee2u
fa4cd420bf fix: quick workaround for FormattedMessage error (may refactor) 2023-02-15 14:49:15 -05:00
ilee2u
bc87466e6c fix: only show description if no due date set 2023-02-15 13:45:34 -05:00
ilee2u
6c4d2fc88a fix: exams with no due date now display exam type 2023-02-15 11:53:53 -05:00
142 changed files with 34651 additions and 18030 deletions

4
.env
View File

@@ -15,7 +15,6 @@ ECOMMERCE_BASE_URL=''
ENABLE_JUMPNAV='true' ENABLE_JUMPNAV='true'
ENABLE_NOTICES='' ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='' ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
EXAMS_BASE_URL=''
FAVICON_URL='' FAVICON_URL=''
IGNORED_ERROR_REGEX='' IGNORED_ERROR_REGEX=''
INSIGHTS_BASE_URL='' INSIGHTS_BASE_URL=''
@@ -29,8 +28,6 @@ LOGO_WHITE_URL=''
LEGACY_THEME_NAME='' LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='' MARKETING_SITE_BASE_URL=''
ORDER_HISTORY_URL='' ORDER_HISTORY_URL=''
PROCTORED_EXAM_FAQ_URL=''
PROCTORED_EXAM_RULES_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='' REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEARCH_CATALOG_URL='' SEARCH_CATALOG_URL=''
SEGMENT_KEY='' SEGMENT_KEY=''
@@ -46,4 +43,3 @@ TERMS_OF_SERVICE_URL=''
TWITTER_HASHTAG='' TWITTER_HASHTAG=''
TWITTER_URL='' TWITTER_URL=''
USER_INFO_COOKIE_NAME='' USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''

View File

@@ -15,7 +15,6 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true' ENABLE_JUMPNAV='true'
ENABLE_NOTICES='' ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734' ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL=''
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX='' IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
@@ -29,8 +28,6 @@ LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000' MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders' ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000 PORT=2000
PROCTORED_EXAM_FAQ_URL=''
PROCTORED_EXAM_RULES_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses' SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY='' SEGMENT_KEY=''
@@ -46,6 +43,3 @@ TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline' TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info' USER_INFO_COOKIE_NAME='edx-user-info'
SESSION_COOKIE_DOMAIN='localhost' SESSION_COOKIE_DOMAIN='localhost'
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_KEY=''

View File

@@ -15,7 +15,6 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true' ENABLE_JUMPNAV='true'
ENABLE_NOTICES='' ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734' ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL='http://localhost:18740'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX='' IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
@@ -29,8 +28,6 @@ LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000' MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders' ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000 PORT=2000
PROCTORED_EXAM_FAQ_URL=''
PROCTORED_EXAM_RULES_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses' SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY='' SEGMENT_KEY=''
@@ -45,4 +42,3 @@ TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney' TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline' TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info' USER_INFO_COOKIE_NAME='edx-user-info'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'

View File

@@ -16,4 +16,4 @@ jobs:
secrets: secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }} GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }} GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }} SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

View File

@@ -1,20 +0,0 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "label: " it tries to apply
# the label indicated in rest of comment.
# If the comment starts with "remove label: ", it tries
# to remove the indicated label.
# Note: Labels are allowed to have spaces and this script does
# not parse spaces (as often a space is legitimate), so the command
# "label: really long lots of words label" will apply the
# label "really long lots of words label"
name: Allows for the adding and removing of labels via comment
on:
issue_comment:
types: [created]
jobs:
add_remove_labels:
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master

View File

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

View File

@@ -1,12 +0,0 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "assign me" it assigns the author to the
# ticket (case insensitive)
name: Assign comment author to ticket if they say "assign me"
on:
issue_comment:
types: [created]
jobs:
self_assign_by_comment:
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master

View File

@@ -9,13 +9,14 @@ on:
jobs: jobs:
tests: tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
node: [16]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: ${{ env.NODE_VER }} node-version: ${{ matrix.node }}
- run: make validate.ci - run: make validate.ci
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v3 uses: codecov/codecov-action@v3

2
.gitignore vendored
View File

@@ -1,8 +1,6 @@
.DS_Store .DS_Store
.eslintcache .eslintcache
.idea .idea
*.swp
*.swo
node_modules node_modules
npm-debug.log npm-debug.log
coverage coverage

1
.nvmrc
View File

@@ -1 +0,0 @@
18

View File

@@ -1,7 +1,6 @@
export TRANSIFEX_RESOURCE=frontend-app-learning export TRANSIFEX_RESOURCE=frontend-app-learning
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fa_IR,fr_CA,it_IT,pt_PT,de_DE" transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json transifex_input = $(i18n)/transifex_input.json
@@ -43,24 +42,9 @@ push_translations:
# Pushing comments to Transifex... # Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh ./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex. # Pulls translations from Transifex.
pull_translations: pull_translations:
tx pull -f --mode reviewed --languages=$(transifex_langs) tx pull -f --mode reviewed --languages=$(transifex_langs)
else
# Experimental: OEP-58 Pulls translations using atlas
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
$(intl_imports) paragon frontend-component-header frontend-component-footer frontend-app-learning
endif
# This target is used by Travis. # This target is used by Travis.
validate-no-uncommitted-package-lock-changes: validate-no-uncommitted-package-lock-changes:

View File

@@ -9,12 +9,6 @@ module.exports = createConfig('jest', {
'src/i18n', 'src/i18n',
'src/.*\\.exp\\..*', 'src/.*\\.exp\\..*',
], ],
// see https://github.com/axios/axios/issues/5026
moduleNameMapper: {
"^axios$": "axios/dist/axios.js",
// See https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
},
testTimeout: 30000, testTimeout: 30000,
testEnvironment: 'jsdom' testEnvironment: 'jsdom'
}); });

44746
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,8 @@
"prepare": "husky install", "prepare": "husky install",
"snapshot": "fedx-scripts jest --updateSnapshot", "snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress", "start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage --passWithNoTests" "test": "fedx-scripts jest --coverage --passWithNoTests",
"testx": "fedx-scripts jest -t 'Outline Tab' --coverage --passWithNoTests"
}, },
"author": "edX", "author": "edX",
"license": "AGPL-3.0", "license": "AGPL-3.0",
@@ -29,46 +30,44 @@
"url": "https://github.com/openedx/frontend-app-learning/issues" "url": "https://github.com/openedx/frontend-app-learning/issues"
}, },
"dependencies": { "dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.2.0", "@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "12.2.1", "@edx/frontend-component-footer": "11.1.0",
"@edx/frontend-component-header": "4.6.0", "@edx/frontend-component-header": "^3.6.0",
"@edx/frontend-lib-learning-assistant": "^1.14.0", "@edx/frontend-lib-special-exams": "^2.2.0",
"@edx/frontend-lib-special-exams": "2.23.2", "@edx/frontend-platform": "2.5.1",
"@edx/frontend-platform": "5.5.2", "@edx/paragon": "^20.24.0",
"@edx/paragon": "20.46.0",
"@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0",
"@fortawesome/fontawesome-svg-core": "1.3.0", "@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-brands-svg-icons": "5.15.4", "@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4", "@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4", "@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "^0.1.4", "@fortawesome/react-fontawesome": "0.1.18",
"@popperjs/core": "2.11.8", "@popperjs/core": "2.11.6",
"@reduxjs/toolkit": "1.8.1", "@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.2", "classnames": "2.3.2",
"core-js": "3.22.2", "core-js": "3.22.2",
"history": "5.3.0", "history": "^5.3.0",
"js-cookie": "3.0.5", "js-cookie": "3.0.1",
"lodash.camelcase": "4.3.0", "lodash.camelcase": "4.3.0",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"query-string": "^7.1.3", "query-string": "^7.1.1",
"react": "17.0.2", "react": "16.14.0",
"react-dom": "17.0.2", "react-dom": "16.14.0",
"react-helmet": "6.1.0", "react-helmet": "6.1.0",
"react-redux": "7.2.9", "react-redux": "7.2.9",
"react-router": "6.15.0", "react-router": "5.2.1",
"react-router-dom": "6.15.0", "react-router-dom": "5.3.0",
"react-share": "4.4.1", "react-share": "^4.4.1",
"redux": "4.1.2", "redux": "4.1.2",
"regenerator-runtime": "0.13.11", "regenerator-runtime": "0.13.11",
"reselect": "4.1.8", "reselect": "4.1.7",
"truncate-html": "1.0.4", "truncate-html": "1.0.4",
"util": "0.12.5" "util": "0.12.5"
}, },
"devDependencies": { "devDependencies": {
"@edx/browserslist-config": "1.2.0", "@edx/browserslist-config": "1.1.1",
"@edx/frontend-build": "^12.9.10", "@edx/frontend-build": "^12.4.15",
"@edx/reactifex": "2.2.0", "@edx/reactifex": "2.1.1",
"@pact-foundation/pact": "^11.0.2", "@pact-foundation/pact": "9.17.3",
"@testing-library/jest-dom": "5.16.5", "@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5", "@testing-library/react": "12.1.5",
"@testing-library/user-event": "13.5.0", "@testing-library/user-event": "13.5.0",
@@ -76,7 +75,7 @@
"copy-webpack-plugin": "^11.0.0", "copy-webpack-plugin": "^11.0.0",
"es-check": "6.2.1", "es-check": "6.2.1",
"husky": "7.0.4", "husky": "7.0.4",
"jest": "29.5.0", "jest": "27.5.1",
"rosie": "2.1.0" "rosie": "2.1.0"
} }
} }

View File

@@ -68,7 +68,7 @@ const CourseStartAlert = ({ payload }) => {
<Alert variant="info" icon={Info}> <Alert variant="info" icon={Info}>
<strong> <strong>
<FormattedMessage <FormattedMessage
id="learning.outline.alert.start.long" id="learning.outline.alert.end.long"
defaultMessage="Course starts {timeRemaining} on {courseStartDate}." defaultMessage="Course starts {timeRemaining} on {courseStartDate}."
description="Used when the time remaining is more than a day away." description="Used when the time remaining is more than a day away."
values={{ values={{
@@ -88,7 +88,7 @@ const CourseStartAlert = ({ payload }) => {
</strong> </strong>
<br /> <br />
<FormattedMessage <FormattedMessage
id="learning.outline.alert.start.calendar" id="learning.outline.alert.end.calendar"
defaultMessage="Dont forget to add a calendar reminder!" defaultMessage="Dont forget to add a calendar reminder!"
description="It's just a recommendation for learners to set a reminder for the course starting date and is shown when the course starting date is more than a day. " description="It's just a recommendation for learners to set a reminder for the course starting date and is shown when the course starting date is more than a day. "
/> />

View File

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

View File

@@ -28,7 +28,6 @@ Factory.define('outlineTabData')
upgrade_url: `${host}/dashboard`, upgrade_url: `${host}/dashboard`,
})) }))
.attrs({ .attrs({
course_access_redirect: false,
has_scheduled_content: null, has_scheduled_content: null,
access_expiration: null, access_expiration: null,
can_show_upgrade_sock: false, can_show_upgrade_sock: false,

View File

@@ -18,9 +18,6 @@ Object {
"sequenceMightBeUnit": false, "sequenceMightBeUnit": false,
"sequenceStatus": "loading", "sequenceStatus": "loading",
}, },
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course": Object {
@@ -339,9 +336,6 @@ Object {
"sequenceMightBeUnit": false, "sequenceMightBeUnit": false,
"sequenceStatus": "loading", "sequenceStatus": "loading",
}, },
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course": Object {
@@ -538,9 +532,6 @@ Object {
"sequenceMightBeUnit": false, "sequenceMightBeUnit": false,
"sequenceStatus": "loading", "sequenceStatus": "loading",
}, },
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course": Object {

View File

@@ -204,18 +204,12 @@ export async function getDatesTabData(courseId) {
const { data } = await getAuthenticatedHttpClient().get(url); const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data); return camelCaseObject(data);
} catch (error) { } catch (error) {
const httpErrorStatus = error?.response?.status; const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 401) { if (httpErrorStatus === 401) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining // The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
// courseAccess in the metadata call, so just ignore this status for now. // courseAccess in the metadata call, so just ignore this status for now.
return {}; return {};
} }
if (httpErrorStatus === 403) {
// The backend sends this if there is a course access error and the user should be redirected. The redirect
// info is included in the course metadata request and will be handled there as long as this call returns
// without an error
return {};
}
throw error; throw error;
} }
} }
@@ -265,7 +259,7 @@ export async function getProgressTabData(courseId, targetUserId) {
return camelCasedData; return camelCasedData;
} catch (error) { } catch (error) {
const httpErrorStatus = error?.response?.status; const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) { if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`); global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
return {}; return {};
@@ -275,12 +269,6 @@ export async function getProgressTabData(courseId, targetUserId) {
// courseAccess in the metadata call, so just ignore this status for now. // courseAccess in the metadata call, so just ignore this status for now.
return {}; return {};
} }
if (httpErrorStatus === 403) {
// The backend sends this if there is a course access error and the user should be redirected. The redirect
// info is included in the course metadata request and will be handled there as long as this call returns
// without an error
return {};
}
throw error; throw error;
} }
} }
@@ -334,20 +322,7 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
export async function getOutlineTabData(courseId) { export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`; const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
const requestTime = Date.now(); const requestTime = Date.now();
let tabData; const tabData = await getAuthenticatedHttpClient().get(url);
try {
tabData = await getAuthenticatedHttpClient().get(url);
} catch (error) {
const httpErrorStatus = error?.response?.status;
if (httpErrorStatus === 403) {
// The backend sends this if there is a course access error and the user should be redirected. The redirect
// info is included in the course metadata request and will be handled there as long as this call returns
// without an error
return {};
}
throw error;
}
const responseTime = Date.now(); const responseTime = Date.now();
const { const {

View File

@@ -1,6 +1,6 @@
import { Pact, Matchers } from '@pact-foundation/pact';
import path from 'path'; import path from 'path';
import { mergeConfig, getConfig } from '@edx/frontend-platform'; import { mergeConfig, getConfig } from '@edx/frontend-platform';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { import {
getCourseHomeCourseMetadata, getCourseHomeCourseMetadata,
@@ -14,8 +14,8 @@ import {
const { const {
somethingLike: like, term, boolean, string, eachLike, somethingLike: like, term, boolean, string, eachLike,
} = MatchersV3; } = Matchers;
const provider = new PactV3({ const provider = new Pact({
consumer: 'frontend-app-learning', consumer: 'frontend-app-learning',
provider: 'lms', provider: 'lms',
log: path.resolve(process.cwd(), 'src/course-home/data/pact-tests/logs', 'pact.log'), log: path.resolve(process.cwd(), 'src/course-home/data/pact-tests/logs', 'pact.log'),
@@ -28,193 +28,194 @@ const provider = new PactV3({
describe('Course Home Service', () => { describe('Course Home Service', () => {
beforeAll(async () => { beforeAll(async () => {
initializeMockApp(); initializeMockApp();
mergeConfig({ await provider
LMS_BASE_URL: 'http://localhost:8081', .setup()
}, 'Custom app config for pact tests'); .then((options) => mergeConfig({
LMS_BASE_URL: `http://localhost:${options.port}`,
}, 'Custom app config for pact tests'));
}); });
afterEach(() => provider.verify());
afterAll(() => provider.finalize());
describe('When a request to fetch tab is made', () => { describe('When a request to fetch tab is made', () => {
it('returns tab data for a course_id', async () => { it('returns tab data for a course_id', async () => {
setTimeout(() => { await provider.addInteraction({
provider.addInteraction({ state: `Tab data exists for course_id ${courseId}`,
state: `Tab data exists for course_id ${courseId}`, uponReceiving: 'a request to fetch tab',
uponReceiving: 'a request to fetch tab', withRequest: {
withRequest: { method: 'GET',
method: 'GET', path: `/api/course_home/course_metadata/${courseId}`,
path: `/api/course_home/course_metadata/${courseId}`, },
}, willRespondWith: {
willRespondWith: { status: 200,
status: 200, body: {
body: { can_show_upgrade_sock: boolean(false),
can_show_upgrade_sock: boolean(false), verified_mode: like({
verified_mode: like({ access_expiration_date: null,
access_expiration_date: null, currency: 'USD',
currency: 'USD', currency_symbol: '$',
currency_symbol: '$', price: 149,
price: 149, sku: '8CF08E5',
sku: '8CF08E5', upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`, }),
}), celebrations: like({
celebrations: like({ first_section: false,
first_section: false, streak_length_to_celebrate: null,
streak_length_to_celebrate: null, streak_discount_enabled: false,
streak_discount_enabled: false, }),
}), course_access: {
course_access: { has_access: boolean(true),
has_access: boolean(true), error_code: null,
error_code: null, developer_message: null,
developer_message: null, user_message: null,
user_message: null, additional_context_user_message: null,
additional_context_user_message: null, user_fragment: null,
user_fragment: null,
},
course_id: term({
generate: 'course-v1:edX+DemoX+Demo_Course',
matcher: opaqueKeysRegex,
}),
is_enrolled: boolean(true),
is_self_paced: boolean(false),
is_staff: boolean(true),
number: string('DemoX'),
org: string('edX'),
original_user_is_staff: boolean(true),
start: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
tabs: eachLike({
tab_id: 'courseware',
title: 'Course',
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
}),
title: string('Demonstration Course'),
username: string('edx'),
}, },
}, course_id: term({
}); generate: 'course-v1:edX+DemoX+Demo_Course',
const normalizedTabData = { matcher: opaqueKeysRegex,
canShowUpgradeSock: false, }),
verifiedMode: { is_enrolled: boolean(true),
accessExpirationDate: null, is_self_paced: boolean(false),
currency: 'USD', is_staff: boolean(true),
currencySymbol: '$', number: string('DemoX'),
price: 149, org: string('edX'),
sku: '8CF08E5', original_user_is_staff: boolean(true),
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`, start: term({
}, generate: '2013-02-05T05:00:00Z',
celebrations: { matcher: dateRegex,
firstSection: false, }),
streakLengthToCelebrate: null, tabs: eachLike({
streakDiscountEnabled: false, tab_id: 'courseware',
},
courseAccess: {
hasAccess: true,
errorCode: null,
developerMessage: null,
userMessage: null,
additionalContextUserMessage: null,
userFragment: null,
},
courseId: 'course-v1:edX+DemoX+Demo_Course',
isEnrolled: true,
isMasquerading: false,
isSelfPaced: false,
isStaff: true,
number: 'DemoX',
org: 'edX',
originalUserIsStaff: true,
start: '2013-02-05T05:00:00Z',
tabs: [
{
slug: 'outline',
title: 'Course', title: 'Course',
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`, url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
}, }),
], title: string('Demonstration Course'),
title: 'Demonstration Course', username: string('edx'),
username: 'edx', },
}; },
const response = getCourseHomeCourseMetadata(courseId, 'outline'); });
expect(response).toBeTruthy(); const normalizedTabData = {
expect(response).toEqual(normalizedTabData); canShowUpgradeSock: false,
}, 100); verifiedMode: {
accessExpirationDate: null,
currency: 'USD',
currencySymbol: '$',
price: 149,
sku: '8CF08E5',
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
},
celebrations: {
firstSection: false,
streakLengthToCelebrate: null,
streakDiscountEnabled: false,
},
courseAccess: {
hasAccess: true,
errorCode: null,
developerMessage: null,
userMessage: null,
additionalContextUserMessage: null,
userFragment: null,
},
courseId: 'course-v1:edX+DemoX+Demo_Course',
isEnrolled: true,
isMasquerading: false,
isSelfPaced: false,
isStaff: true,
number: 'DemoX',
org: 'edX',
originalUserIsStaff: true,
start: '2013-02-05T05:00:00Z',
tabs: [
{
slug: 'outline',
title: 'Course',
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
},
],
title: 'Demonstration Course',
username: 'edx',
};
const response = await getCourseHomeCourseMetadata(courseId, 'outline');
expect(response).toBeTruthy();
expect(response).toEqual(normalizedTabData);
}); });
}); });
describe('When a request to fetch dates tab is made', () => { describe('When a request to fetch dates tab is made', () => {
it('returns course date blocks for a course_id', async () => { it('returns course date blocks for a course_id', async () => {
setTimeout(() => { await provider.addInteraction({
provider.addInteraction({ state: `course date blocks exist for course_id ${courseId}`,
state: `course date blocks exist for course_id ${courseId}`, uponReceiving: 'a request to fetch dates tab',
uponReceiving: 'a request to fetch dates tab', withRequest: {
withRequest: { method: 'GET',
method: 'GET', path: `/api/course_home/dates/${courseId}`,
path: `/api/course_home/dates/${courseId}`, },
}, willRespondWith: {
willRespondWith: { status: 200,
status: 200, body: {
body: { dates_banner_info: like({
dates_banner_info: like({ missed_deadlines: false,
missed_deadlines: false, content_type_gating_enabled: false,
content_type_gating_enabled: false, missed_gated_content: false,
missed_gated_content: false, verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`, }),
}), course_date_blocks: eachLike({
course_date_blocks: eachLike({ assignment_type: null,
assignment_type: null,
complete: null,
date: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
date_type: term({
generate: 'verified-upgrade-deadline',
matcher: dateTypeRegex,
}),
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
learner_has_access: true,
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
link_text: 'Upgrade to Verified Certificate',
title: 'Verification Upgrade Deadline',
extra_info: null,
first_component_block_id: '',
}),
has_ended: boolean(false),
learner_is_full_access: boolean(true),
user_timezone: null,
},
},
});
const camelCaseResponse = {
datesBannerInfo: {
missedDeadlines: false,
contentTypeGatingEnabled: false,
missedGatedContent: false,
verifiedUpgradeLink: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
},
courseDateBlocks: [
{
assignmentType: null,
complete: null, complete: null,
date: '2013-02-05T05:00:00Z', date: term({
dateType: 'verified-upgrade-deadline', generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
date_type: term({
generate: 'verified-upgrade-deadline',
matcher: dateTypeRegex,
}),
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.', description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
learnerHasAccess: true, learner_has_access: true,
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`, link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
linkText: 'Upgrade to Verified Certificate', link_text: 'Upgrade to Verified Certificate',
title: 'Verification Upgrade Deadline', title: 'Verification Upgrade Deadline',
extraInfo: null, extra_info: null,
firstComponentBlockId: '', first_component_block_id: '',
}, }),
], has_ended: boolean(false),
hasEnded: false, learner_is_full_access: boolean(true),
learnerIsFullAccess: true, user_timezone: null,
userTimezone: null, },
}; },
const response = getDatesTabData(courseId); });
expect(response).toBeTruthy(); const camelCaseResponse = {
expect(response).toEqual(camelCaseResponse); datesBannerInfo: {
}, 100); missedDeadlines: false,
contentTypeGatingEnabled: false,
missedGatedContent: false,
verifiedUpgradeLink: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
},
courseDateBlocks: [
{
assignmentType: null,
complete: null,
date: '2013-02-05T05:00:00Z',
dateType: 'verified-upgrade-deadline',
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
learnerHasAccess: true,
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
linkText: 'Upgrade to Verified Certificate',
title: 'Verification Upgrade Deadline',
extraInfo: null,
firstComponentBlockId: '',
},
],
hasEnded: false,
learnerIsFullAccess: true,
userTimezone: null,
};
const response = await getDatesTabData(courseId);
expect(response).toBeTruthy();
expect(response).toEqual(camelCaseResponse);
}); });
}); });
}); });

View File

@@ -21,18 +21,6 @@ describe('Data layer integration tests', () => {
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`; let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl); courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const courseHomeAccessDeniedMetadata = Factory.build(
'courseHomeMetadata',
{
id: courseId,
course_access: {
has_access: false,
error_code: 'bad codes',
additional_context_user_message: 'your Codes Are BAD',
},
},
);
let store; let store;
beforeEach(() => { beforeEach(() => {
@@ -67,40 +55,16 @@ describe('Data layer integration tests', () => {
const state = store.getState(); const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded'); expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot({ expect(state).toMatchSnapshot();
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
});
}); });
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).reply(errorStatus, {});
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
let expectedState = 'failed';
if (errorStatus === 401 || errorStatus === 403) {
expectedState = 'denied';
}
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
},
);
}); });
describe('Test fetchOutlineTab', () => { describe('Test fetchOutlineTab', () => {
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`; const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`;
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
it('Should result in fetch failure if error occurs', async () => { it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError(); axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(outlineUrl).networkError(); axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
@@ -111,6 +75,8 @@ describe('Data layer integration tests', () => {
it('Should fetch, normalize, and save metadata', async () => { it('Should fetch, normalize, and save metadata', async () => {
const outlineTabData = Factory.build('outlineTabData', { courseId }); const outlineTabData = Factory.build('outlineTabData', { courseId });
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata); axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(outlineUrl).reply(200, outlineTabData); axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
@@ -118,31 +84,8 @@ describe('Data layer integration tests', () => {
const state = store.getState(); const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded'); expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot({ expect(state).toMatchSnapshot();
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
});
}); });
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(outlineUrl).reply(errorStatus, {});
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
let expectedState = 'failed';
if (errorStatus === 403) {
expectedState = 'denied';
}
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
},
);
}); });
describe('Test fetchProgressTab', () => { describe('Test fetchProgressTab', () => {
@@ -170,14 +113,7 @@ describe('Data layer integration tests', () => {
const state = store.getState(); const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded'); expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot({ expect(state).toMatchSnapshot();
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
});
}); });
it('Should handle the url including a targetUserId', async () => { it('Should handle the url including a targetUserId', async () => {
@@ -193,19 +129,6 @@ describe('Data layer integration tests', () => {
const state = store.getState(); const state = store.getState();
expect(state.courseHome.targetUserId).toEqual(2); expect(state.courseHome.targetUserId).toEqual(2);
}); });
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
const progressUrl = `${progressBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(progressUrl).reply(errorStatus, {});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
expect(store.getState().courseHome.courseStatus).toEqual('denied');
},
);
}); });
describe('Test saveCourseGoal', () => { describe('Test saveCourseGoal', () => {

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Routes, Route } from 'react-router-dom'; import { Route } from 'react-router';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie'; import { Factory } from 'rosie';
import { getConfig, history } from '@edx/frontend-platform'; import { getConfig, history } from '@edx/frontend-platform';
@@ -32,16 +32,11 @@ describe('DatesTab', () => {
component = ( component = (
<AppProvider store={store}> <AppProvider store={store}>
<UserMessagesProvider> <UserMessagesProvider>
<Routes> <Route path="/course/:courseId/dates">
<Route <TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
path="/course/:courseId/dates" <DatesTab />
element={( </TabContainer>
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome"> </Route>
<DatesTab />
</TabContainer>
)}
/>
</Routes>
</UserMessagesProvider> </UserMessagesProvider>
</AppProvider> </AppProvider>
); );

View File

@@ -38,21 +38,21 @@ function getBadgeListAndColor(date, intl, item, items) {
message: messages.today, message: messages.today,
shownForDay: isToday, shownForDay: isToday,
bg: 'bg-warning-300', bg: 'bg-warning-300',
className: 'text-dark', className: 'text-black',
}, },
{ {
message: messages.completed, message: messages.completed,
shownForDay: assignments.length && assignments.every(isComplete), shownForDay: assignments.length && assignments.every(isComplete),
shownForItem: x => isLearnerAssignment(x) && isComplete(x), shownForItem: x => isLearnerAssignment(x) && isComplete(x),
bg: 'bg-light-500', bg: 'bg-light-500',
className: 'text-dark', className: 'text-black',
}, },
{ {
message: messages.pastDue, message: messages.pastDue,
shownForDay: assignments.length && assignments.every(isPastDue), shownForDay: assignments.length && assignments.every(isPastDue),
shownForItem: x => isLearnerAssignment(x) && isPastDue(x), shownForItem: x => isLearnerAssignment(x) && isPastDue(x),
bg: 'bg-dark-200', bg: 'bg-dark-200',
className: 'text-dark', className: 'text-white',
}, },
{ {
message: messages.dueNext, message: messages.dueNext,

View File

@@ -2,20 +2,21 @@ import { getConfig } from '@edx/frontend-platform';
import { injectIntl } from '@edx/frontend-platform/i18n'; import { injectIntl } from '@edx/frontend-platform/i18n';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useParams, generatePath, useNavigate } from 'react-router-dom'; import { generatePath, useHistory } from 'react-router';
import { useParams } from 'react-router-dom';
import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks'; import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks';
const DiscussionTab = () => { const DiscussionTab = () => {
const { courseId } = useSelector(state => state.courseHome); const { courseId } = useSelector(state => state.courseHome);
const { path } = useParams(); const { path } = useParams();
const [originalPath] = useState(path); const [originalPath] = useState(path);
const navigate = useNavigate(); const history = useHistory();
const [, iFrameHeight] = useIFrameHeight(); const [, iFrameHeight] = useIFrameHeight();
useIFramePluginEvents({ useIFramePluginEvents({
'discussions.navigate': (payload) => { 'discussions.navigate': (payload) => {
const basePath = generatePath('/course/:courseId/discussion', { courseId }); const basePath = generatePath('/course/:courseId/discussion', { courseId });
navigate(`${basePath}/${payload.path}`); history.push(`${basePath}/${payload.path}`);
}, },
}); });
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`; const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;

View File

@@ -4,7 +4,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import React from 'react'; import React from 'react';
import { Route, Routes } from 'react-router-dom'; import { Route } from 'react-router';
import { Factory } from 'rosie'; import { Factory } from 'rosie';
import { UserMessagesProvider } from '../../generic/user-messages'; import { UserMessagesProvider } from '../../generic/user-messages';
import { import {
@@ -30,16 +30,11 @@ describe('DiscussionTab', () => {
component = ( component = (
<AppProvider store={store}> <AppProvider store={store}>
<UserMessagesProvider> <UserMessagesProvider>
<Routes> <Route path="/course/:courseId/discussion">
<Route <TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
path="/course/:courseId/discussion" <DiscussionTab />
element={( </TabContainer>
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome"> </Route>
<DiscussionTab />
</TabContainer>
)}
/>
</Routes>
</UserMessagesProvider> </UserMessagesProvider>
</AppProvider> </AppProvider>
); );

View File

@@ -1,9 +1,7 @@
import React from 'react'; import React from 'react';
import { import { Route } from 'react-router';
MemoryRouter, Route, Routes,
} from 'react-router-dom';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { getConfig } from '@edx/frontend-platform'; import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react'; import { AppProvider } from '@edx/frontend-platform/react';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
@@ -26,16 +24,13 @@ describe('GoalUnsubscribe', () => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore(); store = initializeStore();
component = ( component = (
<AppProvider store={store} wrapWithRouter={false}> <AppProvider store={store}>
<UserMessagesProvider> <UserMessagesProvider>
<MemoryRouter initialEntries={['/goal-unsubscribe/TOKEN']}> <Route path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
<Routes>
<Route path="/goal-unsubscribe/:token" element={<GoalUnsubscribe />} />
</Routes>
</MemoryRouter>
</UserMessagesProvider> </UserMessagesProvider>
</AppProvider> </AppProvider>
); );
history.push('/goal-unsubscribe/TOKEN'); // so we can pull token from url
}); });
it('starts with a spinner', () => { it('starts with a spinner', () => {

View File

@@ -9,9 +9,8 @@ const LmsHtmlFragment = ({
title, title,
...rest ...rest
}) => { }) => {
const direction = document.documentElement?.getAttribute('dir') || 'ltr';
const wholePage = ` const wholePage = `
<html dir="${direction}"> <html>
<head> <head>
<base href="${getConfig().LMS_BASE_URL}" target="_parent"> <base href="${getConfig().LMS_BASE_URL}" target="_parent">
<link rel="stylesheet" href="/static/${getConfig().LEGACY_THEME_NAME ? `${getConfig().LEGACY_THEME_NAME}/` : ''}css/bootstrap/lms-main.css"> <link rel="stylesheet" href="/static/${getConfig().LEGACY_THEME_NAME ? `${getConfig().LEGACY_THEME_NAME}/` : ''}css/bootstrap/lms-main.css">

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { history } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon'; import { Button } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages'; import { AlertList } from '../../generic/user-messages';
@@ -66,7 +67,6 @@ const OutlineTab = ({ intl }) => {
} = useModel('coursewareMeta', courseId); } = useModel('coursewareMeta', courseId);
const [expandAll, setExpandAll] = useState(false); const [expandAll, setExpandAll] = useState(false);
const navigate = useNavigate();
const eventProperties = { const eventProperties = {
org_key: org, org_key: org,
@@ -115,10 +115,8 @@ const OutlineTab = ({ intl }) => {
// Deleting the course_start query param as it only needs to be set once // Deleting the course_start query param as it only needs to be set once
// whenever passed in query params. // whenever passed in query params.
currentParams.delete('start_course'); currentParams.delete('start_course');
navigate({ history.replace({
pathname: location.pathname, search: currentParams.toString(),
search: `?${currentParams.toString()}`,
replace: true,
}); });
} }
}, [location.search]); }, [location.search]);

View File

@@ -23,6 +23,8 @@ import { CERT_STATUS_TYPE } from './alerts/certificate-status-alert/CertificateS
import OutlineTab from './OutlineTab'; import OutlineTab from './OutlineTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage'; import LoadedTabPage from '../../tab-page/LoadedTabPage';
import SequenceLink from './SequenceLink';
initializeMockApp(); initializeMockApp();
jest.mock('@edx/frontend-platform/analytics'); jest.mock('@edx/frontend-platform/analytics');
@@ -119,11 +121,11 @@ describe('Outline Tab', () => {
// Click to expand section // Click to expand section
userEvent.click(expandButton); userEvent.click(expandButton);
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true')); expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
// Click to collapse section // Click to collapse section
userEvent.click(expandButton); userEvent.click(expandButton);
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false')); expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
}); });
it('displays correct icon for complete assignment', async () => { it('displays correct icon for complete assignment', async () => {
@@ -154,6 +156,49 @@ describe('Outline Tab', () => {
const sequenceLink = screen.getByText('Title of Sequence'); const sequenceLink = screen.getByText('Title of Sequence');
expect(sequenceLink.getAttribute('href')).toContain(`/course/${courseId}`); expect(sequenceLink.getAttribute('href')).toContain(`/course/${courseId}`);
}); });
it('if exam due date set, exam description AND due date appear', async () => {
// Create a due date set a year into the future
const now = new Date();
const dueDate = new Date(now.getFullYear() + 1, now.getMonth(), now.getDate());
// Build course blocks with a future due date set
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', {
sequenceBlocks: [
<SequenceLink
key={0}
id={0}
courseId={courseId}
sequence={
{
complete: false,
description: 'Description of Sequence',
due: dueDate,
showLink: true,
title: 'Title of Subsection',
}
}
first={null}
/>,
],
});
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
// Button renders as "Expand All"
const expandButton = screen.getByRole('button', { name: 'Expand all' });
expect(expandButton).toBeInTheDocument();
// Click to expand section
userEvent.click(expandButton);
// Look for a substring that says "(exam type) Exam due (datetime)""
expect(screen.getByText(/Exam due/)).toBeInDocument();
});
// If due date is NOT set, ONLY display description
}); });
describe('Suggested schedule alerts', () => { describe('Suggested schedule alerts', () => {

View File

@@ -18,7 +18,7 @@ const ProgressTab = () => {
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
const { const {
gradesFeatureIsFullyLocked, disableProgressGraph, gradesFeatureIsFullyLocked,
} = useModel('progress', courseId); } = useModel('progress', courseId);
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : ''; const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
@@ -38,7 +38,7 @@ const ProgressTab = () => {
<div className="row w-100 m-0"> <div className="row w-100 m-0">
{/* Main body */} {/* Main body */}
<div className="col-12 col-md-8 p-0"> <div className="col-12 col-md-8 p-0">
{!disableProgressGraph && <CourseCompletion />} <CourseCompletion />
{!wideScreen && <CertificateStatus />} {!wideScreen && <CertificateStatus />}
<CourseGrade /> <CourseGrade />
<div className={`grades my-4 p-4 rounded raised-card ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsFullyLocked}> <div className={`grades my-4 p-4 rounded raised-card ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsFullyLocked}>

View File

@@ -1,6 +1,7 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { history } from '@edx/frontend-platform';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { defaultMemoize as memoize } from 'reselect'; import { defaultMemoize as memoize } from 'reselect';
@@ -16,46 +17,45 @@ import { TabPage } from '../tab-page';
import Course from './course'; import Course from './course';
import { handleNextSectionCelebration } from './course/celebration'; import { handleNextSectionCelebration } from './course/celebration';
import withParamsAndNavigation from './utils';
// Look at where this is called in componentDidUpdate for more info about its usage // Look at where this is called in componentDidUpdate for more info about its usage
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId, navigate) => { const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
if (courseStatus === 'loaded' && !sequenceId) { if (courseStatus === 'loaded' && !sequenceId) {
// Note that getResumeBlock is just an API call, not a redux thunk. // Note that getResumeBlock is just an API call, not a redux thunk.
getResumeBlock(courseId).then((data) => { getResumeBlock(courseId).then((data) => {
// This is a replace because we don't want this change saved in the browser's history. // This is a replace because we don't want this change saved in the browser's history.
if (data.sectionId && data.unitId) { if (data.sectionId && data.unitId) {
navigate(`/course/${courseId}/${data.sectionId}/${data.unitId}`, { replace: true }); history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
} else if (firstSequenceId) { } else if (firstSequenceId) {
navigate(`/course/${courseId}/${firstSequenceId}`, { replace: true }); history.replace(`/course/${courseId}/${firstSequenceId}`);
} }
}); });
} }
}); });
// Look at where this is called in componentDidUpdate for more info about its usage // Look at where this is called in componentDidUpdate for more info about its usage
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => { const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) { if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) {
navigate(`/course/${courseId}/${unitId}`, { replace: true }); history.replace(`/course/${courseId}/${unitId}`);
} }
}); });
// Look at where this is called in componentDidUpdate for more info about its usage // Look at where this is called in componentDidUpdate for more info about its usage
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => { const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) { if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) {
// If the section is non-empty, redirect to its first sequence. // If the section is non-empty, redirect to its first sequence.
if (section.sequenceIds && section.sequenceIds[0]) { if (section.sequenceIds && section.sequenceIds[0]) {
navigate(`/course/${courseId}/${section.sequenceIds[0]}`, { replace: true }); history.replace(`/course/${courseId}/${section.sequenceIds[0]}`);
// Otherwise, just go to the course root, letting the resume redirect take care of things. // Otherwise, just go to the course root, letting the resume redirect take care of things.
} else { } else {
navigate(`/course/${courseId}`, { replace: true }); history.replace(`/course/${courseId}`);
} }
} }
}); });
// Look at where this is called in componentDidUpdate for more info about its usage // Look at where this is called in componentDidUpdate for more info about its usage
const checkUnitToSequenceUnitRedirect = memoize( const checkUnitToSequenceUnitRedirect = memoize(
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId, navigate) => { (courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && !section && !routeUnitId) { if (courseStatus === 'loaded' && sequenceStatus === 'failed' && !section && !routeUnitId) {
if (sequenceMightBeUnit) { if (sequenceMightBeUnit) {
// If the sequence failed to load as a sequence, but it is marked as a possible unit, then // If the sequence failed to load as a sequence, but it is marked as a possible unit, then
@@ -64,62 +64,60 @@ const checkUnitToSequenceUnitRedirect = memoize(
getSequenceForUnitDeprecated(courseId, unitId).then( getSequenceForUnitDeprecated(courseId, unitId).then(
parentId => { parentId => {
if (parentId) { if (parentId) {
navigate(`/course/${courseId}/${parentId}/${unitId}`, { replace: true }); history.replace(`/course/${courseId}/${parentId}/${unitId}`);
} else { } else {
navigate(`/course/${courseId}`, { replace: true }); history.replace(`/course/${courseId}`);
} }
}, },
() => { // error case () => { // error case
navigate(`/course/${courseId}`, { replace: true }); history.replace(`/course/${courseId}`);
}, },
); );
} else { } else {
// Invalid sequence that isn't a unit either. Redirect up to main course. // Invalid sequence that isn't a unit either. Redirect up to main course.
navigate(`/course/${courseId}`, { replace: true }); history.replace(`/course/${courseId}`);
} }
} }
}, },
); );
// Look at where this is called in componentDidUpdate for more info about its usage // Look at where this is called in componentDidUpdate for more info about its usage
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId, navigate) => { const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
if (sequenceStatus === 'loaded' && sequence.id && !unitId) { if (sequenceStatus === 'loaded' && sequence.id && !unitId) {
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) { if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
const nextUnitId = sequence.unitIds[sequence.activeUnitIndex]; const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
// This is a replace because we don't want this change saved in the browser's history. // This is a replace because we don't want this change saved in the browser's history.
navigate(`/course/${courseId}/${sequence.id}/${nextUnitId}`, { replace: true }); history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
} }
} }
}); });
// Look at where this is called in componentDidUpdate for more info about its usage // Look at where this is called in componentDidUpdate for more info about its usage
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize( const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
(courseId, sequenceStatus, sequence, unitId, navigate) => { if (sequenceStatus !== 'loaded' || !sequence.id) {
if (sequenceStatus !== 'loaded' || !sequence.id) { return;
return; }
}
const hasUnits = sequence.unitIds?.length > 0; const hasUnits = sequence.unitIds?.length > 0;
if (unitId === 'first') { if (unitId === 'first') {
if (hasUnits) { if (hasUnits) {
const firstUnitId = sequence.unitIds[0]; const firstUnitId = sequence.unitIds[0];
navigate(`/course/${courseId}/${sequence.id}/${firstUnitId}`, { replace: true }); history.replace(`/course/${courseId}/${sequence.id}/${firstUnitId}`);
} else { } else {
// No units... go to general sequence page // No units... go to general sequence page
navigate(`/course/${courseId}/${sequence.id}`, { replace: true }); history.replace(`/course/${courseId}/${sequence.id}`);
}
} else if (unitId === 'last') {
if (hasUnits) {
const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1];
navigate(`/course/${courseId}/${sequence.id}/${lastUnitId}`, { replace: true });
} else {
// No units... go to general sequence page
navigate(`/course/${courseId}/${sequence.id}`, { replace: true });
}
} }
}, } else if (unitId === 'last') {
); if (hasUnits) {
const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1];
history.replace(`/course/${courseId}/${sequence.id}/${lastUnitId}`);
} else {
// No units... go to general sequence page
history.replace(`/course/${courseId}/${sequence.id}`);
}
}
});
class CoursewareContainer extends Component { class CoursewareContainer extends Component {
checkSaveSequencePosition = memoize((unitId) => { checkSaveSequencePosition = memoize((unitId) => {
@@ -147,8 +145,12 @@ class CoursewareContainer extends Component {
componentDidMount() { componentDidMount() {
const { const {
routeCourseId, match: {
routeSequenceId, params: {
courseId: routeCourseId,
sequenceId: routeSequenceId,
},
},
} = this.props; } = this.props;
// Load data whenever the course or sequence ID changes. // Load data whenever the course or sequence ID changes.
this.checkFetchCourse(routeCourseId); this.checkFetchCourse(routeCourseId);
@@ -165,10 +167,13 @@ class CoursewareContainer extends Component {
sequence, sequence,
firstSequenceId, firstSequenceId,
sectionViaSequenceId, sectionViaSequenceId,
routeCourseId, match: {
routeSequenceId, params: {
routeUnitId, courseId: routeCourseId,
navigate, sequenceId: routeSequenceId,
unitId: routeUnitId,
},
},
} = this.props; } = this.props;
// Load data whenever the course or sequence ID changes. // Load data whenever the course or sequence ID changes.
@@ -197,7 +202,7 @@ class CoursewareContainer extends Component {
// Check resume redirect: // Check resume redirect:
// /course/:courseId -> /course/:courseId/:sequenceId/:unitId // /course/:courseId -> /course/:courseId/:sequenceId/:unitId
// based on sequence/unit where user was last active. // based on sequence/unit where user was last active.
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId, navigate); checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId);
// Check section-unit to unit redirect: // Check section-unit to unit redirect:
// /course/:courseId/:sectionId/:unitId -> /course/:courseId/:unitId // /course/:courseId/:sectionId/:unitId -> /course/:courseId/:unitId
@@ -210,54 +215,60 @@ class CoursewareContainer extends Component {
// otherwise, we could get stuck in a redirect loop, since a sequence that failed to load // otherwise, we could get stuck in a redirect loop, since a sequence that failed to load
// would endlessly redirect to itself through `checkSectionUnitToUnitRedirect` // would endlessly redirect to itself through `checkSectionUnitToUnitRedirect`
// and `checkUnitToSequenceUnitRedirect`. // and `checkUnitToSequenceUnitRedirect`.
checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate); checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId);
// Check section to sequence redirect: // Check section to sequence redirect:
// /course/:courseId/:sectionId -> /course/:courseId/:sequenceId // /course/:courseId/:sectionId -> /course/:courseId/:sequenceId
// by redirecting to the first sequence within the section. // by redirecting to the first sequence within the section.
checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate); checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId);
// Check unit to sequence-unit redirect: // Check unit to sequence-unit redirect:
// /course/:courseId/:unitId -> /course/:courseId/:sequenceId/:unitId // /course/:courseId/:unitId -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID of the parent sequence of :unitId. // by filling in the ID of the parent sequence of :unitId.
checkUnitToSequenceUnitRedirect(( checkUnitToSequenceUnitRedirect((
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, sectionViaSequenceId, routeUnitId
sequenceId, sectionViaSequenceId, routeUnitId, navigate
)); ));
// Check sequence to sequence-unit redirect: // Check sequence to sequence-unit redirect:
// /course/:courseId/:sequenceId -> /course/:courseId/:sequenceId/:unitId // /course/:courseId/:sequenceId -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID the most-recently-active unit in the sequence, OR // by filling in the ID the most-recently-active unit in the sequence, OR
// the ID of the first unit the sequence if none is active. // the ID of the first unit the sequence if none is active.
checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate); checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
// Check sequence-unit marker to sequence-unit redirect: // Check sequence-unit marker to sequence-unit redirect:
// /course/:courseId/:sequenceId/first -> /course/:courseId/:sequenceId/:unitId // /course/:courseId/:sequenceId/first -> /course/:courseId/:sequenceId/:unitId
// /course/:courseId/:sequenceId/last -> /course/:courseId/:sequenceId/:unitId // /course/:courseId/:sequenceId/last -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID the first or last unit in the sequence. // by filling in the ID the first or last unit in the sequence.
// "Sequence unit marker" is an invented term used only in this component. // "Sequence unit marker" is an invented term used only in this component.
checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate); checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
} }
handleUnitNavigationClick = () => { handleUnitNavigationClick = (nextUnitId) => {
const { const {
courseId, courseId, sequenceId,
sequenceId, match: {
routeUnitId, params: {
unitId: routeUnitId,
},
},
} = this.props; } = this.props;
this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId); this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId);
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
}; };
handleNextSequenceClick = () => { handleNextSequenceClick = () => {
const { const {
course, course,
courseId,
nextSequence, nextSequence,
sequence, sequence,
sequenceId, sequenceId,
} = this.props; } = this.props;
if (nextSequence !== null) { if (nextSequence !== null) {
history.push(`/course/${courseId}/${nextSequence.id}/first`);
const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection; const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection;
if (celebrateFirstSection && sequence.sectionId !== nextSequence.sectionId) { if (celebrateFirstSection && sequence.sectionId !== nextSequence.sectionId) {
handleNextSectionCelebration(sequenceId, nextSequence.id); handleNextSectionCelebration(sequenceId, nextSequence.id);
@@ -265,14 +276,23 @@ class CoursewareContainer extends Component {
} }
}; };
handlePreviousSequenceClick = () => {}; handlePreviousSequenceClick = () => {
const { previousSequence, courseId } = this.props;
if (previousSequence !== null) {
history.push(`/course/${courseId}/${previousSequence.id}/last`);
}
};
render() { render() {
const { const {
courseStatus, courseStatus,
courseId, courseId,
sequenceId, sequenceId,
routeUnitId, match: {
params: {
unitId: routeUnitId,
},
},
} = this.props; } = this.props;
return ( return (
@@ -315,9 +335,13 @@ const courseShape = PropTypes.shape({
}); });
CoursewareContainer.propTypes = { CoursewareContainer.propTypes = {
routeCourseId: PropTypes.string.isRequired, match: PropTypes.shape({
routeSequenceId: PropTypes.string, params: PropTypes.shape({
routeUnitId: PropTypes.string, courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string,
unitId: PropTypes.string,
}).isRequired,
}).isRequired,
courseId: PropTypes.string, courseId: PropTypes.string,
sequenceId: PropTypes.string, sequenceId: PropTypes.string,
firstSequenceId: PropTypes.string, firstSequenceId: PropTypes.string,
@@ -333,14 +357,11 @@ CoursewareContainer.propTypes = {
checkBlockCompletion: PropTypes.func.isRequired, checkBlockCompletion: PropTypes.func.isRequired,
fetchCourse: PropTypes.func.isRequired, fetchCourse: PropTypes.func.isRequired,
fetchSequence: PropTypes.func.isRequired, fetchSequence: PropTypes.func.isRequired,
navigate: PropTypes.func.isRequired,
}; };
CoursewareContainer.defaultProps = { CoursewareContainer.defaultProps = {
courseId: null, courseId: null,
sequenceId: null, sequenceId: null,
routeSequenceId: null,
routeUnitId: null,
firstSequenceId: null, firstSequenceId: null,
nextSequence: null, nextSequence: null,
previousSequence: null, previousSequence: null,
@@ -455,4 +476,4 @@ export default connect(mapStateToProps, {
saveSequencePosition, saveSequencePosition,
fetchCourse, fetchCourse,
fetchSequence, fetchSequence,
})(withParamsAndNavigation(CoursewareContainer)); })(CoursewareContainer);

View File

@@ -5,16 +5,13 @@ import { waitForElementToBeRemoved, fireEvent } from '@testing-library/dom';
import '@testing-library/jest-dom/extend-expect'; import '@testing-library/jest-dom/extend-expect';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { import { Route, Switch } from 'react-router';
BrowserRouter, MemoryRouter, Route, Routes,
} from 'react-router-dom';
import { Factory } from 'rosie'; import { Factory } from 'rosie';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { UserMessagesProvider } from '../generic/user-messages'; import { UserMessagesProvider } from '../generic/user-messages';
import tabMessages from '../tab-page/messages'; import tabMessages from '../tab-page/messages';
import { initializeMockApp, waitFor } from '../setupTest'; import { initializeMockApp } from '../setupTest';
import { DECODE_ROUTES } from '../constants';
import CoursewareContainer from './CoursewareContainer'; import CoursewareContainer from './CoursewareContainer';
import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory'; import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
@@ -83,16 +80,18 @@ describe('CoursewareContainer', () => {
store = initializeStore(); store = initializeStore();
component = ( component = (
<AppProvider store={store} wrapWithRouter={false}> <AppProvider store={store}>
<UserMessagesProvider> <UserMessagesProvider>
<Routes> <Switch>
{DECODE_ROUTES.COURSEWARE.map((route) => ( <Route
<Route path={[
path={route} '/course/:courseId/:sequenceId/:unitId',
element={<CoursewareContainer />} '/course/:courseId/:sequenceId',
/> '/course/:courseId',
))} ]}
</Routes> component={CoursewareContainer}
/>
</Switch>
</UserMessagesProvider> </UserMessagesProvider>
</AppProvider> </AppProvider>
); );
@@ -152,7 +151,7 @@ describe('CoursewareContainer', () => {
} }
async function loadContainer() { async function loadContainer() {
const { container } = render(<BrowserRouter>{component}</BrowserRouter>); const { container } = render(component);
// Wait for the page spinner to be removed, such that we can wait for our main // Wait for the page spinner to be removed, such that we can wait for our main
// content to load before making any assertions. // content to load before making any assertions.
await waitForElementToBeRemoved(screen.getByRole('status')); await waitForElementToBeRemoved(screen.getByRole('status'));
@@ -161,7 +160,7 @@ describe('CoursewareContainer', () => {
it('should initialize to show a spinner', () => { it('should initialize to show a spinner', () => {
history.push('/course/abc123'); history.push('/course/abc123');
render(<MemoryRouter initialEntries={['/course/abc123']}>{component}</MemoryRouter>); render(component);
const spinner = screen.getByRole('status'); const spinner = screen.getByRole('status');
@@ -186,7 +185,7 @@ describe('CoursewareContainer', () => {
function assertSequenceNavigation(container, expectedUnitCount = 3) { function assertSequenceNavigation(container, expectedUnitCount = 3) {
// Ensure we had appropriate sequence navigation buttons. We should only have one unit. // Ensure we had appropriate sequence navigation buttons. We should only have one unit.
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button'); const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
expect(sequenceNavButtons).toHaveLength(expectedUnitCount + 2); expect(sequenceNavButtons).toHaveLength(expectedUnitCount + 2);
expect(sequenceNavButtons[0]).toHaveTextContent('Previous'); expect(sequenceNavButtons[0]).toHaveTextContent('Previous');
@@ -212,7 +211,7 @@ describe('CoursewareContainer', () => {
}); });
history.push(`/course/${courseId}`); history.push(`/course/${courseId}`);
const container = await waitFor(() => loadContainer()); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
assertSequenceNavigation(container); assertSequenceNavigation(container);
@@ -235,7 +234,7 @@ describe('CoursewareContainer', () => {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {}); axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
history.push(`/course/${courseId}`); history.push(`/course/${courseId}`);
const container = await waitFor(() => loadContainer()); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
assertSequenceNavigation(container); assertSequenceNavigation(container);
@@ -285,7 +284,7 @@ describe('CoursewareContainer', () => {
describe('when the URL does not contain a unit ID', () => { describe('when the URL does not contain a unit ID', () => {
it('should choose a unit within the section\'s first sequence', async () => { it('should choose a unit within the section\'s first sequence', async () => {
setUrl(sectionTree[1].id); setUrl(sectionTree[1].id);
const container = await waitFor(() => loadContainer()); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
assertSequenceNavigation(container, 2); assertSequenceNavigation(container, 2);
assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id); assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id);
@@ -360,7 +359,7 @@ describe('CoursewareContainer', () => {
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => { it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}`); history.push(`/course/${courseId}/${sequenceBlock.id}`);
const container = await waitFor(() => loadContainer()); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
assertSequenceNavigation(container); assertSequenceNavigation(container);
@@ -379,7 +378,7 @@ describe('CoursewareContainer', () => {
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] }); setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
history.push(`/course/${courseId}/${sequenceBlock.id}`); history.push(`/course/${courseId}/${sequenceBlock.id}`);
const container = await waitFor(() => loadContainer()); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
assertSequenceNavigation(container); assertSequenceNavigation(container);
@@ -396,7 +395,7 @@ describe('CoursewareContainer', () => {
it('should load the specified unit', async () => { it('should load the specified unit', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`); history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
const container = await waitFor(() => loadContainer()); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
assertSequenceNavigation(container); assertSequenceNavigation(container);
@@ -412,12 +411,12 @@ describe('CoursewareContainer', () => {
}); });
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`); history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
const container = await waitFor(() => loadContainer()); const container = await loadContainer();
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button'); const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
const sequenceNextButton = sequenceNavButtons[4]; const sequenceNextButton = sequenceNavButtons[4];
expect(sequenceNextButton).toHaveTextContent('Next'); expect(sequenceNextButton).toHaveTextContent('Next');
fireEvent.click(sequenceNextButton); fireEvent.click(sequenceNavButtons[4]);
expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${sequenceBlock.id}/${unitBlocks[1].id}`); expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${sequenceBlock.id}/${unitBlocks[1].id}`);
}); });

View File

@@ -1,44 +1,54 @@
import React from 'react'; import React from 'react';
import { Routes, Route } from 'react-router-dom'; import { Switch, useRouteMatch } from 'react-router';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { PageWrap } from '@edx/frontend-platform/react'; import { PageRoute } from '@edx/frontend-platform/react';
import queryString from 'query-string';
import PageLoading from '../generic/PageLoading'; import PageLoading from '../generic/PageLoading';
import DecodePageRoute from '../decode-page-route'; const CoursewareRedirectLandingPage = () => {
import { DECODE_ROUTES, REDIRECT_MODES, ROUTES } from '../constants'; const { path } = useRouteMatch();
import RedirectPage from './RedirectPage'; return (
<div className="flex-grow-1">
const CoursewareRedirectLandingPage = () => ( <PageLoading srMessage={(
<div className="flex-grow-1"> <FormattedMessage
<PageLoading srMessage={( id="learn.redirect.interstitial.message"
<FormattedMessage description="The screen-reader message when a page is about to redirect"
id="learn.redirect.interstitial.message" defaultMessage="Redirecting..."
description="The screen-reader message when a page is about to redirect" />
defaultMessage="Redirecting..."
/>
)} )}
/> />
<Routes> <Switch>
<Route <PageRoute
path={DECODE_ROUTES.REDIRECT_SURVEY} path={`${path}/survey/:courseId`}
element={<DecodePageRoute><RedirectPage pattern="/courses/:courseId/survey" mode={REDIRECT_MODES.SURVEY_REDIRECT} /></DecodePageRoute>} render={({ match }) => {
/> global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/survey`);
<Route }}
path={ROUTES.DASHBOARD} />
element={<PageWrap><RedirectPage pattern="/dashboard" mode={REDIRECT_MODES.DASHBOARD_REDIRECT} /></PageWrap>} <PageRoute
/> path={`${path}/dashboard`}
<Route render={({ location }) => {
path={ROUTES.CONSENT} global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`);
element={<PageWrap><RedirectPage mode={REDIRECT_MODES.CONSENT_REDIRECT} /></PageWrap>} }}
/> />
<Route <PageRoute
path={DECODE_ROUTES.REDIRECT_HOME} path={`${path}/consent/`}
element={<DecodePageRoute><RedirectPage pattern="/course/:courseId/home" mode={REDIRECT_MODES.HOME_REDIRECT} /></DecodePageRoute>} render={({ location }) => {
/> const { consentPath } = queryString.parse(location.search);
</Routes> global.location.assign(`${getConfig().LMS_BASE_URL}${consentPath}`);
</div> }}
); />
<PageRoute
path={`${path}/home/:courseId`}
render={({ match }) => {
global.location.assign(`/course/${match.params.courseId}/home`);
}}
/>
</Switch>
</div>
);
};
export default CoursewareRedirectLandingPage; export default CoursewareRedirectLandingPage;

View File

@@ -1,12 +1,19 @@
import React from 'react'; import React from 'react';
import { MemoryRouter as Router } from 'react-router-dom'; import { Router } from 'react-router';
import { createMemoryHistory } from 'history';
import { render, initializeMockApp } from '../setupTest'; import { render, initializeMockApp } from '../setupTest';
import CoursewareRedirectLandingPage from './CoursewareRedirectLandingPage'; import CoursewareRedirectLandingPage from './CoursewareRedirectLandingPage';
const redirectUrl = jest.fn(); const redirectUrl = jest.fn();
jest.mock('@edx/frontend-platform/analytics'); jest.mock('@edx/frontend-platform/analytics');
jest.mock('../decode-page-route', () => jest.fn(({ children }) => <div>{children}</div>));
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useRouteMatch: () => ({
path: '/redirect',
}),
}));
describe('CoursewareRedirectLandingPage', () => { describe('CoursewareRedirectLandingPage', () => {
beforeEach(async () => { beforeEach(async () => {
@@ -16,8 +23,12 @@ describe('CoursewareRedirectLandingPage', () => {
}); });
it('Redirects to correct consent URL', () => { it('Redirects to correct consent URL', () => {
const history = createMemoryHistory({
initialEntries: ['/redirect/consent/?consentPath=%2Fgrant_data_sharing_consent'],
});
render( render(
<Router initialEntries={['/consent/?consentPath=%2Fgrant_data_sharing_consent']}> <Router history={history}>
<CoursewareRedirectLandingPage /> <CoursewareRedirectLandingPage />
</Router>, </Router>,
); );
@@ -26,8 +37,12 @@ describe('CoursewareRedirectLandingPage', () => {
}); });
it('Redirects to correct consent URL', () => { it('Redirects to correct consent URL', () => {
const history = createMemoryHistory({
initialEntries: ['/redirect/home/course-v1:edX+DemoX+Demo_Course'],
});
render( render(
<Router initialEntries={['/home/course-v1:edX+DemoX+Demo_Course']}> <Router history={history}>
<CoursewareRedirectLandingPage /> <CoursewareRedirectLandingPage />
</Router>, </Router>,
); );

View File

@@ -1,45 +0,0 @@
import PropTypes from 'prop-types';
import {
generatePath, useParams, useLocation,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import queryString from 'query-string';
import { REDIRECT_MODES } from '../constants';
const RedirectPage = ({
pattern, mode,
}) => {
const { courseId } = useParams();
const location = useLocation();
const { consentPath } = queryString.parse(location?.search);
const BASE_URL = getConfig().LMS_BASE_URL;
switch (mode) {
case REDIRECT_MODES.DASHBOARD_REDIRECT:
global.location.assign(`${BASE_URL}${pattern}${location?.search}`);
break;
case REDIRECT_MODES.CONSENT_REDIRECT:
global.location.assign(`${BASE_URL}${consentPath}`);
break;
case REDIRECT_MODES.HOME_REDIRECT:
global.location.assign(generatePath(pattern, { courseId }));
break;
default:
global.location.assign(`${BASE_URL}${generatePath(pattern, { courseId })}`);
}
return null;
};
RedirectPage.propTypes = {
pattern: PropTypes.string,
mode: PropTypes.string.isRequired,
};
RedirectPage.defaultProps = {
pattern: null,
};
export default RedirectPage;

View File

@@ -1,69 +0,0 @@
import React from 'react';
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import RedirectPage from './RedirectPage';
import { REDIRECT_MODES } from '../constants';
const BASE_URL = getConfig().LMS_BASE_URL;
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
courseId: 'course-id-123',
}),
useLocation: () => ({
search: '?consentPath=/some-path',
}),
}));
describe('RedirectPage component', () => {
beforeEach(() => {
Object.defineProperty(window, 'location', {
writable: true,
value: { assign: jest.fn() },
});
jest.clearAllMocks();
});
it('should handle DASHBOARD_REDIRECT correctly', () => {
render(
<MemoryRouter>
<RedirectPage mode={REDIRECT_MODES.DASHBOARD_REDIRECT} pattern="/dashboard" />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/dashboard?consentPath=/some-path`);
});
it('should handle CONSENT_REDIRECT correctly', () => {
render(
<MemoryRouter>
<RedirectPage mode={REDIRECT_MODES.CONSENT_REDIRECT} />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/some-path`);
});
it('should handle HOME_REDIRECT correctly', () => {
render(
<MemoryRouter>
<RedirectPage mode={REDIRECT_MODES.HOME_REDIRECT} pattern="/course/:courseId/home" />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith('/course/course-id-123/home');
});
it('should handle the default case correctly', () => {
render(
<MemoryRouter>
<RedirectPage pattern="/default/:courseId" />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/default/course-id-123`);
});
});

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
@@ -10,7 +10,6 @@ import { AlertList } from '../../generic/user-messages';
import Sequence from './sequence'; import Sequence from './sequence';
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration'; import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
import Chat from './chat/Chat';
import ContentTools from './content-tools'; import ContentTools from './content-tools';
import CourseBreadcrumbs from './CourseBreadcrumbs'; import CourseBreadcrumbs from './CourseBreadcrumbs';
import SidebarProvider from './sidebar/SidebarContextProvider'; import SidebarProvider from './sidebar/SidebarContextProvider';
@@ -44,8 +43,10 @@ const Course = ({
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here) // Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
const dispatch = useDispatch(); const dispatch = useDispatch();
const celebrateFirstSection = celebrations && celebrations.firstSection;
const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(false); const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(
shouldCelebrateOnSectionLoad(courseId, sequenceId, celebrateFirstSection, dispatch, celebrations),
);
// If streakLengthToCelebrate is populated, that modal takes precedence. Wait til the next load to display // If streakLengthToCelebrate is populated, that modal takes precedence. Wait til the next load to display
// the weekly goal celebration modal. // the weekly goal celebration modal.
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState( const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
@@ -67,17 +68,6 @@ const Course = ({
} }
} }
useEffect(() => {
const celebrateFirstSection = celebrations && celebrations.firstSection;
setFirstSectionCelebrationOpen(shouldCelebrateOnSectionLoad(
courseId,
sequenceId,
celebrateFirstSection,
dispatch,
celebrations,
));
}, [sequenceId]);
return ( return (
<SidebarProvider courseId={courseId} unitId={unitId}> <SidebarProvider courseId={courseId} unitId={unitId}>
<Helmet> <Helmet>
@@ -92,16 +82,7 @@ const Course = ({
unitId={unitId} unitId={unitId}
/> />
{shouldDisplayTriggers && ( {shouldDisplayTriggers && (
<> <SidebarTriggers />
<Chat
enabled={course.learningAssistantEnabled}
enrollmentMode={course.enrollmentMode}
isStaff={isStaff}
courseId={courseId}
contentToolsEnabled={course.showCalculator || course.notes.enabled}
/>
<SidebarTriggers />
</>
)} )}
</div> </div>

View File

@@ -1,24 +1,14 @@
import React from 'react'; import React from 'react';
import { Factory } from 'rosie'; import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import { breakpoints } from '@edx/paragon'; import { breakpoints } from '@edx/paragon';
import { import {
act, fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor, fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
} from '../../setupTest'; } from '../../setupTest';
import { buildTopicsFromUnits } from '../data/__factories__/discussionTopics.factory';
import { handleNextSectionCelebration } from './celebration'; import { handleNextSectionCelebration } from './celebration';
import * as celebrationUtils from './celebration/utils'; import * as celebrationUtils from './celebration/utils';
import Course from './Course'; import Course from './Course';
import { executeThunk } from '../../utils';
import * as thunks from '../data/thunks';
jest.mock('@edx/frontend-platform/analytics'); jest.mock('@edx/frontend-platform/analytics');
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 recordFirstSectionCelebration = jest.fn(); const recordFirstSectionCelebration = jest.fn();
// eslint-disable-next-line no-import-assign // eslint-disable-next-line no-import-assign
@@ -53,27 +43,8 @@ describe('Course', () => {
setItemSpy.mockRestore(); setItemSpy.mockRestore();
}); });
const setupDiscussionSidebar = async () => {
const testStore = await initializeTestStore({ provider: 'openedx' });
const state = testStore.getState();
const { courseware: { courseId } } = state;
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/${courseId}`).reply(200, { provider: 'openedx' });
const topicsResponse = buildTopicsFromUnits(state.models.units);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v2/course_topics/${courseId}`)
.reply(200, topicsResponse);
await executeThunk(thunks.getCourseDiscussionTopics(courseId), testStore.dispatch);
const [firstUnitId] = Object.keys(state.models.units);
mockData.unitId = firstUnitId;
const [firstSequenceId] = Object.keys(state.models.sequences);
mockData.sequenceId = firstSequenceId;
await render(<Course {...mockData} />, { store: testStore, wrapWithRouter: true });
};
it('loads learning sequence', async () => { it('loads learning sequence', async () => {
render(<Course {...mockData} />, { wrapWithRouter: true }); render(<Course {...mockData} />);
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument(); expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument(); expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
@@ -106,7 +77,7 @@ describe('Course', () => {
}; };
// Set up LocalStorage for testing. // Set up LocalStorage for testing.
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId); handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Course {...testData} />, { store: testStore });
const firstSectionCelebrationModal = screen.getByRole('dialog'); const firstSectionCelebrationModal = screen.getByRole('dialog');
expect(firstSectionCelebrationModal).toBeInTheDocument(); expect(firstSectionCelebrationModal).toBeInTheDocument();
@@ -124,7 +95,7 @@ describe('Course', () => {
sequenceId, sequenceId,
unitId: Object.values(models.units)[0].id, unitId: Object.values(models.units)[0].id,
}; };
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Course {...testData} />, { store: testStore });
const weeklyGoalCelebrationModal = screen.getByRole('dialog'); const weeklyGoalCelebrationModal = screen.getByRole('dialog');
expect(weeklyGoalCelebrationModal).toBeInTheDocument(); expect(weeklyGoalCelebrationModal).toBeInTheDocument();
@@ -132,71 +103,29 @@ describe('Course', () => {
}); });
it('displays notification trigger and toggles active class on click', async () => { it('displays notification trigger and toggles active class on click', async () => {
render(<Course {...mockData} />, { wrapWithRouter: true }); render(<Course {...mockData} />);
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i }); const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
expect(notificationTrigger).toBeInTheDocument(); expect(notificationTrigger).toBeInTheDocument();
expect(notificationTrigger.parentNode).toHaveClass('mt-3'); expect(notificationTrigger.parentNode).toHaveClass('border-primary-700');
fireEvent.click(notificationTrigger); fireEvent.click(notificationTrigger);
expect(notificationTrigger.parentNode).not.toHaveClass('mt-3', { exact: true }); expect(notificationTrigger.parentNode).not.toHaveClass('border-primary-700');
});
it('handles click to open/close discussions sidebar', async () => {
await setupDiscussionSidebar();
const discussionsTrigger = await screen.getByRole('button', { name: /Show discussions tray/i });
const discussionsSideBar = await waitFor(() => screen.findByTestId('sidebar-DISCUSSIONS'));
expect(discussionsSideBar).not.toHaveClass('d-none');
await act(async () => {
fireEvent.click(discussionsTrigger);
});
await expect(discussionsSideBar).toHaveClass('d-none');
await act(async () => {
fireEvent.click(discussionsTrigger);
});
await expect(discussionsSideBar).not.toHaveClass('d-none');
});
it('displays discussions sidebar when unit changes', async () => {
const testStore = await initializeTestStore();
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
unitId: Object.values(models.units)[0].id,
};
await setupDiscussionSidebar();
const { rerender } = render(<Course {...testData} />, { store: testStore });
loadUnit();
await waitFor(() => {
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
});
rerender(null);
}); });
it('handles click to open/close notification tray', async () => { it('handles click to open/close notification tray', async () => {
sessionStorage.clear(); sessionStorage.clear();
render(<Course {...mockData} />, { wrapWithRouter: true }); render(<Course {...mockData} />);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"'); expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i }); const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none'); expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument();
fireEvent.click(notificationShowButton); fireEvent.click(notificationShowButton);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"'); expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none'); expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
}); });
it('handles reload persisting notification tray status', async () => { it('handles reload persisting notification tray status', async () => {
sessionStorage.clear(); sessionStorage.clear();
render(<Course {...mockData} />, { wrapWithRouter: true }); render(<Course {...mockData} />);
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i }); const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
fireEvent.click(notificationShowButton); fireEvent.click(notificationShowButton);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"'); expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
@@ -220,7 +149,7 @@ describe('Course', () => {
// set sessionStorage for a different course before rendering Course // set sessionStorage for a different course before rendering Course
sessionStorage.setItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`, '"open"'); sessionStorage.setItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`, '"open"');
render(<Course {...mockData} />, { wrapWithRouter: true }); render(<Course {...mockData} />);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"'); expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i }); const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
fireEvent.click(notificationShowButton); fireEvent.click(notificationShowButton);
@@ -248,7 +177,7 @@ describe('Course', () => {
sequenceId, sequenceId,
unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests. unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests.
}; };
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Course {...testData} />, { store: testStore });
loadUnit(); loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -280,12 +209,12 @@ describe('Course', () => {
previousSequenceHandler, previousSequenceHandler,
unitNavigationHandler, unitNavigationHandler,
}; };
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Course {...testData} />, { store: testStore });
loadUnit(); loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
screen.getAllByRole('link', { name: /previous/i }).forEach(link => fireEvent.click(link)); screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
screen.getAllByRole('link', { name: /next/i }).forEach(link => fireEvent.click(link)); screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
// We are in the middle of the sequence, so no // We are in the middle of the sequence, so no
expect(previousSequenceHandler).not.toHaveBeenCalled(); expect(previousSequenceHandler).not.toHaveBeenCalled();
@@ -309,7 +238,7 @@ describe('Course', () => {
courseId: courseMetadata.id, courseId: courseMetadata.id,
sequenceId: sequenceBlocks[0].id, sequenceId: sequenceBlocks[0].id,
}; };
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Course {...testData} />, { store: testStore });
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
}); });
@@ -343,7 +272,7 @@ describe('Course', () => {
courseId: testCourseMetadata.id, courseId: testCourseMetadata.id,
sequenceId: sequenceBlocks[0].id, sequenceId: sequenceBlocks[0].id,
}; };
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Course {...testData} />, { store: testStore });
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
}); });
@@ -377,7 +306,7 @@ describe('Course', () => {
courseId: testCourseMetadata.id, courseId: testCourseMetadata.id,
sequenceId: sequenceBlocks[0].id, sequenceId: sequenceBlocks[0].id,
}; };
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Course {...testData} />, { store: testStore });
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
}); });
}); });

View File

@@ -1,80 +1,57 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHome } from '@fortawesome/free-solid-svg-icons'; import { faHome } from '@fortawesome/free-solid-svg-icons';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useToggle, ModalPopup, Menu } from '@edx/paragon'; import { SelectMenu } from '@edx/paragon';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useModel, useModels } from '../../generic/model-store'; import { useModel, useModels } from '../../generic/model-store';
import JumpNavMenuItem from './JumpNavMenuItem'; import JumpNavMenuItem from './JumpNavMenuItem';
const CourseBreadcrumb = ({ const CourseBreadcrumb = ({
content, content, withSeparator, courseId, sequenceId, unitId, isStaff,
withSeparator,
courseId,
sequenceId,
unitId,
isStaff,
}) => { }) => {
const defaultContent = content.filter( const defaultContent = content.filter(destination => destination.default)[0] || { id: courseId, label: '', sequences: [] };
(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 ( return (
<> <>
{withSeparator && ( {withSeparator && (
<li className="col-auto p-0 mx-2 text-primary-500 text-truncate text-nowrap" role="presentation" aria-hidden>/</li> <li className="col-auto p-0 mx-2 text-primary-500 text-truncate text-nowrap" role="presentation" aria-hidden>/</li>
)} )}
<li <li style={{
style={{ overflow: 'hidden',
overflow: 'hidden', textOverflow: 'ellipsis',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
whiteSpace: 'nowrap', }}
}}
data-testid="breadcrumb-item"
> >
{showRegularLink ? ( { getConfig().ENABLE_JUMPNAV !== 'true' || content.length < 2 || !isStaff
<Link ? (
className="text-primary-500" <Link
to={ className="text-primary-500"
defaultContent.sequences.length to={defaultContent.sequences.length
? `/course/${courseId}/${defaultContent.sequences[0].id}` ? `/course/${courseId}/${defaultContent.sequences[0].id}`
: `/course/${courseId}/${defaultContent.id}` : `/course/${courseId}/${defaultContent.id}`}
} >
> {defaultContent.label}
{defaultContent.label} </Link>
</Link> )
) : ( : (
<> <SelectMenu isLink defaultMessage={defaultContent.label}>
{ {content.map(item => (
// eslint-disable-next-line <JumpNavMenuItem
<a className="text-primary-500" onClick={open} ref={setTarget}> isDefault={item.default}
{defaultContent.label} sequences={item.sequences}
</a> courseId={courseId}
} title={item.label}
<ModalPopup positionRef={target} isOpen={isOpen} onClose={close}> currentSequence={sequenceId}
<Menu> currentUnit={unitId}
{content.map((item) => ( />
<JumpNavMenuItem ))}
isDefault={item.default} </SelectMenu>
sequences={item.sequences} )}
courseId={courseId}
title={item.label}
currentSequence={sequenceId}
currentUnit={unitId}
onClick={close}
/>
))}
</Menu>
</ModalPopup>
</>
)}
</li> </li>
</> </>
); );
@@ -110,21 +87,14 @@ const CourseBreadcrumbs = ({
isStaff, isStaff,
}) => { }) => {
const course = useModel('coursewareMeta', courseId); const course = useModel('coursewareMeta', courseId);
const courseStatus = useSelector((state) => state.courseware.courseStatus); const courseStatus = useSelector(state => state.courseware.courseStatus);
const sequenceStatus = useSelector( const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
(state) => state.courseware.sequenceStatus,
);
const allSequencesInSections = Object.fromEntries( const allSequencesInSections = Object.fromEntries(useModels('sections', course.sectionIds).map(section => [section.id, {
useModels('sections', course.sectionIds).map((section) => [ default: section.id === sectionId,
section.id, title: section.title,
{ sequences: useModels('sequences', section.sequenceIds),
default: section.id === sectionId, }]));
title: section.title,
sequences: useModels('sequences', section.sequenceIds),
},
]),
);
const links = useMemo(() => { const links = useMemo(() => {
const chapters = []; const chapters = [];
@@ -138,7 +108,7 @@ const CourseBreadcrumbs = ({
sequences: section.sequences, sequences: section.sequences,
}); });
if (section.default) { if (section.default) {
section.sequences.forEach((sequence) => { section.sequences.forEach(sequence => {
sequentials.push({ sequentials.push({
id: sequence.id, id: sequence.id,
label: sequence.title, label: sequence.title,
@@ -154,12 +124,11 @@ const CourseBreadcrumbs = ({
return ( return (
<nav aria-label="breadcrumb" className="my-4 d-inline-block col-sm-10"> <nav aria-label="breadcrumb" className="my-4 d-inline-block col-sm-10">
<ol className="list-unstyled d-flex flex-nowrap align-items-center m-0"> <ol className="list-unstyled d-flex flex-nowrap align-items-center m-0">
<li className="list-unstyled col-auto m-0 p-0"> <li className="list-unstyled col-auto m-0 p-0">
<Link <Link
className="flex-shrink-0 text-primary" className="flex-shrink-0 text-primary"
to={`/course/${courseId}/home`} to={`/course/${courseId}/home`}
replace
> >
<FontAwesomeIcon icon={faHome} className="mr-2" /> <FontAwesomeIcon icon={faHome} className="mr-2" />
<FormattedMessage <FormattedMessage
@@ -169,7 +138,7 @@ const CourseBreadcrumbs = ({
/> />
</Link> </Link>
</li> </li>
{links.map((content) => ( {links.map(content => (
<CourseBreadcrumb <CourseBreadcrumb
courseId={courseId} courseId={courseId}
sequenceId={sequenceId} sequenceId={sequenceId}

View File

@@ -26,12 +26,6 @@ jest.mock('react-redux', () => ({
Provider: ({ children }) => children, Provider: ({ children }) => children,
useSelector: () => 'loaded', 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) => { useModels.mockImplementation((name) => {
if (name === 'sections') { if (name === 'sections') {
@@ -129,6 +123,6 @@ describe('CourseBreadcrumbs', () => {
const courseHomeButtonDestination = screen.getAllByRole('link')[0].href; const courseHomeButtonDestination = screen.getAllByRole('link')[0].href;
expect(courseHomeButtonDestination).toBe('http://localhost/course/course-v1:edX+DemoX+Demo_Course/home'); expect(courseHomeButtonDestination).toBe('http://localhost/course/course-v1:edX+DemoX+Demo_Course/home');
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument(); expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(screen.queryAllByTestId('breadcrumb-item')).toHaveLength(2); expect(screen.queryAllByRole('button')).toHaveLength(2);
}); });
}); });

View File

@@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Dropdown } from '@edx/paragon'; import { history } from '@edx/frontend-platform';
import { MenuItem } from '@edx/paragon';
import { import {
sendTrackingLogEvent, sendTrackingLogEvent,
sendTrackEvent, sendTrackEvent,
} from '@edx/frontend-platform/analytics'; } from '@edx/frontend-platform/analytics';
import { useNavigate } from 'react-router-dom';
const JumpNavMenuItem = ({ const JumpNavMenuItem = ({
title, title,
@@ -15,10 +15,7 @@ const JumpNavMenuItem = ({
currentUnit, currentUnit,
sequences, sequences,
isDefault, isDefault,
onClick,
}) => { }) => {
const navigate = useNavigate();
function logEvent(targetUrl) { function logEvent(targetUrl) {
const eventName = 'edx.ui.lms.jump_nav.selected'; const eventName = 'edx.ui.lms.jump_nav.selected';
const payload = { const payload = {
@@ -37,20 +34,19 @@ const JumpNavMenuItem = ({
} }
return `/course/${courseId}/${sequences[0].id}`; return `/course/${courseId}/${sequences[0].id}`;
} }
function handleClick(e) { function handleClick() {
const url = destinationUrl(); const url = destinationUrl();
logEvent(url); logEvent(url);
navigate(url); history.push(url);
if (onClick) { onClick(e); }
} }
return ( return (
<Dropdown.Item <MenuItem
active={isDefault} defaultSelected={isDefault}
onClick={e => handleClick(e)} onClick={() => handleClick()}
> >
{title} {title}
</Dropdown.Item> </MenuItem>
); );
}; };
@@ -58,10 +54,6 @@ const sequenceShape = PropTypes.shape({
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
}); });
JumpNavMenuItem.defaultProps = {
onClick: null,
};
JumpNavMenuItem.propTypes = { JumpNavMenuItem.propTypes = {
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
sequences: PropTypes.arrayOf(sequenceShape).isRequired, sequences: PropTypes.arrayOf(sequenceShape).isRequired,
@@ -69,7 +61,6 @@ JumpNavMenuItem.propTypes = {
courseId: PropTypes.string.isRequired, courseId: PropTypes.string.isRequired,
currentSequence: PropTypes.string.isRequired, currentSequence: PropTypes.string.isRequired,
currentUnit: PropTypes.string.isRequired, currentUnit: PropTypes.string.isRequired,
onClick: PropTypes.func,
}; };
export default JumpNavMenuItem; export default JumpNavMenuItem;

View File

@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { screen, render } from '@testing-library/react'; import { screen, render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import JumpNavMenuItem from './JumpNavMenuItem'; import JumpNavMenuItem from './JumpNavMenuItem';
import { fireEvent } from '../../setupTest'; import { fireEvent } from '../../setupTest';
@@ -23,15 +22,12 @@ const mockData = {
}, },
], ],
isDefault: false, isDefault: false,
onClick: jest.fn().mockName('onClick'),
}; };
describe('JumpNavMenuItem', () => { describe('JumpNavMenuItem', () => {
render( render(
<BrowserRouter> <JumpNavMenuItem
<JumpNavMenuItem {...mockData}
{...mockData} />,
/>
</BrowserRouter>,
); );
it('renders menu Item as expected with button and Text and handles clicks', () => { it('renders menu Item as expected with button and Text and handles clicks', () => {
expect(screen.queryAllByRole('button')).toHaveLength(1); expect(screen.queryAllByRole('button')).toHaveLength(1);

View File

@@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import { Factory } from 'rosie'; import { Factory } from 'rosie';
import { import {
render, screen, fireEvent, initializeTestStore, waitFor, authenticatedUser, logUnhandledRequests, render, screen, fireEvent, initializeTestStore, waitFor, authenticatedUser, logUnhandledRequests,
} from '../../../setupTest'; } from '../../../setupTest';
import { BookmarkButton } from './index'; import { BookmarkButton } from './index';
import { getBookmarksBaseUrl } from './data/api';
describe('Bookmark Button', () => { describe('Bookmark Button', () => {
let axiosMock; let axiosMock;
@@ -32,8 +32,7 @@ describe('Bookmark Button', () => {
mockData.unitId = nonBookmarkedUnitBlock.id; mockData.unitId = nonBookmarkedUnitBlock.id;
axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const bookmarkUrl = getBookmarksBaseUrl(); const bookmarkUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
axiosMock.onPost(bookmarkUrl).reply(200, { }); axiosMock.onPost(bookmarkUrl).reply(200, { });
const bookmarkDeleteUrlRegExp = new RegExp(`${bookmarkUrl}*,*`); const bookmarkDeleteUrlRegExp = new RegExp(`${bookmarkUrl}*,*`);

View File

@@ -1,13 +1,13 @@
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
export const getBookmarksBaseUrl = () => `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`; const bookmarksBaseUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
export async function createBookmark(usageId) { export async function createBookmark(usageId) {
return getAuthenticatedHttpClient().post(getBookmarksBaseUrl(), { usage_id: usageId }); return getAuthenticatedHttpClient().post(bookmarksBaseUrl, { usage_id: usageId });
} }
export async function deleteBookmark(usageId) { export async function deleteBookmark(usageId) {
const { username } = getAuthenticatedUser(); const { username } = getAuthenticatedUser();
return getAuthenticatedHttpClient().delete(`${getBookmarksBaseUrl()}${username},${usageId}/`); return getAuthenticatedHttpClient().delete(`${bookmarksBaseUrl}${username},${usageId}/`);
} }

View File

@@ -9,7 +9,6 @@ import {
useWindowSize, useWindowSize,
} from '@edx/paragon'; } from '@edx/paragon';
import { useDispatch } from 'react-redux';
import ClapsMobile from './assets/claps_280x201.gif'; import ClapsMobile from './assets/claps_280x201.gif';
import ClapsTablet from './assets/claps_456x328.gif'; import ClapsTablet from './assets/claps_456x328.gif';
import messages from './messages'; import messages from './messages';
@@ -20,13 +19,12 @@ import { useModel } from '../../../generic/model-store';
const CelebrationModal = ({ const CelebrationModal = ({
courseId, intl, isOpen, onClose, ...rest courseId, intl, isOpen, onClose, ...rest
}) => { }) => {
const { org, celebrations } = useModel('courseHomeMeta', courseId); const { org } = useModel('courseHomeMeta', courseId);
const dispatch = useDispatch();
const wideScreen = useWindowSize().width >= breakpoints.small.minWidth; const wideScreen = useWindowSize().width >= breakpoints.small.minWidth;
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
recordFirstSectionCelebration(org, courseId, celebrations, dispatch); recordFirstSectionCelebration(org, courseId);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]); }, [isOpen]);

View File

@@ -15,20 +15,9 @@ function handleNextSectionCelebration(sequenceId, nextSequenceId) {
}); });
} }
function recordFirstSectionCelebration(org, courseId, celebrations, dispatch) { function recordFirstSectionCelebration(org, courseId) {
// Tell the LMS // Tell the LMS
postCelebrationComplete(courseId, { first_section: false }); postCelebrationComplete(courseId, { first_section: false });
// Update our local copy of course data from LMS
dispatch(updateModel({
modelType: 'courseHomeMeta',
model: {
id: courseId,
celebrations: {
...celebrations,
firstSection: false,
},
},
}));
// Tell our analytics // Tell our analytics
const { administrator } = getAuthenticatedUser(); const { administrator } = getAuthenticatedUser();

View File

@@ -1,15 +0,0 @@
import { recordFirstSectionCelebration } from './utils';
jest.mock('@edx/frontend-platform/analytics');
jest.mock('./data/api');
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(() => ({ administrator: 'admin' })),
}));
describe('recordFirstSectionCelebration', () => {
it('updates the local copy of the course data from the LMS', async () => {
const dispatchMock = jest.fn();
recordFirstSectionCelebration('org', 'courseId', 'celebration', dispatchMock);
expect(dispatchMock).toHaveBeenCalled();
});
});

View File

@@ -1,76 +0,0 @@
import { createPortal } from 'react-dom';
import PropTypes from 'prop-types';
import { Xpert } from '@edx/frontend-lib-learning-assistant';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
const Chat = ({
enabled,
enrollmentMode,
isStaff,
courseId,
contentToolsEnabled,
}) => {
const VERIFIED_MODES = [
'professional',
'verified',
'no-id-professional',
'credit',
'masters',
'executive-education',
'paid-executive-education',
'paid-bootcamp',
];
const AUDIT_MODES = [
'audit',
'honor',
'unpaid-executive-education',
'unpaid-bootcamp',
];
const isEnrolled = (
enrollmentMode !== null
&& enrollmentMode !== undefined
&& [...VERIFIED_MODES, ...AUDIT_MODES].some(mode => mode === enrollmentMode)
);
const shouldDisplayChat = (
enabled
&& (isEnrolled || isStaff) // display only to enrolled or staff
);
// TODO: Remove this Segment alert. This has been added purely to diagnose whether
// usage issues are as a result of the Xpert toggle button not appearing.
if (shouldDisplayChat) {
sendTrackEvent('edx.ui.lms.learning_assistant.render', {
course_id: courseId,
});
}
return (
<>
{/* Use a portal to ensure that component overlay does not compete with learning MFE styles. */}
{shouldDisplayChat && (createPortal(
<Xpert courseId={courseId} contentToolsEnabled={contentToolsEnabled} />,
document.body,
))}
</>
);
};
Chat.propTypes = {
isStaff: PropTypes.bool.isRequired,
enabled: PropTypes.bool.isRequired,
enrollmentMode: PropTypes.string,
courseId: PropTypes.string.isRequired,
contentToolsEnabled: PropTypes.bool.isRequired,
};
Chat.defaultProps = {
enrollmentMode: null,
};
export default injectIntl(Chat);

View File

@@ -1,157 +0,0 @@
import { BrowserRouter } from 'react-router-dom';
import { configureStore } from '@reduxjs/toolkit';
import React from 'react';
import { reducer as learningAssistantReducer } from '@edx/frontend-lib-learning-assistant';
import { initializeMockApp, render, screen } from '../../../setupTest';
import Chat from './Chat';
jest.mock('@edx/frontend-platform/analytics');
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', 'audit', 'honor', 'unpaid-executive-education', 'unpaid-bootcamp',
];
const disabledModes = [null, undefined, 'xyz'];
describe('Chat', () => {
// 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 () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff={false}
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId('toggle-button');
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 () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId('toggle-button');
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 () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff={test.isStaff}
enabled={test.enabled}
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId('toggle-button');
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
},
);
});
});

View File

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

View File

@@ -1,32 +1,23 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import Calculator from './calculator'; import Calculator from './calculator';
import NotesVisibility from './notes-visibility'; import NotesVisibility from './notes-visibility';
const ContentTools = ({ const ContentTools = ({
course, course,
}) => { }) => (
const { <div className="content-tools">
sidebarIsOpen, <div className="d-flex justify-content-end align-items-end m-0">
} = useSelector(state => state.learningAssistant); {course.showCalculator && (
<Calculator />
return ( )}
!sidebarIsOpen && ( {course.notes.enabled && (
<div className="content-tools"> <NotesVisibility course={course} />
<div className="d-flex justify-content-end align-items-end m-0"> )}
{course.showCalculator && ( </div>
<Calculator /> </div>
)} );
{course.notes.enabled && (
<NotesVisibility course={course} />
)}
</div>
</div>
)
);
};
ContentTools.propTypes = { ContentTools.propTypes = {
course: PropTypes.shape({ course: PropTypes.shape({

View File

@@ -1,5 +1,6 @@
.content-tools { .content-tools {
position: fixed; position: fixed;
left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
z-index: 100; z-index: 100;

View File

@@ -25,7 +25,7 @@ class NotesVisibility extends Component {
} }
handleClick = () => { handleClick = () => {
const data = { visibility: !this.state.visible }; const data = { visibility: this.state.visible };
getAuthenticatedHttpClient().put( getAuthenticatedHttpClient().put(
this.visibilityUrl, this.visibilityUrl,
data, data,

View File

@@ -85,7 +85,7 @@ describe('Notes Visibility', () => {
expect(axiosMock.history.put).toHaveLength(1); expect(axiosMock.history.put).toHaveLength(1);
expect(axiosMock.history.put[0].url).toEqual(visibilityUrl); expect(axiosMock.history.put[0].url).toEqual(visibilityUrl);
expect(axiosMock.history.put[0].data).toEqual(`{"visibility":${!mockData.course.notes.visible}}`); expect(axiosMock.history.put[0].data).toEqual(`{"visibility":${mockData.course.notes.visible}}`);
expect(screen.getByRole('switch', { name: 'Hide Notes' })).toBeInTheDocument(); expect(screen.getByRole('switch', { name: 'Hide Notes' })).toBeInTheDocument();
}); });

View File

@@ -4,7 +4,7 @@ import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon'; import { Button } from '@edx/paragon';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Navigate } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import CourseCelebration from './CourseCelebration'; import CourseCelebration from './CourseCelebration';
import CourseInProgress from './CourseInProgress'; import CourseInProgress from './CourseInProgress';
@@ -58,7 +58,7 @@ const CourseExit = ({ intl }) => {
} else if (mode === COURSE_EXIT_MODES.celebration) { } else if (mode === COURSE_EXIT_MODES.celebration) {
body = (<CourseCelebration />); body = (<CourseCelebration />);
} else { } else {
return (<Navigate to={`/course/${courseId}`} replace />); return (<Redirect to={`/course/${courseId}`} />);
} }
return ( return (

View File

@@ -51,7 +51,7 @@ describe('Course Exit Pages', () => {
async function fetchAndRender(component) { async function fetchAndRender(component) {
await executeThunk(fetchCourse(courseId), store.dispatch); await executeThunk(fetchCourse(courseId), store.dispatch);
render(component, { store, wrapWithRouter: true }); render(component, { store });
} }
beforeEach(() => { beforeEach(() => {

View File

@@ -10,6 +10,7 @@ import {
} from '@edx/frontend-platform/analytics'; } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { history } from '@edx/frontend-platform';
import SequenceExamWrapper from '@edx/frontend-lib-special-exams'; import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
import { breakpoints, useWindowSize } from '@edx/paragon'; import { breakpoints, useWindowSize } from '@edx/paragon';
@@ -138,30 +139,32 @@ const Sequence = ({
} }
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated; const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
const goToCourseExitPage = () => {
history.push(`/course/${courseId}/course-end`);
};
const defaultContent = ( const defaultContent = (
<div className="sequence-container d-inline-flex flex-row w-100"> <div className="sequence-container d-inline-flex flex-row">
<div className={classNames('sequence w-100', { 'position-relative': shouldDisplayNotificationTriggerInSequence })}> <div className={classNames('sequence w-100', { 'position-relative': shouldDisplayNotificationTriggerInSequence })}>
<div className="sequence-navigation-container"> <SequenceNavigation
<SequenceNavigation sequenceId={sequenceId}
sequenceId={sequenceId} unitId={unitId}
unitId={unitId} className="mb-4"
className="mb-4" nextSequenceHandler={() => {
nextHandler={() => { logEvent('edx.ui.lms.sequence.next_selected', 'top');
logEvent('edx.ui.lms.sequence.next_selected', 'top'); handleNext();
handleNext(); }}
}} onNavigate={(destinationUnitId) => {
onNavigate={(destinationUnitId) => { logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId); handleNavigate(destinationUnitId);
handleNavigate(destinationUnitId); }}
}} previousSequenceHandler={() => {
previousHandler={() => { logEvent('edx.ui.lms.sequence.previous_selected', 'top');
logEvent('edx.ui.lms.sequence.previous_selected', 'top'); handlePrevious();
handlePrevious(); }}
}} goToCourseExitPage={() => goToCourseExitPage()}
/> />
{shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />} {shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />}
</div>
<div className="unit-container flex-grow-1"> <div className="unit-container flex-grow-1">
<SequenceContent <SequenceContent
@@ -183,6 +186,7 @@ const Sequence = ({
logEvent('edx.ui.lms.sequence.next_selected', 'bottom'); logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
handleNext(); handleNext();
}} }}
goToCourseExitPage={() => goToCourseExitPage()}
/> />
)} )}
</div> </div>

View File

@@ -11,10 +11,6 @@ import Sequence from './Sequence';
import { fetchSequenceFailure } from '../../data/slice'; import { fetchSequenceFailure } from '../../data/slice';
jest.mock('@edx/frontend-platform/analytics'); jest.mock('@edx/frontend-platform/analytics');
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(),
}));
describe('Sequence', () => { describe('Sequence', () => {
let mockData; let mockData;
@@ -46,14 +42,10 @@ describe('Sequence', () => {
it('renders correctly without data', async () => { it('renders correctly without data', async () => {
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false); const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
render( render(<Sequence {...mockData} {...{ unitId: undefined, sequenceId: undefined }} />, { store: testStore });
<Sequence {...mockData} {...{ unitId: undefined, sequenceId: undefined }} />,
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByText('There is no content here.')).toBeInTheDocument(); expect(screen.getByText('There is no content here.')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument(); expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
}); });
it('renders correctly for gated content', async () => { it('renders correctly for gated content', async () => {
@@ -78,14 +70,12 @@ describe('Sequence', () => {
}, false); }, false);
const { container } = render( const { container } = render(
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />, <Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
{ store: testStore, wrapWithRouter: true }, { store: testStore },
); );
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
// `Previous`, `Prerequisite` and `Close Tray` buttons. // `Previous`, `Active`, `Next` and `Prerequisite` buttons.
expect(screen.getAllByRole('button').length).toEqual(3); expect(screen.getAllByRole('button').length).toEqual(4);
// `Active` and `Next` buttons.
expect(screen.getAllByRole('link').length).toEqual(2);
expect(screen.getByText('Content Locked')).toBeInTheDocument(); expect(screen.getByText('Content Locked')).toBeInTheDocument();
const unitContainer = container.querySelector('.unit-container'); const unitContainer = container.querySelector('.unit-container');
@@ -111,7 +101,7 @@ describe('Sequence', () => {
}, false); }, false);
render( render(
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />, <Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
{ store: testStore, wrapWithRouter: true }, { store: testStore },
); );
await waitFor(() => { await waitFor(() => {
@@ -122,30 +112,26 @@ describe('Sequence', () => {
// No normal content or navigation should be rendered. Just the above alert. // No normal content or navigation should be rendered. Just the above alert.
expect(screen.queryAllByRole('button').length).toEqual(0); expect(screen.queryAllByRole('button').length).toEqual(0);
expect(screen.queryAllByRole('link').length).toEqual(1);
}); });
it('displays error message on sequence load failure', async () => { it('displays error message on sequence load failure', async () => {
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false); const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
testStore.dispatch(fetchSequenceFailure({ sequenceId: mockData.sequenceId })); testStore.dispatch(fetchSequenceFailure({ sequenceId: mockData.sequenceId }));
render(<Sequence {...mockData} />, { store: testStore, wrapWithRouter: true }); render(<Sequence {...mockData} />, { store: testStore });
expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument(); expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument();
}); });
it('handles loading unit', async () => { it('handles loading unit', async () => {
render(<Sequence {...mockData} />, { wrapWithRouter: true }); render(<Sequence {...mockData} />);
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument(); expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
// `Previous`, `Bookmark` and `Close Tray` buttons // Renders navigation buttons plus one button for each unit.
expect(screen.getAllByRole('button')).toHaveLength(3); expect(screen.getAllByRole('button')).toHaveLength(3 + unitBlocks.length);
// Renders `Next` button plus one button for each unit.
expect(screen.getAllByRole('link')).toHaveLength(1 + unitBlocks.length);
loadUnit(); loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
// At this point there will be 2 `Previous` and 2 `Next` buttons. // At this point there will be 2 `Previous` and 2 `Next` buttons.
expect(screen.getAllByRole('button', { name: /previous/i }).length).toEqual(2); expect(screen.getAllByRole('button', { name: /previous|next/i }).length).toEqual(4);
expect(screen.getAllByRole('link', { name: /next/i }).length).toEqual(2);
}); });
describe('sequence and unit navigation buttons', () => { describe('sequence and unit navigation buttons', () => {
@@ -174,10 +160,10 @@ describe('Sequence', () => {
sequenceId: sequenceBlocks[1].id, sequenceId: sequenceBlocks[1].id,
previousSequenceHandler: jest.fn(), previousSequenceHandler: jest.fn(),
}; };
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Sequence {...testData} />, { store: testStore });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument(); expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
const sequencePreviousButton = screen.getByRole('link', { name: /previous/i }); const sequencePreviousButton = screen.getByRole('button', { name: /previous/i });
fireEvent.click(sequencePreviousButton); fireEvent.click(sequencePreviousButton);
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(1); expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledTimes(1); expect(sendTrackEvent).toHaveBeenCalledTimes(1);
@@ -190,7 +176,7 @@ describe('Sequence', () => {
loadUnit(); loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
const unitPreviousButton = screen.getAllByRole('link', { name: /previous/i }) const unitPreviousButton = screen.getAllByRole('button', { name: /previous/i })
.filter(button => button !== sequencePreviousButton)[0]; .filter(button => button !== sequencePreviousButton)[0];
fireEvent.click(unitPreviousButton); fireEvent.click(unitPreviousButton);
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2); expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
@@ -210,10 +196,10 @@ describe('Sequence', () => {
sequenceId: sequenceBlocks[0].id, sequenceId: sequenceBlocks[0].id,
nextSequenceHandler: jest.fn(), nextSequenceHandler: jest.fn(),
}; };
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Sequence {...testData} />, { store: testStore });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument(); expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
const sequenceNextButton = screen.getByRole('link', { name: /next/i }); const sequenceNextButton = screen.getByRole('button', { name: /next/i });
fireEvent.click(sequenceNextButton); fireEvent.click(sequenceNextButton);
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(1); expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.next_selected', { expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.next_selected', {
@@ -225,7 +211,7 @@ describe('Sequence', () => {
loadUnit(); loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
const unitNextButton = screen.getAllByRole('link', { name: /next/i }) const unitNextButton = screen.getAllByRole('button', { name: /next/i })
.filter(button => button !== sequenceNextButton)[0]; .filter(button => button !== sequenceNextButton)[0];
fireEvent.click(unitNextButton); fireEvent.click(unitNextButton);
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2); expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
@@ -248,14 +234,14 @@ describe('Sequence', () => {
previousSequenceHandler: jest.fn(), previousSequenceHandler: jest.fn(),
nextSequenceHandler: jest.fn(), nextSequenceHandler: jest.fn(),
}; };
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Sequence {...testData} />, { store: testStore });
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
fireEvent.click(screen.getByRole('link', { name: /previous/i })); fireEvent.click(screen.getByRole('button', { name: /previous/i }));
expect(testData.previousSequenceHandler).not.toHaveBeenCalled(); expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(unitBlocks[unitNumber - 1].id); expect(testData.unitNavigationHandler).toHaveBeenCalledWith(unitBlocks[unitNumber - 1].id);
fireEvent.click(screen.getByRole('link', { name: /next/i })); fireEvent.click(screen.getByRole('button', { name: /next/i }));
expect(testData.nextSequenceHandler).not.toHaveBeenCalled(); expect(testData.nextSequenceHandler).not.toHaveBeenCalled();
// As `previousSequenceHandler` and `nextSequenceHandler` are mocked, we aren't really changing the position here. // As `previousSequenceHandler` and `nextSequenceHandler` are mocked, we aren't really changing the position here.
// Therefore the next unit will still be `the initial one + 1`. // Therefore the next unit will still be `the initial one + 1`.
@@ -272,7 +258,7 @@ describe('Sequence', () => {
unitNavigationHandler: jest.fn(), unitNavigationHandler: jest.fn(),
previousSequenceHandler: jest.fn(), previousSequenceHandler: jest.fn(),
}; };
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Sequence {...testData} />, { store: testStore });
loadUnit(); loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -291,7 +277,7 @@ describe('Sequence', () => {
unitNavigationHandler: jest.fn(), unitNavigationHandler: jest.fn(),
nextSequenceHandler: jest.fn(), nextSequenceHandler: jest.fn(),
}; };
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Sequence {...testData} />, { store: testStore });
loadUnit(); loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -333,15 +319,15 @@ describe('Sequence', () => {
nextSequenceHandler: jest.fn(), nextSequenceHandler: jest.fn(),
}; };
render(<Sequence {...testData} />, { store: innerTestStore, wrapWithRouter: true }); render(<Sequence {...testData} />, { store: innerTestStore });
loadUnit(); loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
screen.getAllByRole('link', { name: /previous/i }).forEach(button => fireEvent.click(button)); screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2); expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
expect(testData.unitNavigationHandler).not.toHaveBeenCalled(); expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
screen.getAllByRole('link', { name: /next/i }).forEach(button => fireEvent.click(button)); screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2); expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
expect(testData.unitNavigationHandler).not.toHaveBeenCalled(); expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
@@ -381,10 +367,10 @@ describe('Sequence', () => {
sequenceId: sequenceBlocks[0].id, sequenceId: sequenceBlocks[0].id,
unitNavigationHandler: jest.fn(), unitNavigationHandler: jest.fn(),
}; };
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Sequence {...testData} />, { store: testStore });
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
fireEvent.click(screen.getByRole('link', { name: targetUnit.display_name })); fireEvent.click(screen.getByRole('button', { name: targetUnit.display_name }));
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(targetUnit.id); expect(testData.unitNavigationHandler).toHaveBeenCalledWith(targetUnit.id);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', { expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', {
current_tab: currentTabNumber, current_tab: currentTabNumber,
@@ -408,13 +394,13 @@ describe('Sequence', () => {
describe('notification feature', () => { describe('notification feature', () => {
it('renders notification tray in sequence', async () => { it('renders notification tray in sequence', async () => {
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />, { wrapWithRouter: true }); render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />);
expect(await screen.findByText('Notifications')).toBeInTheDocument(); expect(await screen.findByText('Notifications')).toBeInTheDocument();
}); });
it('handles click on notification tray close button', async () => { it('handles click on notification tray close button', async () => {
const toggleNotificationTray = jest.fn(); const toggleNotificationTray = jest.fn();
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />, { wrapWithRouter: true }); render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />);
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i }); const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
fireEvent.click(notificationCloseIconButton); fireEvent.click(notificationCloseIconButton);
expect(toggleNotificationTray).toHaveBeenCalledTimes(1); expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
@@ -422,7 +408,7 @@ describe('Sequence', () => {
it('does not render notification tray in sequence by default if in responsive view', async () => { it('does not render notification tray in sequence by default if in responsive view', async () => {
global.innerWidth = breakpoints.medium.maxWidth; global.innerWidth = breakpoints.medium.maxWidth;
const { container } = render(<Sequence {...mockData} />, { wrapWithRouter: true }); const { container } = render(<Sequence {...mockData} />);
// unable to test the absence of 'Notifications' by finding it by text, using the class of the tray instead: // unable to test the absence of 'Notifications' by finding it by text, using the class of the tray instead:
expect(container).not.toHaveClass('notification-tray-container'); expect(container).not.toHaveClass('notification-tray-container');
}); });

View File

@@ -19,13 +19,13 @@ describe('Sequence Content', () => {
}); });
it('displays loading message', () => { it('displays loading message', () => {
render(<SequenceContent {...mockData} />, { wrapWithRouter: true }); render(<SequenceContent {...mockData} />);
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument(); expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
}); });
it('displays messages for the locked content', async () => { it('displays messages for the locked content', async () => {
const { gatedContent } = store.getState().models.sequences[mockData.sequenceId]; const { gatedContent } = store.getState().models.sequences[mockData.sequenceId];
const { container } = render(<SequenceContent {...mockData} gated />, { wrapWithRouter: true }); const { container } = render(<SequenceContent {...mockData} gated />);
expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument(); expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument();
expect(await screen.findByText('Content Locked')).toBeInTheDocument(); expect(await screen.findByText('Content Locked')).toBeInTheDocument();
@@ -38,7 +38,7 @@ describe('Sequence Content', () => {
}); });
it('displays message for no content', () => { it('displays message for no content', () => {
render(<SequenceContent {...mockData} unitId={null} />, { wrapWithRouter: true }); render(<SequenceContent {...mockData} unitId={null} />);
expect(screen.getByText('There is no content here.')).toBeInTheDocument(); expect(screen.getByText('There is no content here.')).toBeInTheDocument();
}); });
}); });

View File

@@ -0,0 +1,257 @@
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext, ErrorPage } from '@edx/frontend-platform/react';
import { Modal } from '@edx/paragon';
import PropTypes from 'prop-types';
import React, {
Suspense, useCallback, useContext, useEffect, useLayoutEffect, useState,
} from 'react';
import { useDispatch } from 'react-redux';
import { processEvent } from '../../../course-home/data/thunks';
import { useEventListener } from '../../../generic/hooks';
import { useModel } from '../../../generic/model-store';
import PageLoading from '../../../generic/PageLoading';
import { fetchCourse } from '../../data';
import BookmarkButton from '../bookmark/BookmarkButton';
import ShareButton from '../share/ShareButton';
import messages from './messages';
const HonorCode = React.lazy(() => import('./honor-code'));
const LockPaywall = React.lazy(() => import('./lock-paywall'));
/**
* Feature policy for iframe, allowing access to certain courseware-related media.
*
* We must use the wildcard (*) origin for each feature, as courseware content
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
* block that iframes external course content.
* This policy was selected in conference with the edX Security Working Group.
* Changes to it should be vetted by them (security@edx.org).
*/
const IFRAME_FEATURE_POLICY = (
'microphone *; camera *; midi *; geolocation *; encrypted-media *'
);
/**
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
* useEffect hooks until the user interacts with the page again. This is particularly confusing
* when navigating between sequences, as the UI partially updates leaving the user in a nebulous
* state.
*
* We were able to solve this error by using a layout effect to update some component state, which
* executes synchronously on render. Somehow this forces React to continue it's lifecycle
* immediately, rather than waiting for user interaction. This layout effect could be anywhere in
* the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's
* a joke) one here so it wouldn't be accidentally removed elsewhere.
*
* If we remove this hook when one of these happens:
* 1. React figures out that there's an issue here and fixes a bug.
* 2. We cease to use an iframe for unit rendering.
* 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug.
* 4. We stop supporting Firefox.
* 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to
* Firefox/React for review, and they kindly help us figure out what in the world is happening
* so we can fix it.
*
* This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If
* we change whether or not the Unit component is re-mounted when the unit ID changes, this may
* become important, as this hook will otherwise only evaluate the useLayoutEffect once.
*/
function useLoadBearingHook(id) {
const setValue = useState(0)[1];
useLayoutEffect(() => {
setValue(currentValue => currentValue + 1);
}, [id]);
}
export function sendUrlHashToFrame(frame) {
const { hash } = window.location;
if (hash) {
// The url hash will be sent to LMS-served iframe in order to find the location of the
// hash within the iframe.
frame.contentWindow.postMessage({ hashName: hash }, `${getConfig().LMS_BASE_URL}`);
}
}
const Unit = ({
courseId,
format,
onLoaded,
id,
intl,
}) => {
const { authenticatedUser } = useContext(AppContext);
const view = authenticatedUser ? 'student_view' : 'public_view';
let iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=${view}`;
if (format) {
iframeUrl += `&format=${format}`;
}
const [iframeHeight, setIframeHeight] = useState(0);
const [hasLoaded, setHasLoaded] = useState(false);
const [showError, setShowError] = useState(false);
const [modalOptions, setModalOptions] = useState({ open: false });
const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false);
const unit = useModel('units', id);
const course = useModel('coursewareMeta', courseId);
const {
contentTypeGatingEnabled,
userNeedsIntegritySignature,
} = course;
const dispatch = useDispatch();
// Do not remove this hook. See function description.
useLoadBearingHook(id);
useEffect(() => {
if (userNeedsIntegritySignature && unit.graded) {
setShouldDisplayHonorCode(true);
} else {
setShouldDisplayHonorCode(false);
}
}, [userNeedsIntegritySignature]);
const receiveMessage = useCallback(({ data }) => {
const {
type,
payload,
} = data;
if (type === 'plugin.resize') {
setIframeHeight(payload.height);
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
setHasLoaded(true);
if (onLoaded) {
onLoaded();
}
}
} else if (type === 'plugin.modal') {
payload.open = true;
setModalOptions(payload);
} else if (data.offset) {
// We listen for this message from LMS to know when the page needs to
// be scrolled to another location on the page.
window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop);
}
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
useEventListener('message', receiveMessage);
useEffect(() => {
sendUrlHashToFrame(document.getElementById('unit-iframe'));
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
return (
<div className="unit">
<h1 className="mb-0 h3">{unit.title}</h1>
<h2 className="sr-only">{intl.formatMessage(messages.headerPlaceholder)}</h2>
<BookmarkButton
unitId={unit.id}
isBookmarked={unit.bookmarked}
isProcessing={unit.bookmarkedUpdateState === 'loading'}
/>
{/* TODO: social share exp. Need to remove later */}
{(window.expSocialShareAboutUrls && window.expSocialShareAboutUrls[unit.id] !== undefined) && (
<ShareButton url={window.expSocialShareAboutUrls[unit.id]} />
)}
{contentTypeGatingEnabled && unit.containsContentTypeGatedContent && (
<Suspense
fallback={(
<PageLoading
srMessage={intl.formatMessage(messages.loadingLockedContent)}
/>
)}
>
<LockPaywall courseId={courseId} />
</Suspense>
)}
{shouldDisplayHonorCode && (
<Suspense
fallback={(
<PageLoading
srMessage={intl.formatMessage(messages.loadingHonorCode)}
/>
)}
>
<HonorCode courseId={courseId} />
</Suspense>
)}
{!shouldDisplayHonorCode && !hasLoaded && !showError && (
<PageLoading
srMessage={intl.formatMessage(messages.loadingSequence)}
/>
)}
{!shouldDisplayHonorCode && !hasLoaded && showError && (
<ErrorPage />
)}
{modalOptions.open && (
<Modal
body={(
<>
{modalOptions.body
? <div className="unit-modal">{ modalOptions.body }</div>
: (
<iframe
title={modalOptions.title}
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.url}
style={{
width: '100%',
height: '100vh',
}}
/>
)}
</>
)}
onClose={() => { setModalOptions({ open: false }); }}
open
dialogClassName="modal-lti"
/>
)}
{!shouldDisplayHonorCode && (
<div className="unit-iframe-wrapper">
<iframe
id="unit-iframe"
title={unit.title}
src={iframeUrl}
allow={IFRAME_FEATURE_POLICY}
allowFullScreen
height={iframeHeight}
scrolling="no"
referrerPolicy="origin"
onLoad={() => {
// onLoad *should* only fire after everything in the iframe has finished its own load events.
// Which means that the plugin.resize message (which calls setHasLoaded above) will have fired already
// for a successful load. If it *has not fired*, we are in an error state. For example, the backend
// could have given us a 4xx or 5xx response.
if (!hasLoaded) {
setShowError(true);
}
window.onmessage = (e) => {
if (e.data.event_name) {
dispatch(processEvent(e.data, fetchCourse));
}
};
}}
/>
</div>
)}
</div>
);
};
Unit.propTypes = {
courseId: PropTypes.string.isRequired,
format: PropTypes.string,
id: PropTypes.string.isRequired,
intl: intlShape.isRequired,
onLoaded: PropTypes.func,
};
Unit.defaultProps = {
format: null,
onLoaded: undefined,
};
export default injectIntl(Unit);

View File

@@ -0,0 +1,177 @@
import React from 'react';
import { Factory } from 'rosie';
import {
initializeTestStore, loadUnit, messageEvent, render, screen, waitFor,
} from '../../../setupTest';
import Unit, { sendUrlHashToFrame } from './Unit';
describe('Unit', () => {
let mockData;
const courseMetadata = Factory.build(
'courseMetadata',
{ content_type_gating_enabled: true },
);
const courseMetadataNeedsSignature = Factory.build(
'courseMetadata',
{ user_needs_integrity_signature: true },
);
const unitBlocks = [
Factory.build(
'block',
{ type: 'vertical', graded: 'true' },
{ courseId: courseMetadata.id },
), Factory.build(
'block',
{
type: 'vertical',
contains_content_type_gated_content: true,
bookmarked: true,
graded: true,
},
{ courseId: courseMetadata.id },
),
Factory.build(
'block',
{ type: 'vertical', graded: false },
{ courseId: courseMetadata.id },
),
];
const [unit, unitThatContainsGatedContent, ungradedUnit] = unitBlocks;
beforeAll(async () => {
await initializeTestStore({ courseMetadata, unitBlocks });
mockData = {
id: unit.id,
courseId: courseMetadata.id,
format: 'Homework',
};
});
it('renders correctly', () => {
render(<Unit {...mockData} />);
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
const renderedUnit = screen.getByTitle(unit.display_name);
expect(renderedUnit).toHaveAttribute('height', String(0));
expect(renderedUnit).toHaveAttribute('src', `http://localhost:18000/xblock/${mockData.id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=student_view&format=${mockData.format}`);
});
it('renders proper message for gated content', () => {
render(<Unit {...mockData} id={unitThatContainsGatedContent.id} />);
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument();
});
it('does not display HonorCode for ungraded units', async () => {
const signatureStore = await initializeTestStore(
{ courseMetadata: courseMetadataNeedsSignature, unitBlocks },
false,
);
const signatureData = {
id: ungradedUnit.id,
courseId: courseMetadataNeedsSignature.id,
format: 'Homework',
};
render(<Unit {...signatureData} />, { store: signatureStore });
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
});
it('displays HonorCode for graded units if user needs integrity signature', async () => {
const signatureStore = await initializeTestStore(
{ courseMetadata: courseMetadataNeedsSignature, unitBlocks },
false,
);
const signatureData = {
id: unit.id,
courseId: courseMetadataNeedsSignature.id,
format: 'Homework',
};
render(<Unit {...signatureData} />, { store: signatureStore });
expect(screen.getByText('Loading honor code messaging...')).toBeInTheDocument();
});
it('handles receiving MessageEvent', async () => {
render(<Unit {...mockData} />);
loadUnit();
// Loading message is gone now.
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
// Iframe's height is set via message.
expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(messageEvent.payload.height));
});
it('calls onLoaded after receiving MessageEvent', async () => {
const onLoaded = jest.fn();
render(<Unit {...mockData} {...{ onLoaded }} />);
loadUnit();
await waitFor(() => expect(onLoaded).toHaveBeenCalledTimes(1));
});
it('resizes iframe on second MessageEvent, does not call onLoaded again', async () => {
const onLoaded = jest.fn();
// Clone message and set different height.
const testMessageWithOtherHeight = { ...messageEvent, payload: { height: 200 } };
render(<Unit {...mockData} {...{ onLoaded }} />);
loadUnit();
await waitFor(() => expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(messageEvent.payload.height)));
window.postMessage(testMessageWithOtherHeight, '*');
await waitFor(() => expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(testMessageWithOtherHeight.payload.height)));
expect(onLoaded).toHaveBeenCalledTimes(1);
});
it('scrolls page on MessagaeEvent when receiving offset', async () => {
// Set message to constain offset data.
const testMessageWithOffset = { offset: 1500 };
render(<Unit {...mockData} />);
window.postMessage(testMessageWithOffset, '*');
await expect(waitFor(() => expect(window.scrollTo()).toHaveBeenCalled()));
expect(window.scrollY === testMessageWithOffset.offset);
});
it('ignores MessageEvent with unhandled type', async () => {
// Clone message and set different type.
const testMessageWithUnhandledType = { ...messageEvent, type: 'wrong type' };
render(<Unit {...mockData} />);
window.postMessage(testMessageWithUnhandledType, '*');
// HACK: We don't have a function we could reliably await here, so this test relies on the timeout of `waitFor`.
await expect(waitFor(
() => expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(testMessageWithUnhandledType.payload.height)),
{ timeout: 100 },
)).rejects.toThrowError(/Expected the element to have attribute/);
});
it('scrolls to correct place onLoad', () => {
document.body.innerHTML = "<iframe id='unit-iframe' />";
const mockHashCheck = jest.fn(frameVar => sendUrlHashToFrame(frameVar));
const frame = document.getElementById('unit-iframe');
const originalWindow = { ...window };
const windowSpy = jest.spyOn(global, 'window', 'get');
windowSpy.mockImplementation(() => ({
...originalWindow,
location: {
...originalWindow.location,
hash: '#test',
},
}));
const messageSpy = jest.spyOn(frame.contentWindow, 'postMessage');
messageSpy.mockImplementation(() => ({ hashName: originalWindow.location.hash }));
mockHashCheck(frame);
expect(mockHashCheck).toHaveBeenCalled();
expect(messageSpy).toHaveBeenCalled();
windowSpy.mockRestore();
});
it('calls useEffect and checkForHash', () => {
const mockHashCheck = jest.fn(() => sendUrlHashToFrame());
const effectSpy = jest.spyOn(React, 'useEffect');
effectSpy.mockImplementation(() => mockHashCheck());
render(<Unit {...mockData} />);
expect(React.useEffect).toHaveBeenCalled();
expect(mockHashCheck).toHaveBeenCalled();
});
});

View File

@@ -1,114 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { ErrorPage } from '@edx/frontend-platform/react';
import { StrictDict } from '@edx/react-unit-test-utils';
import { Modal } from '@edx/paragon';
import PageLoading from '../../../../generic/PageLoading';
import * as hooks from './hooks';
/**
* Feature policy for iframe, allowing access to certain courseware-related media.
*
* We must use the wildcard (*) origin for each feature, as courseware content
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
* block that iframes external course content.
* This policy was selected in conference with the edX Security Working Group.
* Changes to it should be vetted by them (security@edx.org).
*/
export const IFRAME_FEATURE_POLICY = (
'microphone *; camera *; midi *; geolocation *; encrypted-media *, clipboard-write *'
);
export const testIDs = StrictDict({
contentIFrame: 'content-iframe-test-id',
modalIFrame: 'modal-iframe-test-id',
});
const ContentIFrame = ({
iframeUrl,
shouldShowContent,
loadingMessage,
id,
elementId,
onLoaded,
title,
}) => {
const {
handleIFrameLoad,
hasLoaded,
iframeHeight,
showError,
} = hooks.useIFrameBehavior({
elementId,
id,
iframeUrl,
onLoaded,
});
const {
modalOptions,
handleModalClose,
} = hooks.useModalIFrameData();
const contentIFrameProps = {
id: elementId,
src: iframeUrl,
allow: IFRAME_FEATURE_POLICY,
allowFullScreen: true,
height: iframeHeight,
scrolling: 'no',
referrerPolicy: 'origin',
onLoad: handleIFrameLoad,
};
return (
<>
{(shouldShowContent && !hasLoaded) && (
showError ? <ErrorPage /> : <PageLoading srMessage={loadingMessage} />
)}
{shouldShowContent && (
<div className="unit-iframe-wrapper">
<iframe title={title} {...contentIFrameProps} data-testid={testIDs.contentIFrame} />
</div>
)}
{modalOptions.isOpen && (
<Modal
body={modalOptions.body
? <div className="unit-modal">{ modalOptions.body }</div>
: (
<iframe
title={modalOptions.title}
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.url}
style={{ width: '100%', height: modalOptions.height }}
/>
)}
dialogClassName="modal-lti"
onClose={handleModalClose}
open
/>
)}
</>
);
};
ContentIFrame.propTypes = {
iframeUrl: PropTypes.string,
id: PropTypes.string.isRequired,
shouldShowContent: PropTypes.bool.isRequired,
loadingMessage: PropTypes.node.isRequired,
elementId: PropTypes.string.isRequired,
onLoaded: PropTypes.func,
title: PropTypes.node.isRequired,
};
ContentIFrame.defaultProps = {
iframeUrl: null,
onLoaded: () => ({}),
};
export default ContentIFrame;

View File

@@ -1,175 +0,0 @@
import React from 'react';
import { ErrorPage } from '@edx/frontend-platform/react';
import { Modal } from '@edx/paragon';
import { shallow } from '@edx/react-unit-test-utils';
import PageLoading from '../../../../generic/PageLoading';
import * as hooks from './hooks';
import ContentIFrame, { IFRAME_FEATURE_POLICY, testIDs } from './ContentIFrame';
jest.mock('@edx/frontend-platform/react', () => ({ ErrorPage: 'ErrorPage' }));
jest.mock('@edx/paragon', () => ({ Modal: 'Modal' }));
jest.mock('../../../../generic/PageLoading', () => 'PageLoading');
jest.mock('./hooks', () => ({
useIFrameBehavior: jest.fn(),
useModalIFrameData: jest.fn(),
}));
const iframeBehavior = {
handleIFrameLoad: jest.fn().mockName('IFrameBehavior.handleIFrameLoad'),
hasLoaded: false,
iframeHeight: 20,
showError: false,
};
const modalOptions = {
closed: {
isOpen: false,
},
withBody: {
body: 'test-body',
isOpen: true,
},
withUrl: {
isOpen: true,
title: 'test-modal-title',
url: 'test-modal-url',
height: 'test-height',
},
};
const modalIFrameData = {
modalOptions: modalOptions.closed,
handleModalClose: jest.fn().mockName('modalIFrameOptions.handleModalClose'),
};
hooks.useIFrameBehavior.mockReturnValue(iframeBehavior);
hooks.useModalIFrameData.mockReturnValue(modalIFrameData);
const props = {
iframeUrl: 'test-iframe-url',
shouldShowContent: true,
loadingMessage: 'test-loading-message',
id: 'test-id',
elementId: 'test-element-id',
onLoaded: jest.fn().mockName('props.onLoaded'),
title: 'test-title',
};
let el;
describe('ContentIFrame Component', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('behavior', () => {
beforeEach(() => {
el = shallow(<ContentIFrame {...props} />);
});
it('initializes iframe behavior hook', () => {
expect(hooks.useIFrameBehavior).toHaveBeenCalledWith({
elementId: props.elementId,
id: props.id,
iframeUrl: props.iframeUrl,
onLoaded: props.onLoaded,
});
});
it('initializes modal iframe data', () => {
expect(hooks.useModalIFrameData).toHaveBeenCalledWith();
});
});
describe('output', () => {
let component;
describe('if shouldShowContent', () => {
describe('if not hasLoaded', () => {
it('displays errorPage if showError', () => {
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, showError: true });
el = shallow(<ContentIFrame {...props} />);
expect(el.instance.findByType(ErrorPage).length).toEqual(1);
});
it('displays PageLoading component if not showError', () => {
el = shallow(<ContentIFrame {...props} />);
[component] = el.instance.findByType(PageLoading);
expect(component.props.srMessage).toEqual(props.loadingMessage);
});
});
describe('hasLoaded', () => {
it('does not display PageLoading or ErrorPage', () => {
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, hasLoaded: true });
el = shallow(<ContentIFrame {...props} />);
expect(el.instance.findByType(PageLoading).length).toEqual(0);
expect(el.instance.findByType(ErrorPage).length).toEqual(0);
});
});
it('display iframe with props from hooks', () => {
el = shallow(<ContentIFrame {...props} />);
[component] = el.instance.findByTestId(testIDs.contentIFrame);
expect(component.props).toEqual({
allow: IFRAME_FEATURE_POLICY,
allowFullScreen: true,
scrolling: 'no',
referrerPolicy: 'origin',
title: props.title,
id: props.elementId,
src: props.iframeUrl,
height: iframeBehavior.iframeHeight,
onLoad: iframeBehavior.handleIFrameLoad,
'data-testid': testIDs.contentIFrame,
});
});
});
describe('if not shouldShowContent', () => {
it('does not show PageLoading, ErrorPage, or unit-iframe-wrapper', () => {
el = shallow(<ContentIFrame {...{ ...props, shouldShowContent: false }} />);
expect(el.instance.findByType(PageLoading).length).toEqual(0);
expect(el.instance.findByType(ErrorPage).length).toEqual(0);
expect(el.instance.findByTestId(testIDs.contentIFrame).length).toEqual(0);
});
});
it('does not display modal if modalOptions returns isOpen: false', () => {
el = shallow(<ContentIFrame {...props} />);
expect(el.instance.findByType(Modal).length).toEqual(0);
});
describe('if modalOptions.isOpen', () => {
const testModalOpenAndHandleClose = () => {
test('Modal component isOpen, with handleModalClose from hook', () => {
expect(component.props.onClose).toEqual(modalIFrameData.handleModalClose);
});
};
describe('body modal', () => {
beforeEach(() => {
hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withBody });
el = shallow(<ContentIFrame {...props} />);
[component] = el.instance.findByType(Modal);
});
it('displays Modal with div wrapping provided body content if modal.body is provided', () => {
expect(component.props.body).toEqual(<div className="unit-modal">{modalOptions.withBody.body}</div>);
});
testModalOpenAndHandleClose();
});
describe('url modal', () => {
beforeEach(() => {
hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withUrl });
el = shallow(<ContentIFrame {...props} />);
[component] = el.instance.findByType(Modal);
});
testModalOpenAndHandleClose();
it('displays Modal with iframe to provided url if modal.body is not provided', () => {
expect(component.props.body).toEqual(
<iframe
title={modalOptions.withUrl.title}
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.withUrl.url}
style={{ width: '100%', height: modalOptions.withUrl.height }}
/>,
);
});
});
});
});
});

View File

@@ -1,50 +0,0 @@
import React, { Suspense } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useModel } from '../../../../generic/model-store';
import PageLoading from '../../../../generic/PageLoading';
import messages from '../messages';
import HonorCode from '../honor-code';
import LockPaywall from '../lock-paywall';
import * as hooks from './hooks';
import { modelKeys } from './constants';
const UnitSuspense = ({
courseId,
id,
}) => {
const { formatMessage } = useIntl();
const shouldDisplayHonorCode = hooks.useShouldDisplayHonorCode({ courseId, id });
const unit = useModel(modelKeys.units, id);
const meta = useModel(modelKeys.coursewareMeta, courseId);
const shouldDisplayContentGating = (
meta.contentTypeGatingEnabled && unit.containsContentTypeGatedContent
);
const suspenseComponent = (message, Component) => (
<Suspense fallback={<PageLoading srMessage={formatMessage(message)} />}>
<Component courseId={courseId} />
</Suspense>
);
return (
<>
{shouldDisplayContentGating && (
suspenseComponent(messages.loadingLockedContent, LockPaywall)
)}
{shouldDisplayHonorCode && (
suspenseComponent(messages.loadingHonorCode, HonorCode)
)}
</>
);
};
UnitSuspense.propTypes = {
courseId: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
};
export default UnitSuspense;

View File

@@ -1,106 +0,0 @@
import React from 'react';
import { formatMessage, shallow } from '@edx/react-unit-test-utils';
import { useModel } from '../../../../generic/model-store';
import PageLoading from '../../../../generic/PageLoading';
import messages from '../messages';
import HonorCode from '../honor-code';
import LockPaywall from '../lock-paywall';
import hooks from './hooks';
import { modelKeys } from './constants';
import UnitSuspense from './UnitSuspense';
jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m,
useIntl: () => ({ formatMessage: jest.requireActual('@edx/react-unit-test-utils').formatMessage }),
}));
jest.mock('react', () => ({
...jest.requireActual('react'),
Suspense: 'Suspense',
}));
jest.mock('../honor-code', () => 'HonorCode');
jest.mock('../lock-paywall', () => 'LockPaywall');
jest.mock('../../../../generic/model-store', () => ({ useModel: jest.fn() }));
jest.mock('../../../../generic/PageLoading', () => 'PageLoading');
jest.mock('./hooks', () => ({
useShouldDisplayHonorCode: jest.fn(() => false),
}));
const mockModels = (enabled, containsContent) => {
useModel.mockImplementation((key) => (
key === modelKeys.units
? { containsContentTypeGatedContent: containsContent }
: { contentTypeGatingEnabled: enabled }
));
};
const props = {
courseId: 'test-course-id',
id: 'test-id',
};
let el;
describe('UnitSuspense component', () => {
beforeEach(() => {
jest.clearAllMocks();
mockModels(false, false);
});
describe('behavior', () => {
it('initializes models', () => {
el = shallow(<UnitSuspense {...props} />);
const { calls } = useModel.mock;
const [unitCall] = calls.filter(call => call[0] === modelKeys.units);
const [metaCall] = calls.filter(call => call[0] === modelKeys.coursewareMeta);
expect(unitCall[1]).toEqual(props.id);
expect(metaCall[1]).toEqual(props.courseId);
});
});
describe('output', () => {
describe('LockPaywall', () => {
const testNoPaywall = () => {
it('does not display LockPaywal', () => {
el = shallow(<UnitSuspense {...props} />);
expect(el.instance.findByType(LockPaywall).length).toEqual(0);
});
};
describe('gating not enabled', () => { testNoPaywall(); });
describe('gating enabled, but no gated content included', () => {
beforeEach(() => { mockModels(true, false); });
testNoPaywall();
});
describe('gating enabled, gated content included', () => {
beforeEach(() => { mockModels(true, true); });
it('displays LockPaywall in Suspense wrapper with PageLoading fallback', () => {
el = shallow(<UnitSuspense {...props} />);
const [component] = el.instance.findByType(LockPaywall);
expect(component.parent.type).toEqual('Suspense');
expect(component.parent.props.fallback)
.toEqual(<PageLoading srMessage={formatMessage(messages.loadingLockedContent)} />);
expect(component.props.courseId).toEqual(props.courseId);
});
});
});
describe('HonorCode', () => {
it('does not display HonorCode if useShouldDisplayHonorCode => false', () => {
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(false);
el = shallow(<UnitSuspense {...props} />);
expect(el.instance.findByType(HonorCode).length).toEqual(0);
});
it('displays HonorCode component in Suspense wrapper with PageLoading fallback if shouldDisplayHonorCode', () => {
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(true);
el = shallow(<UnitSuspense {...props} />);
const [component] = el.instance.findByType(HonorCode);
expect(component.parent.type).toEqual('Suspense');
expect(component.parent.props.fallback)
.toEqual(<PageLoading srMessage={formatMessage(messages.loadingHonorCode)} />);
expect(component.props.courseId).toEqual(props.courseId);
});
});
});
});

View File

@@ -1,51 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Unit component output BookmarkButton props bookmarked, bookmark update pending snapshot 1`] = `
<BookmarkButton
isBookmarked={true}
isProcessing={false}
unitId="unit-id"
/>
`;
exports[`Unit component output BookmarkButton props not bookmarked, bookmark update loading snapshot 1`] = `
<BookmarkButton
isBookmarked={false}
isProcessing={true}
unitId="unit-id"
/>
`;
exports[`Unit component output snapshot: not bookmarked, do not show content 1`] = `
<div
className="unit"
>
<h1
className="mb-0 h3"
>
unit-title
</h1>
<h2
className="sr-only"
>
Level 2 headings may be created by course providers in the future.
</h2>
<BookmarkButton
isBookmarked={false}
isProcessing={false}
unitId="unit-id"
/>
<UnitSuspense
courseId="test-course-id"
id="test-props-id"
/>
<ContentIFrame
elementId="unit-iframe"
id="test-props-id"
loadingMessage="Loading learning sequence..."
onLoaded={[MockFunction props.onLoaded]}
shouldShowContent={true}
title="unit-title"
/>
</div>
`;

View File

@@ -1,26 +0,0 @@
import { StrictDict } from '@edx/react-unit-test-utils/dist';
export const modelKeys = StrictDict({
units: 'units',
coursewareMeta: 'coursewareMeta',
});
export const views = StrictDict({
student: 'student_view',
public: 'public_view',
});
export const loadingState = 'loading';
export const messageTypes = StrictDict({
modal: 'plugin.modal',
resize: 'plugin.resize',
videoFullScreen: 'plugin.videoFullScreen',
});
export default StrictDict({
modelKeys,
views,
loadingState,
messageTypes,
});

View File

@@ -1,5 +0,0 @@
export { default as useExamAccess } from './useExamAccess';
export { default as useIFrameBehavior } from './useIFrameBehavior';
export { default as useLoadBearingHook } from './useLoadBearingHook';
export { default as useModalIFrameData } from './useModalIFrameData';
export { default as useShouldDisplayHonorCode } from './useShouldDisplayHonorCode';

View File

@@ -1,37 +0,0 @@
import React from 'react';
import { logError } from '@edx/frontend-platform/logging';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import { getExamAccess, fetchExamAccess, isExam } from '@edx/frontend-lib-special-exams';
export const stateKeys = StrictDict({
accessToken: 'accessToken',
blockAccess: 'blockAccess',
});
const useExamAccess = ({
id,
}) => {
const [accessToken, setAccessToken] = useKeyedState(stateKeys.accessToken, '');
const [blockAccess, setBlockAccess] = useKeyedState(stateKeys.blockAccess, isExam());
React.useEffect(() => {
if (isExam()) {
fetchExamAccess()
.finally(() => {
const examAccess = getExamAccess();
setAccessToken(examAccess);
setBlockAccess(false);
})
.catch((error) => {
logError(error);
});
}
}, [id]);
return {
blockAccess,
accessToken,
};
};
export default useExamAccess;

View File

@@ -1,98 +0,0 @@
import React from 'react';
import { logError } from '@edx/frontend-platform/logging';
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import { getExamAccess, fetchExamAccess, isExam } from '@edx/frontend-lib-special-exams';
import { isEqual } from 'lodash';
import { waitFor } from '../../../../../setupTest';
import useExamAccess, { stateKeys } from './useExamAccess';
const getEffect = (prereqs) => {
const { calls } = React.useEffect.mock;
const match = calls.filter(call => isEqual(call[1], prereqs));
return match.length ? match[0][0] : null;
};
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn(),
}));
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
jest.mock('@edx/frontend-lib-special-exams', () => ({
getExamAccess: jest.fn(),
fetchExamAccess: jest.fn(),
isExam: jest.fn(() => false),
}));
const state = mockUseKeyedState(stateKeys);
const id = 'test-id';
const mockFetchExamAccess = Promise.resolve();
fetchExamAccess.mockReturnValue(mockFetchExamAccess);
const testAccessToken = 'test-access-token';
getExamAccess.mockReturnValue(testAccessToken);
describe('useExamAccess hook', () => {
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
describe('behavior', () => {
it('initializes access token to empty string', () => {
useExamAccess({ id });
state.expectInitializedWith(stateKeys.accessToken, '');
});
it('initializes blockAccess to true if is an exam', () => {
useExamAccess({ id });
state.expectInitializedWith(stateKeys.blockAccess, false);
});
it('initializes blockAccess to false if is not an exam', () => {
isExam.mockReturnValueOnce(true);
useExamAccess({ id });
state.expectInitializedWith(stateKeys.blockAccess, true);
});
describe('effects - on id change', () => {
let useEffectCb;
beforeEach(() => {
useExamAccess({ id });
useEffectCb = getEffect([id], React);
});
it('does not call fetchExamAccess if not an exam', () => {
useEffectCb();
expect(fetchExamAccess).not.toHaveBeenCalled();
});
it('fetches and sets exam access if isExam', async () => {
isExam.mockReturnValueOnce(true);
useEffectCb();
await waitFor(() => expect(fetchExamAccess).toHaveBeenCalled());
state.expectSetStateCalledWith(stateKeys.accessToken, testAccessToken);
state.expectSetStateCalledWith(stateKeys.blockAccess, false);
});
const testError = 'test-error';
it('logs error if fetchExamAccess fails', async () => {
isExam.mockReturnValueOnce(true);
fetchExamAccess.mockReturnValueOnce(Promise.reject(testError));
useEffectCb();
await waitFor(() => expect(fetchExamAccess).toHaveBeenCalled());
expect(logError).toHaveBeenCalledWith(testError);
});
});
});
describe('output', () => {
it('forwards blockAccess and accessToken from state fields', () => {
const testBlockAccess = 'test-block-access';
state.mockVals({
blockAccess: testBlockAccess,
accessToken: testAccessToken,
});
const out = useExamAccess({ id });
expect(out.blockAccess).toEqual(testBlockAccess);
expect(out.accessToken).toEqual(testAccessToken);
state.resetVals();
});
});
});

View File

@@ -1,116 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import React from 'react';
import { useDispatch } from 'react-redux';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import { logError } from '@edx/frontend-platform/logging';
import { fetchCourse } from '../../../../data';
import { processEvent } from '../../../../../course-home/data/thunks';
import { useEventListener } from '../../../../../generic/hooks';
import { messageTypes } from '../constants';
import useLoadBearingHook from './useLoadBearingHook';
export const stateKeys = StrictDict({
iframeHeight: 'iframeHeight',
hasLoaded: 'hasLoaded',
showError: 'showError',
windowTopOffset: 'windowTopOffset',
});
const useIFrameBehavior = ({
elementId,
id,
iframeUrl,
onLoaded,
}) => {
// Do not remove this hook. See function description.
useLoadBearingHook(id);
const dispatch = useDispatch();
const [iframeHeight, setIframeHeight] = useKeyedState(stateKeys.iframeHeight, 0);
const [hasLoaded, setHasLoaded] = useKeyedState(stateKeys.hasLoaded, false);
const [showError, setShowError] = useKeyedState(stateKeys.showError, false);
const [windowTopOffset, setWindowTopOffset] = useKeyedState(stateKeys.windowTopOffset, null);
React.useEffect(() => {
const frame = document.getElementById(elementId);
const { hash } = window.location;
if (hash) {
// The url hash will be sent to LMS-served iframe in order to find the location of the
// hash within the iframe.
frame.contentWindow.postMessage({ hashName: hash }, `${getConfig().LMS_BASE_URL}`);
}
}, [id, onLoaded, iframeHeight, hasLoaded]);
const receiveMessage = React.useCallback(({ data }) => {
const { type, payload } = data;
if (type === messageTypes.resize) {
setIframeHeight(payload.height);
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
setHasLoaded(true);
if (onLoaded) {
onLoaded();
}
}
} else if (type === messageTypes.videoFullScreen) {
// We observe exit from the video xblock fullscreen mode
// and scroll to the previously saved scroll position
if (!payload.open && windowTopOffset !== null) {
window.scrollTo(0, Number(windowTopOffset));
}
// We listen for this message from LMS to know when we need to
// save or reset scroll position on toggle video xblock fullscreen mode
setWindowTopOffset(payload.open ? window.scrollY : null);
} else if (data.offset) {
// We listen for this message from LMS to know when the page needs to
// be scrolled to another location on the page.
window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop);
}
}, [
id,
onLoaded,
hasLoaded,
setHasLoaded,
iframeHeight,
setIframeHeight,
windowTopOffset,
setWindowTopOffset,
]);
useEventListener('message', receiveMessage);
/**
* onLoad *should* only fire after everything in the iframe has finished its own load events.
* Which means that the plugin.resize message (which calls setHasLoaded above) will have fired already
* for a successful load. If it *has not fired*, we are in an error state. For example, the backend
* could have given us a 4xx or 5xx response.
*/
const handleIFrameLoad = () => {
if (!hasLoaded) {
setShowError(true);
logError('Unit iframe failed to load. Server possibly returned 4xx or 5xx response.', {
iframeUrl,
});
}
window.onmessage = (e) => {
if (e.data.event_name) {
dispatch(processEvent(e.data, fetchCourse));
}
};
};
return {
iframeHeight,
handleIFrameLoad,
showError,
hasLoaded,
};
};
export default useIFrameBehavior;

View File

@@ -1,298 +0,0 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { getEffects, mockUseKeyedState } from '@edx/react-unit-test-utils';
import { logError } from '@edx/frontend-platform/logging';
import { getConfig } from '@edx/frontend-platform';
import { fetchCourse } from '../../../../data';
import { processEvent } from '../../../../../course-home/data/thunks';
import { useEventListener } from '../../../../../generic/hooks';
import { messageTypes } from '../constants';
import useIFrameBehavior, { stateKeys } from './useIFrameBehavior';
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn(),
useCallback: jest.fn((cb, prereqs) => ({ cb, prereqs })),
}));
jest.mock('react-redux', () => ({
useDispatch: jest.fn(),
}));
jest.mock('./useLoadBearingHook', () => jest.fn());
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
jest.mock('../../../../data', () => ({
fetchCourse: jest.fn(),
}));
jest.mock('../../../../../course-home/data/thunks', () => ({
processEvent: jest.fn((...args) => ({ processEvent: args })),
}));
jest.mock('../../../../../generic/hooks', () => ({
useEventListener: jest.fn(),
}));
const state = mockUseKeyedState(stateKeys);
const props = {
elementId: 'test-element-id',
id: 'test-id',
iframeUrl: 'test-iframe-url',
onLoaded: jest.fn(),
};
const testIFrameHeight = 42;
const config = { LMS_BASE_URL: 'test-base-url' };
getConfig.mockReturnValue(config);
const dispatch = jest.fn();
useDispatch.mockReturnValue(dispatch);
const postMessage = jest.fn();
const frame = { contentWindow: { postMessage } };
const mockGetElementById = jest.fn(() => frame);
const testHash = '#test-hash';
const defaultStateVals = {
iframeHeight: 0,
hasLoaded: false,
showError: false,
windowTopOffset: null,
};
const stateVals = {
iframeHeight: testIFrameHeight,
hasLoaded: true,
showError: true,
windowTopOffset: 32,
};
describe('useIFrameBehavior hook', () => {
let hook;
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
afterEach(() => {
state.resetVals();
});
describe('behavior', () => {
it('initializes iframe height to 0 and error/loaded values to false', () => {
hook = useIFrameBehavior(props);
state.expectInitializedWith(stateKeys.iframeHeight, 0);
state.expectInitializedWith(stateKeys.hasLoaded, false);
state.expectInitializedWith(stateKeys.showError, false);
state.expectInitializedWith(stateKeys.windowTopOffset, null);
});
describe('effects - on frame change', () => {
let oldGetElement;
beforeEach(() => {
global.window ??= Object.create(window);
Object.defineProperty(window, 'location', { value: {}, writable: true });
state.mockVals(stateVals);
oldGetElement = document.getElementById;
document.getElementById = mockGetElementById;
});
afterEach(() => {
state.resetVals();
document.getElementById = oldGetElement;
});
it('does not post url hash if the window does not have one', () => {
hook = useIFrameBehavior(props);
const cb = getEffects([
props.id,
props.onLoaded,
testIFrameHeight,
true,
], React)[0];
cb();
expect(postMessage).not.toHaveBeenCalled();
});
it('posts url hash if the window has one', () => {
window.location.hash = testHash;
hook = useIFrameBehavior(props);
const cb = getEffects([
props.id,
props.onLoaded,
testIFrameHeight,
true,
], React)[0];
cb();
expect(postMessage).toHaveBeenCalledWith({ hashName: testHash }, config.LMS_BASE_URL);
});
});
describe('event listener', () => {
it('calls eventListener with prepared callback', () => {
state.mockVals(stateVals);
hook = useIFrameBehavior(props);
const [call] = useEventListener.mock.calls;
expect(call[0]).toEqual('message');
expect(call[1].prereqs).toEqual([
props.id,
props.onLoaded,
state.values.hasLoaded,
state.setState.hasLoaded,
state.values.iframeHeight,
state.setState.iframeHeight,
state.values.windowTopOffset,
state.setState.windowTopOffset,
]);
});
describe('resize message', () => {
const resizeMessage = (height = 23) => ({
data: { type: messageTypes.resize, payload: { height } },
});
const videoFullScreenMessage = (open = false) => ({
data: { type: messageTypes.videoFullScreen, payload: { open } },
});
const testSetIFrameHeight = (height = 23) => {
const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage(height));
expect(state.setState.iframeHeight).toHaveBeenCalledWith(height);
};
const testOnlySetsHeight = () => {
it('sets iframe height with payload height', () => {
testSetIFrameHeight();
});
it('does not set hasLoaded', () => {
expect(state.setState.hasLoaded).not.toHaveBeenCalled();
});
};
describe('hasLoaded', () => {
beforeEach(() => {
state.mockVals({ ...defaultStateVals, hasLoaded: true });
hook = useIFrameBehavior(props);
});
testOnlySetsHeight();
});
describe('iframeHeight is not 0', () => {
beforeEach(() => {
state.mockVals({ ...defaultStateVals, hasLoaded: true });
hook = useIFrameBehavior(props);
});
testOnlySetsHeight();
});
describe('payload height is 0', () => {
beforeEach(() => { hook = useIFrameBehavior(props); });
testOnlySetsHeight(0);
});
describe('payload is present but uninitialized', () => {
it('sets iframe height with payload height', () => {
hook = useIFrameBehavior(props);
testSetIFrameHeight();
});
it('sets hasLoaded and calls onLoaded', () => {
hook = useIFrameBehavior(props);
const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage());
expect(state.setState.hasLoaded).toHaveBeenCalledWith(true);
expect(props.onLoaded).toHaveBeenCalled();
});
test('onLoaded is optional', () => {
hook = useIFrameBehavior({ ...props, onLoaded: undefined });
const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage());
expect(state.setState.hasLoaded).toHaveBeenCalledWith(true);
});
});
it('scrolls to current window vertical offset if one is set', () => {
const windowTopOffset = 32;
state.mockVals({ ...defaultStateVals, windowTopOffset });
hook = useIFrameBehavior(props);
const { cb } = useEventListener.mock.calls[0][1];
cb(videoFullScreenMessage());
expect(window.scrollTo).toHaveBeenCalledWith(0, windowTopOffset);
});
it('does not scroll if towverticalp offset is not set', () => {
hook = useIFrameBehavior(props);
const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage());
expect(window.scrollTo).not.toHaveBeenCalled();
});
});
describe('video fullscreen message', () => {
let cb;
const scrollY = 23;
const fullScreenMessage = (open) => ({
data: { type: messageTypes.videoFullScreen, payload: { open } },
});
beforeEach(() => {
window.scrollY = scrollY;
hook = useIFrameBehavior(props);
[[, { cb }]] = useEventListener.mock.calls;
});
it('sets window top offset based on window.scrollY if opening the video', () => {
cb(fullScreenMessage(true));
expect(state.setState.windowTopOffset).toHaveBeenCalledWith(scrollY);
});
it('sets window top offset to null if closing the video', () => {
cb(fullScreenMessage(false));
expect(state.setState.windowTopOffset).toHaveBeenCalledWith(null);
});
});
describe('offset message', () => {
it('scrolls to data offset', () => {
const offsetTop = 44;
const mockGetEl = jest.fn(() => ({ offsetTop }));
const oldGetElement = document.getElementById;
document.getElementById = mockGetEl;
const oldScrollTo = window.scrollTo;
window.scrollTo = jest.fn();
hook = useIFrameBehavior(props);
const { cb } = useEventListener.mock.calls[0][1];
const offset = 99;
cb({ data: { offset } });
expect(window.scrollTo).toHaveBeenCalledWith(0, offset + offsetTop);
expect(mockGetEl).toHaveBeenCalledWith('unit-iframe');
document.getElementById = oldGetElement;
window.scrollTo = oldScrollTo;
});
});
});
});
describe('output', () => {
describe('handleIFrameLoad', () => {
it('sets and logs error if has not loaded', () => {
hook = useIFrameBehavior(props);
hook.handleIFrameLoad();
expect(state.setState.showError).toHaveBeenCalledWith(true);
expect(logError).toHaveBeenCalled();
});
it('does not set/log errors if loaded', () => {
state.mockVals({ ...defaultStateVals, hasLoaded: true });
hook = useIFrameBehavior(props);
hook.handleIFrameLoad();
expect(state.setState.showError).not.toHaveBeenCalled();
expect(logError).not.toHaveBeenCalled();
});
it('registers an event handler to process fetchCourse events.', () => {
hook = useIFrameBehavior(props);
hook.handleIFrameLoad();
const eventName = 'test-event-name';
const event = { data: { event_name: eventName } };
window.onmessage(event);
expect(dispatch).toHaveBeenCalledWith(processEvent(event.data, fetchCourse));
});
});
it('forwards handleIframeLoad, showError, and hasLoaded from state fields', () => {
state.mockVals(stateVals);
hook = useIFrameBehavior(props);
expect(hook.iframeHeight).toEqual(stateVals.iframeHeight);
expect(hook.showError).toEqual(stateVals.showError);
expect(hook.hasLoaded).toEqual(stateVals.hasLoaded);
});
});
});

View File

@@ -1,35 +0,0 @@
import React from 'react';
/**
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
* useEffect hooks until the user interacts with the page again. This is particularly confusing
* when navigating between sequences, as the UI partially updates leaving the user in a nebulous
* state.
*
* We were able to solve this error by using a layout effect to update some component state, which
* executes synchronously on render. Somehow this forces React to continue it's lifecycle
* immediately, rather than waiting for user interaction. This layout effect could be anywhere in
* the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's
* a joke) one here so it wouldn't be accidentally removed elsewhere.
*
* If we remove this hook when one of these happens:
* 1. React figures out that there's an issue here and fixes a bug.
* 2. We cease to use an iframe for unit rendering.
* 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug.
* 4. We stop supporting Firefox.
* 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to
* Firefox/React for review, and they kindly help us figure out what in the world is happening
* so we can fix it.
*
* This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If
* we change whether or not the Unit component is re-mounted when the unit ID changes, this may
* become important, as this hook will otherwise only evaluate the useLayoutEffect once.
*/
const useLoadBearingHook = (id) => {
const setValue = React.useState(0)[1];
React.useLayoutEffect(() => {
setValue(currentValue => currentValue + 1);
}, [id]);
};
export default useLoadBearingHook;

View File

@@ -1,24 +0,0 @@
import React from 'react';
import useLoadBearingHook from './useLoadBearingHook';
jest.mock('react', () => ({
...jest.requireActual('react'),
useState: jest.fn(),
useLayoutEffect: jest.fn(),
}));
const setState = jest.fn();
React.useState.mockImplementation((val) => [val, setState]);
const id = 'test-id';
describe('useLoadBearingHook', () => {
it('increments a simple value w/ useLayoutEffect', () => {
useLoadBearingHook(id);
expect(React.useState).toHaveBeenCalledWith(0);
const [[layoutCb, prereqs]] = React.useLayoutEffect.mock.calls;
expect(prereqs).toEqual([id]);
layoutCb();
const [[setValueCb]] = setState.mock.calls;
expect(setValueCb(1)).toEqual(2);
});
});

View File

@@ -1,37 +0,0 @@
import React from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils/dist';
import { useEventListener } from '../../../../../generic/hooks';
export const stateKeys = StrictDict({
isOpen: 'isOpen',
options: 'options',
});
export const DEFAULT_HEIGHT = '100vh';
const useModalIFrameBehavior = () => {
const [isOpen, setIsOpen] = useKeyedState(stateKeys.isOpen, false);
const [options, setOptions] = useKeyedState(stateKeys.options, { height: DEFAULT_HEIGHT });
const receiveMessage = React.useCallback(({ data }) => {
const { type, payload } = data;
if (type === 'plugin.modal') {
setOptions((current) => ({ ...current, ...payload }));
setIsOpen(true);
}
}, []);
useEventListener('message', receiveMessage);
const handleModalClose = () => {
setIsOpen(false);
};
return {
handleModalClose,
modalOptions: { isOpen, ...options },
};
};
export default useModalIFrameBehavior;

View File

@@ -1,68 +0,0 @@
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import { useEventListener } from '../../../../../generic/hooks';
import { messageTypes } from '../constants';
import useModalIFrameBehavior, { stateKeys, DEFAULT_HEIGHT } from './useModalIFrameData';
jest.mock('react', () => ({
...jest.requireActual('react'),
useCallback: jest.fn((cb, prereqs) => ({ cb, prereqs })),
}));
jest.mock('../../../../../generic/hooks', () => ({
useEventListener: jest.fn(),
}));
const state = mockUseKeyedState(stateKeys);
describe('useModalIFrameBehavior', () => {
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
describe('behavior', () => {
it('initializes isOpen to false', () => {
useModalIFrameBehavior();
state.expectInitializedWith(stateKeys.isOpen, false);
});
it('initializes options with default height', () => {
useModalIFrameBehavior();
state.expectInitializedWith(stateKeys.options, { height: DEFAULT_HEIGHT });
});
describe('eventListener', () => {
it('consumes modal events and opens sets modal options with open: true', () => {
const oldOptions = { some: 'old', options: 'yeah' };
state.mockVals({
[stateKeys.isOpen]: false,
[stateKeys.options]: oldOptions,
});
useModalIFrameBehavior();
expect(useEventListener).toHaveBeenCalled();
const { cb, prereqs } = useEventListener.mock.calls[0][1];
expect(prereqs).toEqual([]);
const payload = { test: 'values' };
cb({ data: { type: messageTypes.modal, payload } });
expect(state.setState.isOpen).toHaveBeenCalledWith(true);
expect(state.setState.options).toHaveBeenCalled();
const [[setOptionsCb]] = state.setState.options.mock.calls;
expect(setOptionsCb(oldOptions)).toEqual({ ...oldOptions, ...payload });
});
});
});
describe('output', () => {
test('handleModalClose sets modal options to closed', () => {
useModalIFrameBehavior().handleModalClose();
state.expectSetStateCalledWith(stateKeys.isOpen, false);
});
it('forwards modalOptions from state values', () => {
const modalOptions = { test: 'options' };
state.mockVals({
[stateKeys.options]: modalOptions,
[stateKeys.isOpen]: true,
});
expect(useModalIFrameBehavior().modalOptions).toEqual({
...modalOptions,
isOpen: true,
});
});
});
});

View File

@@ -1,28 +0,0 @@
import React from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils/dist';
import { useModel } from '../../../../../generic/model-store';
import { modelKeys } from '../constants';
export const stateKeys = StrictDict({
shouldDisplay: 'shouldDisplay',
});
/**
* @return {bool} should the honor code be displayed?
*/
const useShouldDisplayHonorCode = ({ id, courseId }) => {
const [shouldDisplay, setShouldDisplay] = useKeyedState(stateKeys.shouldDisplay, false);
const { graded } = useModel(modelKeys.units, id);
const { userNeedsIntegritySignature } = useModel(modelKeys.coursewareMeta, courseId);
React.useEffect(() => {
setShouldDisplay(userNeedsIntegritySignature && graded);
}, [setShouldDisplay, userNeedsIntegritySignature]);
return shouldDisplay;
};
export default useShouldDisplayHonorCode;

View File

@@ -1,79 +0,0 @@
import React from 'react';
import { getEffects, mockUseKeyedState } from '@edx/react-unit-test-utils';
import { useModel } from '../../../../../generic/model-store';
import { modelKeys } from '../constants';
import useShouldDisplayHonorCode, { stateKeys } from './useShouldDisplayHonorCode';
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn(),
}));
jest.mock('../../../../../generic/model-store', () => ({
useModel: jest.fn(),
}));
const state = mockUseKeyedState(stateKeys);
const props = {
id: 'test-id',
courseId: 'test-course-id',
};
const mockModels = (graded, userNeedsIntegritySignature) => {
useModel.mockImplementation((key) => (
(key === modelKeys.units) ? { graded } : { userNeedsIntegritySignature }
));
};
describe('useShouldDisplayHonorCode hook', () => {
beforeEach(() => {
jest.clearAllMocks();
mockModels(false, false);
state.mock();
});
describe('behavior', () => {
it('initializes shouldDisplay to false', () => {
useShouldDisplayHonorCode(props);
state.expectInitializedWith(stateKeys.shouldDisplay, false);
});
describe('effect - on userNeedsIntegritySignature', () => {
describe('graded and needs integrity signature', () => {
it('sets shouldDisplay(true)', () => {
mockModels(true, true);
useShouldDisplayHonorCode(props);
const cb = getEffects([state.setState.shouldDisplay, true], React)[0];
cb();
expect(state.setState.shouldDisplay).toHaveBeenCalledWith(true);
});
});
describe('not graded', () => {
it('sets should not display', () => {
mockModels(true, false);
useShouldDisplayHonorCode(props);
const cb = getEffects([state.setState.shouldDisplay, false], React)[0];
cb();
expect(state.setState.shouldDisplay).toHaveBeenCalledWith(false);
});
});
describe('does not need integrity signature', () => {
it('sets should not display', () => {
mockModels(false, true);
useShouldDisplayHonorCode(props);
const cb = getEffects([state.setState.shouldDisplay, true], React)[0];
cb();
expect(state.setState.shouldDisplay).toHaveBeenCalledWith(false);
});
});
});
});
describe('output', () => {
it('returns shouldDisplay value from state', () => {
const testValue = 'test-value';
state.mockVal(stateKeys.shouldDisplay, testValue);
expect(useShouldDisplayHonorCode(props)).toEqual(testValue);
});
});
});

View File

@@ -1,73 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useModel } from '../../../../generic/model-store';
import BookmarkButton from '../../bookmark/BookmarkButton';
import messages from '../messages';
import ContentIFrame from './ContentIFrame';
import UnitSuspense from './UnitSuspense';
import { modelKeys, views } from './constants';
import { useExamAccess, useShouldDisplayHonorCode } from './hooks';
import { getIFrameUrl } from './urls';
const Unit = ({
courseId,
format,
onLoaded,
id,
}) => {
const { formatMessage } = useIntl();
const { authenticatedUser } = React.useContext(AppContext);
const examAccess = useExamAccess({ id });
const shouldDisplayHonorCode = useShouldDisplayHonorCode({ courseId, id });
const unit = useModel(modelKeys.units, id);
const isProcessing = unit.bookmarkedUpdateState === 'loading';
const view = authenticatedUser ? views.student : views.public;
const iframeUrl = getIFrameUrl({
id,
view,
format,
examAccess,
});
return (
<div className="unit">
<h1 className="mb-0 h3">{unit.title}</h1>
<h2 className="sr-only">{formatMessage(messages.headerPlaceholder)}</h2>
<BookmarkButton
unitId={unit.id}
isBookmarked={unit.bookmarked}
isProcessing={isProcessing}
/>
<UnitSuspense {...{ courseId, id }} />
<ContentIFrame
elementId="unit-iframe"
id={id}
iframeUrl={iframeUrl}
loadingMessage={formatMessage(messages.loadingSequence)}
onLoaded={onLoaded}
shouldShowContent={!shouldDisplayHonorCode && !examAccess.blockAccess}
title={unit.title}
/>
</div>
);
};
Unit.propTypes = {
courseId: PropTypes.string.isRequired,
format: PropTypes.string,
id: PropTypes.string.isRequired,
onLoaded: PropTypes.func,
};
Unit.defaultProps = {
format: null,
onLoaded: undefined,
};
export default Unit;

View File

@@ -1,191 +0,0 @@
import React from 'react';
import { formatMessage, shallow } from '@edx/react-unit-test-utils/dist';
import { useModel } from '../../../../generic/model-store';
import BookmarkButton from '../../bookmark/BookmarkButton';
import UnitSuspense from './UnitSuspense';
import ContentIFrame from './ContentIFrame';
import Unit from '.';
import messages from '../messages';
import { getIFrameUrl } from './urls';
import { views } from './constants';
import * as hooks from './hooks';
jest.mock('./hooks', () => ({ useUnitData: jest.fn() }));
jest.mock('@edx/frontend-platform/i18n', () => {
const utils = jest.requireActual('@edx/react-unit-test-utils/dist');
return {
useIntl: () => ({ formatMessage: utils.formatMessage }),
defineMessages: m => m,
};
});
jest.mock('../../../../generic/PageLoading', () => 'PageLoading');
jest.mock('../../bookmark/BookmarkButton', () => 'BookmarkButton');
jest.mock('./ContentIFrame', () => 'ContentIFrame');
jest.mock('./UnitSuspense', () => 'UnitSuspense');
jest.mock('../honor-code', () => 'HonorCode');
jest.mock('../lock-paywall', () => 'LockPaywall');
jest.mock('../../../../generic/model-store', () => ({
useModel: jest.fn(),
}));
jest.mock('react', () => ({
...jest.requireActual('react'),
useContext: jest.fn(v => v),
}));
jest.mock('./hooks', () => ({
useExamAccess: jest.fn(),
useShouldDisplayHonorCode: jest.fn(),
}));
jest.mock('./urls', () => ({
getIFrameUrl: jest.fn(),
}));
const props = {
courseId: 'test-course-id',
format: 'test-format',
onLoaded: jest.fn().mockName('props.onLoaded'),
id: 'test-props-id',
};
const context = { authenticatedUser: { test: 'user' } };
React.useContext.mockReturnValue(context);
const examAccess = {
accessToken: 'test-token',
blockAccess: false,
};
hooks.useExamAccess.mockReturnValue(examAccess);
hooks.useShouldDisplayHonorCode.mockReturnValue(false);
const unit = {
id: 'unit-id',
title: 'unit-title',
bookmarked: false,
bookmarkedUpdateState: 'pending',
};
useModel.mockReturnValue(unit);
let el;
describe('Unit component', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<Unit {...props} />);
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(hooks.useShouldDisplayHonorCode).toHaveBeenCalledWith({
courseId: props.courseId,
id: props.id,
});
});
});
describe('output', () => {
let component;
test('snapshot: not bookmarked, do not show content', () => {
el = shallow(<Unit {...props} />);
expect(el.snapshot).toMatchSnapshot();
});
describe('BookmarkButton props', () => {
const renderComponent = () => {
el = shallow(<Unit {...props} />);
[component] = el.instance.findByType(BookmarkButton);
};
describe('not bookmarked, bookmark update loading', () => {
beforeEach(() => {
useModel.mockReturnValueOnce({ ...unit, bookmarkedUpdateState: 'loading' });
renderComponent();
});
test('snapshot', () => {
expect(component.snapshot).toMatchSnapshot();
});
test('props', () => {
expect(component.props.isBookmarked).toEqual(false);
expect(component.props.isProcessing).toEqual(true);
expect(component.props.unitId).toEqual(unit.id);
});
});
describe('bookmarked, bookmark update pending', () => {
beforeEach(() => {
useModel.mockReturnValueOnce({ ...unit, bookmarked: true });
renderComponent();
});
test('snapshot', () => {
expect(component.snapshot).toMatchSnapshot();
});
test('props', () => {
expect(component.props.isBookmarked).toEqual(true);
expect(component.props.isProcessing).toEqual(false);
expect(component.props.unitId).toEqual(unit.id);
});
});
});
test('UnitSuspense props', () => {
el = shallow(<Unit {...props} />);
[component] = el.instance.findByType(UnitSuspense);
expect(component.props.courseId).toEqual(props.courseId);
expect(component.props.id).toEqual(props.id);
});
describe('ContentIFrame props', () => {
const testComponentProps = () => {
expect(component.props.elementId).toEqual('unit-iframe');
expect(component.props.id).toEqual(props.id);
expect(component.props.loadingMessage).toEqual(formatMessage(messages.loadingSequence));
expect(component.props.onLoaded).toEqual(props.onLoaded);
expect(component.props.title).toEqual(unit.title);
};
const loadComponent = () => {
el = shallow(<Unit {...props} />);
[component] = el.instance.findByType(ContentIFrame);
};
describe('shouldShowContent', () => {
test('do not show content if displaying honor code', () => {
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(true);
loadComponent();
testComponentProps();
expect(component.props.shouldShowContent).toEqual(false);
});
test('do not show content if examAccess is blocked', () => {
hooks.useExamAccess.mockReturnValueOnce({ ...examAccess, blockAccess: true });
loadComponent();
testComponentProps();
expect(component.props.shouldShowContent).toEqual(false);
});
test('show content if not displaying honor code or blocked by exam access', () => {
loadComponent();
testComponentProps();
expect(component.props.shouldShowContent).toEqual(true);
});
});
describe('iframeUrl', () => {
test('loads iframe url with student view if authenticated user', () => {
loadComponent();
testComponentProps();
expect(component.props.iframeUrl).toEqual(getIFrameUrl({
id: props.id,
view: views.student,
format: props.format,
examAccess,
}));
});
test('loads iframe url with public view if no authenticated user', () => {
React.useContext.mockReturnValueOnce({});
loadComponent();
testComponentProps();
expect(component.props.iframeUrl).toEqual(getIFrameUrl({
id: props.id,
view: views.public,
format: props.format,
examAccess,
}));
});
});
});
});
});

View File

@@ -1,28 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { stringify } from 'query-string';
export const iframeParams = {
show_title: 0,
show_bookmark: 0,
recheck_access: 1,
};
export const getIFrameUrl = ({
id,
view,
format,
examAccess,
}) => {
const xblockUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}`;
const params = stringify({
...iframeParams,
view,
...(format && { format }),
...(!examAccess.blockAccess && { exam_access: examAccess.accessToken }),
});
return `${xblockUrl}?${params}`;
};
export default {
getIFrameUrl,
};

View File

@@ -1,42 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { stringify } from 'query-string';
import { getIFrameUrl, iframeParams } from './urls';
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
jest.mock('query-string', () => ({
stringify: jest.fn((...args) => ({ stringify: args })),
}));
const config = { LMS_BASE_URL: 'test-lms-url' };
getConfig.mockReturnValue(config);
const props = {
id: 'test-id',
view: 'test-view',
format: 'test-format',
examAccess: { blockAccess: false, accessToken: 'test-access-token' },
};
describe('urls module', () => {
describe('getIFrameUrl', () => {
test('format provided, exam access and token available', () => {
const params = stringify({
...iframeParams,
view: props.view,
format: props.format,
exam_access: props.examAccess.accessToken,
});
expect(getIFrameUrl(props)).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
});
test('no format provided, exam access blocked', () => {
const params = stringify({ ...iframeParams, view: props.view });
expect(getIFrameUrl({
id: props.id,
view: props.view,
examAccess: { blockAccess: true },
})).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
});
});
});

View File

@@ -1,9 +1,9 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useNavigate } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock } from '@fortawesome/free-solid-svg-icons'; import { faLock } from '@fortawesome/free-solid-svg-icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { history } from '@edx/frontend-platform';
import { Button } from '@edx/paragon'; import { Button } from '@edx/paragon';
import messages from './messages'; import messages from './messages';
@@ -11,9 +11,8 @@ import messages from './messages';
const ContentLock = ({ const ContentLock = ({
intl, courseId, prereqSectionName, prereqId, sequenceTitle, intl, courseId, prereqSectionName, prereqId, sequenceTitle,
}) => { }) => {
const navigate = useNavigate();
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
navigate(`/course/${courseId}/${prereqId}`); history.push(`/course/${courseId}/${prereqId}`);
}, [courseId, prereqId]); }, [courseId, prereqId]);
return ( return (

View File

@@ -1,16 +1,10 @@
import React from 'react'; import React from 'react';
import { history } from '@edx/frontend-platform';
import { import {
render, screen, fireEvent, initializeMockApp, render, screen, fireEvent, initializeMockApp,
} from '../../../../setupTest'; } from '../../../../setupTest';
import ContentLock from './ContentLock'; import ContentLock from './ContentLock';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
describe('Content Lock', () => { describe('Content Lock', () => {
const mockData = { const mockData = {
courseId: 'test-course-id', courseId: 'test-course-id',
@@ -25,7 +19,7 @@ describe('Content Lock', () => {
}); });
it('displays sequence title along with lock icon', () => { it('displays sequence title along with lock icon', () => {
const { container } = render(<ContentLock {...mockData} />, { wrapWithRouter: true }); const { container } = render(<ContentLock {...mockData} />);
const lockIcon = container.querySelector('svg'); const lockIcon = container.querySelector('svg');
expect(lockIcon).toHaveClass('fa-lock'); expect(lockIcon).toHaveClass('fa-lock');
@@ -34,15 +28,16 @@ describe('Content Lock', () => {
it('displays prerequisite name', () => { it('displays prerequisite name', () => {
const prereqText = `You must complete the prerequisite: '${mockData.prereqSectionName}' to access this content.`; const prereqText = `You must complete the prerequisite: '${mockData.prereqSectionName}' to access this content.`;
render(<ContentLock {...mockData} />, { wrapWithRouter: true }); render(<ContentLock {...mockData} />);
expect(screen.getByText(prereqText)).toBeInTheDocument(); expect(screen.getByText(prereqText)).toBeInTheDocument();
}); });
it('handles click', () => { it('handles click', () => {
render(<ContentLock {...mockData} />, { wrapWithRouter: true }); history.push = jest.fn();
render(<ContentLock {...mockData} />);
fireEvent.click(screen.getByRole('button')); fireEvent.click(screen.getByRole('button'));
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`); expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
}); });
}); });

View File

@@ -1,18 +1,16 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { getConfig } from '@edx/frontend-platform'; import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ActionRow, Alert, Button } from '@edx/paragon'; import { ActionRow, Alert, Button } from '@edx/paragon';
import { useNavigate } from 'react-router-dom';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
import { saveIntegritySignature } from '../../../data'; import { saveIntegritySignature } from '../../../data';
import messages from './messages'; import messages from './messages';
const HonorCode = ({ intl, courseId }) => { const HonorCode = ({ intl, courseId }) => {
const navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { const {
isMasquerading, isMasquerading,
@@ -22,7 +20,7 @@ const HonorCode = ({ intl, courseId }) => {
const siteName = getConfig().SITE_NAME; const siteName = getConfig().SITE_NAME;
const honorCodeUrl = `${getConfig().TERMS_OF_SERVICE_URL}#honor-code`; const honorCodeUrl = `${getConfig().TERMS_OF_SERVICE_URL}#honor-code`;
const handleCancel = () => navigate(`/course/${courseId}/home`); const handleCancel = () => history.push(`/course/${courseId}/home`);
const handleAgree = () => dispatch( const handleAgree = () => dispatch(
// If the request is made by a staff user masquerading as a specific learner, // If the request is made by a staff user masquerading as a specific learner,

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { getConfig } from '@edx/frontend-platform'; import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie'; import { Factory } from 'rosie';
@@ -9,12 +9,12 @@ import {
} from '../../../../setupTest'; } from '../../../../setupTest';
import HonorCode from './HonorCode'; import HonorCode from './HonorCode';
const mockNavigate = jest.fn();
initializeMockApp(); initializeMockApp();
jest.mock('react-router-dom', () => ({ jest.mock('@edx/frontend-platform', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('@edx/frontend-platform'),
useNavigate: () => mockNavigate, history: {
push: jest.fn(),
},
})); }));
describe('Honor Code', () => { describe('Honor Code', () => {
@@ -38,15 +38,15 @@ describe('Honor Code', () => {
it('cancel button links to course home ', async () => { it('cancel button links to course home ', async () => {
await setupStoreState(); await setupStoreState();
render(<HonorCode {...mockData} />, { wrapWithRouter: true }); render(<HonorCode {...mockData} />);
const cancelButton = screen.getByText('Cancel'); const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton); fireEvent.click(cancelButton);
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`); expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
}); });
it('calls to save integrity_signature when agreeing', async () => { it('calls to save integrity_signature when agreeing', async () => {
await setupStoreState({ username: authenticatedUser.username }); await setupStoreState({ username: authenticatedUser.username });
render(<HonorCode {...mockData} />, { wrapWithRouter: true }); render(<HonorCode {...mockData} />);
const agreeButton = screen.getByText('I agree'); const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton); fireEvent.click(agreeButton);
await waitFor(() => { await waitFor(() => {
@@ -63,7 +63,7 @@ describe('Honor Code', () => {
username: authenticatedUser.username, username: authenticatedUser.username,
}, },
); );
render(<HonorCode {...mockData} />, { wrapWithRouter: true }); render(<HonorCode {...mockData} />);
const agreeButton = screen.getByText('I agree'); const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton); fireEvent.click(agreeButton);
await waitFor(() => { await waitFor(() => {
@@ -80,7 +80,7 @@ describe('Honor Code', () => {
username: 'otheruser', username: 'otheruser',
}, },
); );
render(<HonorCode {...mockData} />, { wrapWithRouter: true }); render(<HonorCode {...mockData} />);
const agreeButton = screen.getByText('I agree'); const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton); fireEvent.click(agreeButton);
await waitFor(() => { await waitFor(() => {

View File

@@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { breakpoints, Button, useWindowSize } from '@edx/paragon'; import { breakpoints, Button, useWindowSize } from '@edx/paragon';
import { ChevronLeft, ChevronRight } from '@edx/paragon/icons'; import { ChevronLeft, ChevronRight } from '@edx/paragon/icons';
@@ -27,13 +26,12 @@ const SequenceNavigation = ({
sequenceId, sequenceId,
className, className,
onNavigate, onNavigate,
nextHandler, nextSequenceHandler,
previousHandler, previousSequenceHandler,
goToCourseExitPage,
}) => { }) => {
const sequence = useModel('sequences', sequenceId); const sequence = useModel('sequences', sequenceId);
const { const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
isFirstUnit, isLastUnit, nextLink, previousLink,
} = useSequenceNavigationMetadata(sequenceId, unitId);
const { const {
courseId, courseId,
sequenceStatus, sequenceStatus,
@@ -65,51 +63,30 @@ const SequenceNavigation = ({
); );
}; };
const renderPreviousButton = () => {
const disabled = isFirstUnit;
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
return (
<Button
variant="link"
className="previous-btn"
onClick={previousHandler}
disabled={disabled}
iconBefore={prevArrow}
as={disabled ? undefined : Link}
to={disabled ? undefined : previousLink}
>
{shouldDisplayNotificationTriggerInSequence ? null : intl.formatMessage(messages.previousButton)}
</Button>
);
};
const renderNextButton = () => { const renderNextButton = () => {
const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl); const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl);
const buttonOnClick = isLastUnit ? goToCourseExitPage : nextSequenceHandler;
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton); const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
const disabled = isLastUnit && !exitActive; const disabled = isLastUnit && !exitActive;
const nextArrow = isRtl(getLocale()) ? ChevronLeft : ChevronRight; const nextArrow = isRtl(getLocale()) ? ChevronLeft : ChevronRight;
return ( return (
<Button <Button variant="link" className="next-btn" onClick={buttonOnClick} disabled={disabled} iconAfter={nextArrow}>
variant="link"
className="next-btn"
onClick={nextHandler}
disabled={disabled}
iconAfter={nextArrow}
as={disabled ? undefined : Link}
to={disabled ? undefined : nextLink}
>
{shouldDisplayNotificationTriggerInSequence ? null : buttonText} {shouldDisplayNotificationTriggerInSequence ? null : buttonText}
</Button> </Button>
); );
}; };
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
return sequenceStatus === LOADED && ( return sequenceStatus === LOADED && (
<nav id="courseware-sequenceNavigation" className={classNames('sequence-navigation', className, { 'mr-2': shouldDisplayNotificationTriggerInSequence })}> <nav id="courseware-sequenceNavigation" className={classNames('sequence-navigation', className)} style={{ width: shouldDisplayNotificationTriggerInSequence ? '90%' : null }}>
{renderPreviousButton()} <Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit} iconBefore={prevArrow}>
{shouldDisplayNotificationTriggerInSequence ? null : intl.formatMessage(messages.previousButton)}
</Button>
{renderUnitButtons()} {renderUnitButtons()}
{renderNextButton()} {renderNextButton()}
</nav> </nav>
); );
}; };
@@ -120,8 +97,9 @@ SequenceNavigation.propTypes = {
unitId: PropTypes.string, unitId: PropTypes.string,
className: PropTypes.string, className: PropTypes.string,
onNavigate: PropTypes.func.isRequired, onNavigate: PropTypes.func.isRequired,
nextHandler: PropTypes.func.isRequired, nextSequenceHandler: PropTypes.func.isRequired,
previousHandler: PropTypes.func.isRequired, previousSequenceHandler: PropTypes.func.isRequired,
goToCourseExitPage: PropTypes.func.isRequired,
}; };
SequenceNavigation.defaultProps = { SequenceNavigation.defaultProps = {

View File

@@ -25,21 +25,22 @@ describe('Sequence Navigation', () => {
mockData = { mockData = {
unitId: unitBlocks[1].id, unitId: unitBlocks[1].id,
sequenceId: courseware.sequenceId, sequenceId: courseware.sequenceId,
previousHandler: () => {}, previousSequenceHandler: () => {},
onNavigate: () => {}, onNavigate: () => {},
nextHandler: () => {}, nextSequenceHandler: () => {},
goToCourseExitPage: () => {},
}; };
}); });
it('is empty while loading', async () => { it('is empty while loading', async () => {
const testStore = await initializeTestStore({ excludeFetchSequence: true }, false); const testStore = await initializeTestStore({ excludeFetchSequence: true }, false);
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore, wrapWithRouter: true }); const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore });
expect(container).toBeEmptyDOMElement(); expect(container).toBeEmptyDOMElement();
}); });
it('renders empty div without unitId', () => { it('renders empty div without unitId', () => {
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />, { wrapWithRouter: true }); const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />);
expect(getByText(container, (content, element) => ( expect(getByText(container, (content, element) => (
element.tagName.toLowerCase() === 'div' && element.getAttribute('style')))).toBeEmptyDOMElement(); element.tagName.toLowerCase() === 'div' && element.getAttribute('style')))).toBeEmptyDOMElement();
}); });
@@ -61,7 +62,7 @@ describe('Sequence Navigation', () => {
sequenceId: sequenceBlocks[0].id, sequenceId: sequenceBlocks[0].id,
onNavigate: jest.fn(), onNavigate: jest.fn(),
}; };
render(<SequenceNavigation {...testData} />, { store: testStore, wrapWithRouter: true }); render(<SequenceNavigation {...testData} />, { store: testStore });
const unitButton = screen.getByTitle(unitBlocks[1].display_name); const unitButton = screen.getByTitle(unitBlocks[1].display_name);
fireEvent.click(unitButton); fireEvent.click(unitButton);
@@ -74,27 +75,27 @@ describe('Sequence Navigation', () => {
it('renders correctly and handles unit button clicks', () => { it('renders correctly and handles unit button clicks', () => {
const onNavigate = jest.fn(); const onNavigate = jest.fn();
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />, { wrapWithRouter: true }); render(<SequenceNavigation {...mockData} {...{ onNavigate }} />);
const unitButtons = screen.getAllByRole('link', { name: /\d+/ }); const unitButtons = screen.getAllByRole('button', { name: /\d+/ });
expect(unitButtons).toHaveLength(unitButtons.length); expect(unitButtons).toHaveLength(unitButtons.length);
unitButtons.forEach(button => fireEvent.click(button)); unitButtons.forEach(button => fireEvent.click(button));
expect(onNavigate).toHaveBeenCalledTimes(unitButtons.length); expect(onNavigate).toHaveBeenCalledTimes(unitButtons.length);
}); });
it('has both navigation buttons enabled for a non-corner unit of the sequence', () => { it('has both navigation buttons enabled for a non-corner unit of the sequence', () => {
render(<SequenceNavigation {...mockData} />, { wrapWithRouter: true }); render(<SequenceNavigation {...mockData} />);
screen.getAllByRole('link', { name: /previous|next/i }).forEach(button => { screen.getAllByRole('button', { name: /previous|next/i }).forEach(button => {
expect(button).toBeEnabled(); expect(button).toBeEnabled();
}); });
}); });
it('has the "Previous" button disabled for the first unit of the sequence', () => { it('has the "Previous" button disabled for the first unit of the sequence', () => {
render(<SequenceNavigation {...mockData} unitId={unitBlocks[0].id} />, { wrapWithRouter: true }); render(<SequenceNavigation {...mockData} unitId={unitBlocks[0].id} />);
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled(); expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled(); expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
}); });
it('has the "Next" button disabled for the last unit of the sequence if there is no Exit page', async () => { it('has the "Next" button disabled for the last unit of the sequence if there is no Exit page', async () => {
@@ -106,10 +107,10 @@ describe('Sequence Navigation', () => {
render( render(
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />, <SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true }, { store: testStore },
); );
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled(); expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
}); });
@@ -122,11 +123,11 @@ describe('Sequence Navigation', () => {
render( render(
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />, <SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true }, { store: testStore },
); );
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled(); expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /next \(end of course\)/i })).toBeEnabled(); expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
}); });
it('displays complete course message instead of the "Next" button as needed', async () => { it('displays complete course message instead of the "Next" button as needed', async () => {
@@ -143,22 +144,22 @@ describe('Sequence Navigation', () => {
render( render(
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />, <SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true }, { store: testStore },
); );
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled(); expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /Complete the course/i })).toBeEnabled(); expect(screen.getByRole('button', { name: /Complete the course/i })).toBeEnabled();
}); });
it('handles "Previous" and "Next" click', () => { it('handles "Previous" and "Next" click', () => {
const previousHandler = jest.fn(); const previousSequenceHandler = jest.fn();
const nextHandler = jest.fn(); const nextSequenceHandler = jest.fn();
render(<SequenceNavigation {...mockData} {...{ previousHandler, nextHandler }} />, { wrapWithRouter: true }); render(<SequenceNavigation {...mockData} {...{ previousSequenceHandler, nextSequenceHandler }} />);
fireEvent.click(screen.getByRole('link', { name: /previous/i })); fireEvent.click(screen.getByRole('button', { name: /previous/i }));
expect(previousHandler).toHaveBeenCalledTimes(1); expect(previousSequenceHandler).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('link', { name: /next/i })); fireEvent.click(screen.getByRole('button', { name: /next/i }));
expect(nextHandler).toHaveBeenCalledTimes(1); expect(nextSequenceHandler).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@@ -40,17 +40,14 @@ describe('Sequence Navigation Dropdown', () => {
unitBlocks.forEach((unit, index) => { unitBlocks.forEach((unit, index) => {
it(`marks unit ${index + 1} as active`, async () => { it(`marks unit ${index + 1} as active`, async () => {
const { container } = render( const { container } = render(<SequenceNavigationDropdown {...mockData} unitId={unit.id} />);
<SequenceNavigationDropdown {...mockData} unitId={unit.id} />,
{ wrapWithRouter: true },
);
const dropdownToggle = container.querySelector('.dropdown-toggle'); const dropdownToggle = container.querySelector('.dropdown-toggle');
await act(async () => { await act(async () => {
await fireEvent.click(dropdownToggle); await fireEvent.click(dropdownToggle);
}); });
const dropdownMenu = container.querySelector('.dropdown-menu'); const dropdownMenu = container.querySelector('.dropdown-menu');
// Only the current unit should be marked as active. // Only the current unit should be marked as active.
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => { getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => {
if (button.textContent === unit.display_name) { if (button.textContent === unit.display_name) {
expect(button).toHaveClass('active'); expect(button).toHaveClass('active');
} else { } else {
@@ -62,17 +59,14 @@ describe('Sequence Navigation Dropdown', () => {
it('handles the clicks', () => { it('handles the clicks', () => {
const onNavigate = jest.fn(); const onNavigate = jest.fn();
const { container } = render( const { container } = render(<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />);
<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />,
{ wrapWithRouter: true },
);
const dropdownToggle = container.querySelector('.dropdown-toggle'); const dropdownToggle = container.querySelector('.dropdown-toggle');
act(() => { act(() => {
fireEvent.click(dropdownToggle); fireEvent.click(dropdownToggle);
}); });
const dropdownMenu = container.querySelector('.dropdown-menu'); const dropdownMenu = container.querySelector('.dropdown-menu');
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => fireEvent.click(button)); getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => fireEvent.click(button));
expect(onNavigate).toHaveBeenCalledTimes(unitBlocks.length); expect(onNavigate).toHaveBeenCalledTimes(unitBlocks.length);
unitBlocks.forEach((unit, index) => { unitBlocks.forEach((unit, index) => {
expect(onNavigate).toHaveBeenNthCalledWith(index + 1, unit.id); expect(onNavigate).toHaveBeenNthCalledWith(index + 1, unit.id);

View File

@@ -41,16 +41,16 @@ describe('Sequence Navigation Tabs', () => {
it('renders unit buttons', () => { it('renders unit buttons', () => {
useIndexOfLastVisibleChild.mockReturnValue([0, null, null]); useIndexOfLastVisibleChild.mockReturnValue([0, null, null]);
render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true }); render(<SequenceNavigationTabs {...mockData} />);
expect(screen.getAllByRole('link')).toHaveLength(unitBlocks.length); expect(screen.getAllByRole('button')).toHaveLength(unitBlocks.length);
}); });
it('renders unit buttons and dropdown button', async () => { it('renders unit buttons and dropdown button', async () => {
let container = null; let container = null;
await act(async () => { await act(async () => {
useIndexOfLastVisibleChild.mockReturnValue([-1, null, null]); useIndexOfLastVisibleChild.mockReturnValue([-1, null, null]);
const booyah = render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true }); const booyah = render(<SequenceNavigationTabs {...mockData} />);
container = booyah.container; container = booyah.container;
const dropdownToggle = container.querySelector('.dropdown-toggle'); const dropdownToggle = container.querySelector('.dropdown-toggle');
@@ -60,8 +60,8 @@ describe('Sequence Navigation Tabs', () => {
await fireEvent.click(dropdownToggle); await fireEvent.click(dropdownToggle);
}); });
const dropdownMenu = container.querySelector('.dropdown'); const dropdownMenu = container.querySelector('.dropdown');
const dropdownButtons = getAllByRole(dropdownMenu, 'link'); const dropdownButtons = getAllByRole(dropdownMenu, 'button');
expect(dropdownButtons).toHaveLength(unitBlocks.length); expect(dropdownButtons).toHaveLength(unitBlocks.length + 1);
expect(screen.getByRole('button', { name: `${activeBlockNumber} of ${unitBlocks.length}` })) expect(screen.getByRole('button', { name: `${activeBlockNumber} of ${unitBlocks.length}` }))
.toHaveClass('dropdown-toggle'); .toHaveClass('dropdown-toggle');
}); });

View File

@@ -1,7 +1,6 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { connect, useSelector } from 'react-redux'; import { connect } from 'react-redux';
import classNames from 'classnames'; import classNames from 'classnames';
import { Button } from '@edx/paragon'; import { Button } from '@edx/paragon';
@@ -21,8 +20,6 @@ const UnitButton = ({
className, className,
showTitle, showTitle,
}) => { }) => {
const { courseId, sequenceId } = useSelector(state => state.courseware);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
onClick(unitId); onClick(unitId);
}, [onClick, unitId]); }, [onClick, unitId]);
@@ -36,8 +33,6 @@ const UnitButton = ({
variant="link" variant="link"
onClick={handleClick} onClick={handleClick}
title={title} title={title}
as={Link}
to={`/course/${courseId}/${sequenceId}/${unitId}`}
> >
<UnitIcon type={contentType} /> <UnitIcon type={contentType} />
{showTitle && <span className="unit-title">{title}</span>} {showTitle && <span className="unit-title">{title}</span>}

View File

@@ -32,13 +32,13 @@ describe('Unit Button', () => {
}); });
it('hides title by default', () => { it('hides title by default', () => {
render(<UnitButton {...mockData} />, { wrapWithRouter: true }); render(<UnitButton {...mockData} />);
expect(screen.getByRole('link')).not.toHaveTextContent(unit.display_name); expect(screen.getByRole('button')).not.toHaveTextContent(unit.display_name);
}); });
it('shows title', () => { it('shows title', () => {
render(<UnitButton {...mockData} showTitle />, { wrapWithRouter: true }); render(<UnitButton {...mockData} showTitle />);
expect(screen.getByRole('link')).toHaveTextContent(unit.display_name); expect(screen.getByRole('button')).toHaveTextContent(unit.display_name);
}); });
it('does not show completion for non-completed unit', () => { it('does not show completion for non-completed unit', () => {
@@ -49,7 +49,7 @@ describe('Unit Button', () => {
}); });
it('shows completion for completed unit', () => { it('shows completion for completed unit', () => {
const { container } = render(<UnitButton {...mockData} unitId={completedUnit.id} />, { wrapWithRouter: true }); const { container } = render(<UnitButton {...mockData} unitId={completedUnit.id} />);
const buttonIcons = container.querySelectorAll('svg'); const buttonIcons = container.querySelectorAll('svg');
expect(buttonIcons).toHaveLength(2); expect(buttonIcons).toHaveLength(2);
expect(buttonIcons[1]).toHaveClass('fa-check'); expect(buttonIcons[1]).toHaveClass('fa-check');
@@ -70,7 +70,7 @@ describe('Unit Button', () => {
}); });
it('shows bookmark', () => { it('shows bookmark', () => {
const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />, { wrapWithRouter: true }); const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />);
const buttonIcons = container.querySelectorAll('svg'); const buttonIcons = container.querySelectorAll('svg');
expect(buttonIcons).toHaveLength(3); expect(buttonIcons).toHaveLength(3);
expect(buttonIcons[2]).toHaveClass('fa-bookmark'); expect(buttonIcons[2]).toHaveClass('fa-bookmark');
@@ -78,8 +78,8 @@ describe('Unit Button', () => {
it('handles the click', () => { it('handles the click', () => {
const onClick = jest.fn(); const onClick = jest.fn();
render(<UnitButton {...mockData} onClick={onClick} />, { wrapWithRouter: true }); render(<UnitButton {...mockData} onClick={onClick} />);
fireEvent.click(screen.getByRole('link')); fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1); expect(onClick).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Button } from '@edx/paragon'; import { Button } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -21,32 +20,14 @@ const UnitNavigation = ({
unitId, unitId,
onClickPrevious, onClickPrevious,
onClickNext, onClickNext,
goToCourseExitPage,
}) => { }) => {
const { const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
isFirstUnit, isLastUnit, nextLink, previousLink,
} = useSequenceNavigationMetadata(sequenceId, unitId);
const { courseId } = useSelector(state => state.courseware); const { courseId } = useSelector(state => state.courseware);
const renderPreviousButton = () => {
const disabled = isFirstUnit;
const prevArrow = isRtl(getLocale()) ? faChevronRight : faChevronLeft;
return (
<Button
variant="outline-secondary"
className="previous-button mr-2 d-flex align-items-center justify-content-center"
disabled={disabled}
onClick={onClickPrevious}
as={disabled ? undefined : Link}
to={disabled ? undefined : previousLink}
>
<FontAwesomeIcon icon={prevArrow} className="mr-2" size="sm" />
{intl.formatMessage(messages.previousButton)}
</Button>
);
};
const renderNextButton = () => { const renderNextButton = () => {
const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl); const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl);
const buttonOnClick = isLastUnit ? goToCourseExitPage : onClickNext;
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton); const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
const disabled = isLastUnit && !exitActive; const disabled = isLastUnit && !exitActive;
const nextArrow = isRtl(getLocale()) ? faChevronLeft : faChevronRight; const nextArrow = isRtl(getLocale()) ? faChevronLeft : faChevronRight;
@@ -54,10 +35,8 @@ const UnitNavigation = ({
<Button <Button
variant="outline-primary" variant="outline-primary"
className="next-button d-flex align-items-center justify-content-center" className="next-button d-flex align-items-center justify-content-center"
onClick={onClickNext} onClick={buttonOnClick}
disabled={disabled} disabled={disabled}
as={disabled ? undefined : Link}
to={disabled ? undefined : nextLink}
> >
<UnitNavigationEffortEstimate sequenceId={sequenceId} unitId={unitId}> <UnitNavigationEffortEstimate sequenceId={sequenceId} unitId={unitId}>
{buttonText} {buttonText}
@@ -67,9 +46,18 @@ const UnitNavigation = ({
); );
}; };
const prevArrow = isRtl(getLocale()) ? faChevronRight : faChevronLeft;
return ( return (
<div className="unit-navigation d-flex"> <div className="unit-navigation d-flex">
{renderPreviousButton()} <Button
variant="outline-secondary"
className="previous-button mr-2 d-flex align-items-center justify-content-center"
disabled={isFirstUnit}
onClick={onClickPrevious}
>
<FontAwesomeIcon icon={prevArrow} className="mr-2" size="sm" />
{intl.formatMessage(messages.previousButton)}
</Button>
{renderNextButton()} {renderNextButton()}
</div> </div>
); );
@@ -81,6 +69,7 @@ UnitNavigation.propTypes = {
unitId: PropTypes.string, unitId: PropTypes.string,
onClickPrevious: PropTypes.func.isRequired, onClickPrevious: PropTypes.func.isRequired,
onClickNext: PropTypes.func.isRequired, onClickNext: PropTypes.func.isRequired,
goToCourseExitPage: PropTypes.func.isRequired,
}; };
UnitNavigation.defaultProps = { UnitNavigation.defaultProps = {

View File

@@ -22,6 +22,7 @@ describe('Unit Navigation', () => {
sequenceId: courseware.sequenceId, sequenceId: courseware.sequenceId,
onClickPrevious: () => {}, onClickPrevious: () => {},
onClickNext: () => {}, onClickNext: () => {},
goToCourseExitPage: () => {},
}; };
}); });
@@ -32,10 +33,10 @@ describe('Unit Navigation', () => {
unitId="" unitId=""
onClickPrevious={() => {}} onClickPrevious={() => {}}
onClickNext={() => {}} onClickNext={() => {}}
/>, { wrapWithRouter: true }); />);
// Only "Previous" and "Next" buttons should be rendered. // Only "Previous" and "Next" buttons should be rendered.
expect(screen.getAllByRole('link')).toHaveLength(2); expect(screen.getAllByRole('button')).toHaveLength(2);
}); });
it('handles the clicks', () => { it('handles the clicks', () => {
@@ -44,30 +45,32 @@ describe('Unit Navigation', () => {
render(<UnitNavigation render(<UnitNavigation
{...mockData} {...mockData}
sequenceId=""
unitId=""
onClickPrevious={onClickPrevious} onClickPrevious={onClickPrevious}
onClickNext={onClickNext} onClickNext={onClickNext}
/>, { wrapWithRouter: true }); />);
fireEvent.click(screen.getByRole('link', { name: /previous/i })); fireEvent.click(screen.getByRole('button', { name: /previous/i }));
expect(onClickPrevious).toHaveBeenCalledTimes(1); expect(onClickPrevious).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('link', { name: /next/i })); fireEvent.click(screen.getByRole('button', { name: /next/i }));
expect(onClickNext).toHaveBeenCalledTimes(1); expect(onClickNext).toHaveBeenCalledTimes(1);
}); });
it('has the navigation buttons enabled for the non-corner unit in the sequence', () => { it('has the navigation buttons enabled for the non-corner unit in the sequence', () => {
render(<UnitNavigation {...mockData} />, { wrapWithRouter: true }); render(<UnitNavigation {...mockData} />);
screen.getAllByRole('link').forEach(button => { screen.getAllByRole('button').forEach(button => {
expect(button).toBeEnabled(); expect(button).toBeEnabled();
}); });
}); });
it('has the "Previous" button disabled for the first unit in the sequence', () => { it('has the "Previous" button disabled for the first unit in the sequence', () => {
render(<UnitNavigation {...mockData} unitId={unitBlocks[0].id} />, { wrapWithRouter: true }); render(<UnitNavigation {...mockData} unitId={unitBlocks[0].id} />);
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled(); expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled(); expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
}); });
it('has the "Next" button disabled for the last unit in the sequence if there is no Exit Page', async () => { it('has the "Next" button disabled for the last unit in the sequence if there is no Exit Page', async () => {
@@ -79,10 +82,10 @@ describe('Unit Navigation', () => {
render( render(
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />, <UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true }, { store: testStore },
); );
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled(); expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
}); });
@@ -95,11 +98,11 @@ describe('Unit Navigation', () => {
render( render(
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />, <UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true }, { store: testStore },
); );
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled(); expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /next \(end of course\)/i })).toBeEnabled(); expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
}); });
it('displays complete course message instead of the "Next" button as needed', async () => { it('displays complete course message instead of the "Next" button as needed', async () => {
@@ -116,10 +119,10 @@ describe('Unit Navigation', () => {
render( render(
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />, <UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true }, { store: testStore },
); );
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled(); expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /Complete the course/i })).toBeEnabled(); expect(screen.getByRole('button', { name: /Complete the course/i })).toBeEnabled();
}); });
}); });

View File

@@ -7,7 +7,6 @@ import { sequenceIdsSelector } from '../../../data';
export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) { export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
const sequenceIds = useSelector(sequenceIdsSelector); const sequenceIds = useSelector(sequenceIdsSelector);
const sequence = useModel('sequences', currentSequenceId); const sequence = useModel('sequences', currentSequenceId);
const courseId = useSelector(state => state.courseware.courseId);
const courseStatus = useSelector(state => state.courseware.courseStatus); const courseStatus = useSelector(state => state.courseware.courseStatus);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus); const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
@@ -15,43 +14,12 @@ export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId)
if (courseStatus !== 'loaded' || sequenceStatus !== 'loaded' || !currentSequenceId || !currentUnitId) { if (courseStatus !== 'loaded' || sequenceStatus !== 'loaded' || !currentSequenceId || !currentUnitId) {
return { isFirstUnit: false, isLastUnit: false }; return { isFirstUnit: false, isLastUnit: false };
} }
const isFirstSequence = sequenceIds.indexOf(currentSequenceId) === 0;
const sequenceIndex = sequenceIds.indexOf(currentSequenceId); const isFirstUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === 0;
const unitIndex = sequence.unitIds.indexOf(currentUnitId);
const isFirstSequence = sequenceIndex === 0;
const isFirstUnitInSequence = unitIndex === 0;
const isFirstUnit = isFirstSequence && isFirstUnitInSequence; const isFirstUnit = isFirstSequence && isFirstUnitInSequence;
const isLastSequence = sequenceIndex === sequenceIds.length - 1; const isLastSequence = sequenceIds.indexOf(currentSequenceId) === sequenceIds.length - 1;
const isLastUnitInSequence = unitIndex === sequence.unitIds.length - 1; const isLastUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === sequence.unitIds.length - 1;
const isLastUnit = isLastSequence && isLastUnitInSequence; const isLastUnit = isLastSequence && isLastUnitInSequence;
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null; return { isFirstUnit, isLastUnit };
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
let nextLink;
if (isLastUnit) {
nextLink = `/course/${courseId}/course-end`;
} else {
const nextIndex = unitIndex + 1;
if (nextIndex < sequence.unitIds.length) {
const nextUnitId = sequence.unitIds[nextIndex];
nextLink = `/course/${courseId}/${currentSequenceId}/${nextUnitId}`;
} else if (nextSequenceId) {
nextLink = `/course/${courseId}/${nextSequenceId}/first`;
}
}
let previousLink;
const previousIndex = unitIndex - 1;
if (previousIndex >= 0) {
const previousUnitId = sequence.unitIds[previousIndex];
previousLink = `/course/${courseId}/${currentSequenceId}/${previousUnitId}`;
} else if (previousSequenceId) {
previousLink = `/course/${courseId}/${previousSequenceId}/last`;
}
return {
isFirstUnit, isLastUnit, nextLink, previousLink,
};
} }

View File

@@ -1,15 +1,18 @@
import React from 'react'; import React, { useContext } from 'react';
import { SIDEBAR_ORDER, SIDEBARS } from './sidebars'; import SidebarContext from './SidebarContext';
import { SIDEBARS } from './sidebars';
const Sidebar = () => ( const Sidebar = () => {
<> const {
{ currentSidebar,
SIDEBAR_ORDER.map((sideBarId) => { } = useContext(SidebarContext);
const SidebarToRender = SIDEBARS[sideBarId].Sidebar; if (!currentSidebar) {
return <SidebarToRender />; return null;
}) }
} const CurrentSidebar = SIDEBARS[currentSidebar].Sidebar;
</> return (
); <CurrentSidebar />
);
};
export default Sidebar; export default Sidebar;

View File

@@ -5,6 +5,8 @@ import React, {
} from 'react'; } from 'react';
import { getLocalStorage, setLocalStorage } from '../../../data/localStorage'; import { getLocalStorage, setLocalStorage } from '../../../data/localStorage';
import { getSessionStorage } from '../../../data/sessionStorage';
import { useModel } from '../../../generic/model-store';
import SidebarContext from './SidebarContext'; import SidebarContext from './SidebarContext';
import { SIDEBARS } from './sidebars'; import { SIDEBARS } from './sidebars';
@@ -13,18 +15,24 @@ const SidebarProvider = ({
unitId, unitId,
children, children,
}) => { }) => {
const { verifiedMode } = useModel('courseHomeMeta', courseId);
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth; const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth; const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
const query = new URLSearchParams(window.location.search); const showNotificationsOnLoad = getSessionStorage(`notificationTrayStatus.${courseId}`) !== 'closed';
const initialSidebar = (shouldDisplaySidebarOpen || query.get('sidebar') === 'true') ? SIDEBARS.DISCUSSIONS.ID : null; const initialSidebar = (verifiedMode && shouldDisplaySidebarOpen && showNotificationsOnLoad)
? SIDEBARS.NOTIFICATIONS.ID
: null;
const [currentSidebar, setCurrentSidebar] = useState(initialSidebar); const [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`)); const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`)); const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
useEffect(() => { useEffect(() => {
setCurrentSidebar(SIDEBARS.DISCUSSIONS.ID); // As a one-off set initial sidebar if the verified mode data has just loaded
if (verifiedMode && currentSidebar === null && initialSidebar) {
setCurrentSidebar(initialSidebar);
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [unitId]); }, [initialSidebar, verifiedMode]);
const onNotificationSeen = useCallback(() => { const onNotificationSeen = useCallback(() => {
setNotificationStatus('inactive'); setNotificationStatus('inactive');

View File

@@ -1,6 +1,5 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import { breakpoints, useWindowSize } from '@edx/paragon';
import SidebarContext from './SidebarContext'; import SidebarContext from './SidebarContext';
import { SIDEBAR_ORDER, SIDEBARS } from './sidebars'; import { SIDEBAR_ORDER, SIDEBARS } from './sidebars';
@@ -9,9 +8,6 @@ const SidebarTriggers = () => {
toggleSidebar, toggleSidebar,
currentSidebar, currentSidebar,
} = useContext(SidebarContext); } = useContext(SidebarContext);
const isMobileView = useWindowSize().width < breakpoints.small.minWidth;
return ( return (
<div className="d-flex ml-auto"> <div className="d-flex ml-auto">
{SIDEBAR_ORDER.map((sidebarId) => { {SIDEBAR_ORDER.map((sidebarId) => {
@@ -19,7 +15,7 @@ const SidebarTriggers = () => {
const isActive = sidebarId === currentSidebar; const isActive = sidebarId === currentSidebar;
return ( return (
<div <div
className={classNames({ 'mt-3': !isMobileView, 'border-primary-700': isActive })} className={classNames('mt-3', { 'border-primary-700': isActive })}
style={{ borderBottom: isActive ? '2px solid' : null }} style={{ borderBottom: isActive ? '2px solid' : null }}
key={sidebarId} key={sidebarId}
> >

View File

@@ -34,14 +34,12 @@ const SidebarBase = ({
useEventListener('message', receiveMessage); useEventListener('message', receiveMessage);
return ( return currentSidebar === sidebarId && (
<section <section
className={classNames('ml-0 ml-lg-4 border border-light-400 rounded-sm h-auto align-top', { className={classNames('ml-0 ml-lg-4 border border-light-400 rounded-sm h-auto align-top', {
'bg-white m-0 border-0 fixed-top vh-100 rounded-0': shouldDisplayFullScreen, 'bg-white m-0 border-0 fixed-top vh-100 rounded-0': shouldDisplayFullScreen,
'min-vh-100': !shouldDisplayFullScreen, 'min-vh-100': !shouldDisplayFullScreen,
'd-none': currentSidebar !== sidebarId,
}, className)} }, className)}
data-testid={`sidebar-${sidebarId}`}
style={{ width: shouldDisplayFullScreen ? '100%' : width }} style={{ width: shouldDisplayFullScreen ? '100%' : width }}
aria-label={ariaLabel} aria-label={ariaLabel}
> >

View File

@@ -8,7 +8,7 @@ const SidebarTriggerBase = ({
children, children,
}) => ( }) => (
<button <button
className="border border-light-400 bg-transparent align-items-center align-content-center d-flex notification-btn" className="border border-light-400 bg-transparent align-items-center align-content-center d-flex"
type="button" type="button"
onClick={onClick} onClick={onClick}
aria-label={ariaLabel} aria-label={ariaLabel}

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