Compare commits

..

31 Commits

Author SHA1 Message Date
mubbsharanwar
6162174be8 chore: upgrade frontend-component-header version
upgrade frontend-component-header version to enable show/hide username from header based on the flag

VAN-1804
2024-02-06 20:40:57 +05:00
alangsto
55e2332b00 feat: remove audit access to learning assistant chat (#1280)
* feat: remove audit access to la

* feat: remove audit access to la
2024-01-29 17:08:17 -05:00
Omar Al-Ithawi
25d746b7c8 feat: tutor-mfe compatiblilty for atlas pull (#1279)
- install atlas
 - remove `--filter` to pull all languages by default
 - use ATLAS_OPTIONS to allow custom `--filter`
 - include frontend-lib-special-exams in `atlas pull`
2024-01-29 12:40:36 -05:00
alangsto
e790e2636f feat: upgrade learning assistant to latest version (#1278) 2024-01-24 15:08:30 -05:00
alangsto
c428222125 Revert "feat: update learning-assistant version" (#1277) 2024-01-24 10:17:21 -05:00
alangsto
58365ba18e feat: update learning-assistant version (#1276) 2024-01-24 09:33:38 -05:00
Troy Sankey
ab87167052 fix: ignore failed outline API call when learner has no access
If the learner doesn't even have access to the course (e.g. because the
course starts in the future), don't worry about a 404 fetching the
course outline since we're not planning to use it anyway.

ENT-8078
2024-01-22 12:49:08 -08:00
sundasnoreen12
4928f505bd test: added test cases for new sidebar (#1267)
* test: added test cases for new sidebar

* test: added factory for verified user

* refactor: updated description for notification widget
2024-01-11 15:54:25 +05:00
alangsto
59a68afa5d feat: gate chat visibility by course end date (#1270) 2024-01-10 09:10:15 -05:00
Zachary Hancock
486faa5744 chore: update exams lib (#1268) 2024-01-08 13:56:01 -05:00
sundasnoreen12
023f5ac254 feat: added implementation for new sidebar (#1260)
* feat: added implementation for new sidebar

* test: fixed test cases

* refactor: fixed naming convention and combine useeffects

* refactor: improved sidebar UI and renamed sidebar flag

* refactor: remove additional states

* refactor: fixed UI and logic related issue

* refactor: simplified condition

* refactor: toggle sidebar action

* refactor: fixed toggle issues

* refactor: back arrow component

* refactor: changed useeffect position

---------

Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
2024-01-08 13:40:21 +05:00
Michael Roytman
4dc4725ba1 feat: pass unitId as a prop through to Xpert component (#1256)
This commit modifies the Course component to pass the unitId prop, which presents the unit usage key in which the Xpert is being rendered, to the Chat component. The Chat component passes this unit ID as a prop to the Xpert component.
2024-01-05 09:08:55 -05:00
Michael Roytman
600f5b4dff feat: add technical support URL in the LTI-based provider proctored exam download instructions (#1258)
This commit changes the installed version of the frontend-lib-special-exams module from 2.25.0 to 2.26.0.

This release adds support for displaying a technical support URL for LTI-based providers on the download instructions interstitial of proctored exams. The download instructions will display a technical support URL when it is returned from the proctoring settings backend endpoint. If the technical support URL is not available, then the technical support email and technical support phone number will be used instead.
2023-12-20 15:18:25 -05:00
German
06d79ce16d fix: prevent error when there are no results for a given filter (#1257) 2023-12-19 13:00:14 -03:00
German
b3a06fd07e feat: update filter naming on courseware search modal (#1255)
Update names to be one of the folow:
1. All Content
2. Text
3. Video
4. Section
5. Other
2023-12-18 17:01:02 -03:00
Zachary Hancock
684ac495f5 feat: update exams library (#1254) 2023-12-15 16:03:26 -05:00
Troy Sankey
79575330c9 feat: Add redirect to enterprise learner dashboard (#1251)
When a learner tries to load the B2C course page for a course that
starts in the future (error_code="course_not_started"), normally the
learning MFE automatically redirects to the B2C dashboard.  This change
supports an alternate error_code "course_not_started_enterprise_learner"
to trigger an alternative redirect to the B2B (enterprise) learner
dashboard.

This does two main things:

1. When the course metadata API response indicates
   course_access.error_code = "course_not_started_enterprise_learner"
   then redirect to "/redirect/enterprise-learner-dashboard".
2. When the top-level router matches path "/redirect/enterprise-learner-dashboard"
   then redirec to to the value of config `ENTERPRISE_LEARNER_PORTAL_URL`.

ENT-8078
2023-12-15 07:45:36 -05:00
Marcos
2bf326fc67 UI search UI a11y changes (#1253)
* fix: Updated UI a11y changes in Courseware Search

* feat: Removed result count for search

* fix: Added new line at the end of course-tabs-navigation.scss
2023-12-15 09:23:34 -03:00
German
e004ead21d bug: fix errors messages when results are empty (#1252)
https://2u-internal.atlassian.net/browse/KBK-80
2023-12-12 16:59:25 -03:00
German
4d723335bf feat: use state params to keep query and filter while searching (#1249)
* feat: use state params to keep query and filter while searching

* feat: Minor factors renaming methods

* feat: fix tests

* feat: Only change url when hit search button

* feat: fix tests
2023-12-11 10:45:13 -03:00
mashal-m
d065c23e32 refactor: add @openedx in renovate automate configuration 2023-12-05 14:18:41 -05:00
alangsto
83c600737b feat: remove segment event (#1250) 2023-12-05 11:47:36 -05:00
alangsto
a30dccd3cf feat: upgrade frontend-lib-learning-assistant version (#1248) 2023-12-05 09:26:01 -05:00
Ben Warzeski
4e1346716b feat: allow plugin.modal to open a fullscreen modal (#1243) 2023-12-04 13:18:59 -05:00
Marcos
5906576c6e Add test coverage for Courseware Search components (WIP) (#1242)
* chore: 100% coverage on CoursewareSearchResults.jsx

* chore: Added test coverage for all CoursewareSearch components

* chore: Minor fixes on Courseware Search components
2023-12-04 12:39:07 -03:00
renovate[bot]
d7cffcd414 fix(deps): update dependency @edx/brand to v1.2.3 2023-12-04 11:34:23 +00:00
renovate[bot]
cf49d92951 chore(deps): update dependency rosie to v2.1.1 2023-12-04 07:05:38 +00:00
Leangseu Kim
2fd5e92d2d chore: fix typo for modal event 2023-11-29 15:26:48 -05:00
leangseu-edx
bce25c462a chore: update sidebar logic (#1236)
* fix: make side bar notification behave correctly

---------

Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
2023-11-22 11:46:34 -05:00
Ben Warzeski
fe773d1700 feat: forward modal-close event to children on modal close (#1238)
* feat: forward modal-close event to children on modal close

* fix: update tests
2023-11-21 14:38:46 -05:00
leangseu-edx
748e73d128 revert: notification made forum click rate drop (#1237) 2023-11-17 15:30:51 -05:00
82 changed files with 3564 additions and 448 deletions

1
.env
View File

@@ -13,6 +13,7 @@ DISCOVERY_API_BASE_URL=''
DISCUSSIONS_MFE_BASE_URL=''
ECOMMERCE_BASE_URL=''
ENABLE_JUMPNAV='true'
ENABLE_NEW_SIDEBAR=''
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
EXAMS_BASE_URL=''

View File

@@ -13,6 +13,7 @@ DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NEW_SIDEBAR=''
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL=''

View File

@@ -13,6 +13,7 @@ DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NEW_SIDEBAR=''
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL='http://localhost:18740'

View File

@@ -53,13 +53,15 @@ pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \
&& atlas pull $(ATLAS_OPTIONS) \
translations/frontend-platform/src/i18n/messages:frontend-platform \
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-lib-special-exams/src/i18n/messages:frontend-lib-special-exams \
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
$(intl_imports) paragon frontend-component-header frontend-component-footer frontend-app-learning
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-lib-special-exams frontend-app-learning
endif
# This target is used by Travis.

4
global-setup.js Normal file
View File

@@ -0,0 +1,4 @@
// Force all tests to run in UTC to prevent tests from being sensitive to host timezone.
module.exports = async () => {
process.env.TZ = 'UTC';
};

View File

@@ -16,5 +16,6 @@ module.exports = createConfig('jest', {
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
},
testTimeout: 30000,
testEnvironment: 'jsdom'
testEnvironment: 'jsdom',
globalSetup: "./global-setup.js"
});

435
package-lock.json generated
View File

@@ -19,16 +19,17 @@
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "12.2.1",
"@edx/frontend-component-header": "4.6.0",
"@edx/frontend-lib-learning-assistant": "^1.17.0",
"@edx/frontend-lib-special-exams": "2.23.3",
"@edx/frontend-lib-learning-assistant": "^1.20.1",
"@edx/frontend-lib-special-exams": "2.27.0",
"@edx/frontend-platform": "5.5.2",
"@edx/paragon": "20.46.0",
"@edx/openedx-atlas": "^0.6.0",
"@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "^0.1.4",
"@openedx/paragon": "22.0.0",
"@popperjs/core": "2.11.8",
"@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.2",
@@ -66,7 +67,7 @@
"es-check": "6.2.1",
"husky": "7.0.4",
"jest": "29.5.0",
"rosie": "2.1.0"
"rosie": "2.1.1"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -1971,6 +1972,23 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/runtime-corejs3": {
"version": "7.23.9",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.23.9.tgz",
"integrity": "sha512-oeOFTrYWdWXCvXGB5orvMTJ6gCZ9I6FBjR+M38iKNXCsPxr4xT0RTdg5uz1H7QP8pp74IzPtwritEr+JscqHXQ==",
"dependencies": {
"core-js-pure": "^3.30.2",
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/runtime-corejs3/node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/@babel/runtime/node_modules/regenerator-runtime": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
@@ -2206,9 +2224,9 @@
},
"node_modules/@edx/brand": {
"name": "@openedx/brand-openedx",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@openedx/brand-openedx/-/brand-openedx-1.2.2.tgz",
"integrity": "sha512-mBvxR7aB9290j9+h3d/9G8VkG1b8ecLSmlxc0vskfm7DL/fKUzFmHAj3PI7Z4kkwCQOL4QT5mJHJKC0ZFf7qvQ=="
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@openedx/brand-openedx/-/brand-openedx-1.2.3.tgz",
"integrity": "sha512-Dn9CtpC8fovh++Xi4NF5NJoeR9yU2yXZnV9IujxIyGd/dn0Phq5t6dzJVfupwq09mpDnzJv7egA8Znz/3ljO+w=="
},
"node_modules/@edx/browserslist-config": {
"version": "1.2.0",
@@ -3460,9 +3478,9 @@
}
},
"node_modules/@edx/frontend-lib-learning-assistant": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-learning-assistant/-/frontend-lib-learning-assistant-1.17.0.tgz",
"integrity": "sha512-Wb8QyohYN8OY/l90UUC5L/LZT+JqeXXzzbQaFeteifn23uPqFDqhDxJyRw/tu6ox4SEtOPO3CuFZLL9AZKUIXg==",
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-learning-assistant/-/frontend-lib-learning-assistant-1.20.1.tgz",
"integrity": "sha512-/JUSfs4CZwZj2Bg0BqlMGIC6BrDu4PufUSFnrgusL4ZD/N8DR3UyX/AQZml7S9O/ci4OY3YU7V4VDM8X00+6Lg==",
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
@@ -3539,9 +3557,9 @@
}
},
"node_modules/@edx/frontend-lib-special-exams": {
"version": "2.23.3",
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-special-exams/-/frontend-lib-special-exams-2.23.3.tgz",
"integrity": "sha512-v+1a6y7rY/38t213o3xXHlbLXDOEJDF4cBA566S84pNuBdcOir3tUm2dB996Tlx8KBsNlE71QVxyplTiQslgTw==",
"version": "2.27.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-special-exams/-/frontend-lib-special-exams-2.27.0.tgz",
"integrity": "sha512-osKuKq1+RfoLBKeDabmUf82bRP+xQu3USGodhNi4Mt0jX9FWJdZsN0CulhFW+mGFManz8dzKyXnO1AXDX1/I8g==",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "1.2.34",
"@fortawesome/free-brands-svg-icons": "5.11.2",
@@ -3700,6 +3718,14 @@
"@newrelic/publish-sourcemap": "^5.0.1"
}
},
"node_modules/@edx/openedx-atlas": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@edx/openedx-atlas/-/openedx-atlas-0.6.0.tgz",
"integrity": "sha512-wZO7hA4VJ/bXjaQNNR7KXGYyTCNs1mBJd3HwQK2EmOwFZYFNX6nzSAm9S7HCfi/kb1PCRpmp3wJt+v/Eu9BEQg==",
"bin": {
"atlas": "atlas"
}
},
"node_modules/@edx/paragon": {
"version": "20.46.0",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.46.0.tgz",
@@ -5570,6 +5596,225 @@
"node": ">= 8"
}
},
"node_modules/@openedx/paragon": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.0.0.tgz",
"integrity": "sha512-2tD5SEu6kNf2Llop/FylqTI87mlG0jaeAhGiX57bABcHZ/cDParCxX/unPiui6LCfdrDhghjVBKD3U2+Qn6Wag==",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/react-fontawesome": "^0.1.18",
"@popperjs/core": "^2.11.4",
"bootstrap": "^4.6.2",
"chalk": "^4.1.2",
"child_process": "^1.0.2",
"classnames": "^2.3.1",
"email-prop-type": "^3.0.0",
"file-selector": "^0.6.0",
"font-awesome": "^4.7.0",
"glob": "^8.0.3",
"inquirer": "^8.2.5",
"lodash.uniqby": "^4.7.0",
"mailto-link": "^2.0.0",
"prop-types": "^15.8.1",
"react-bootstrap": "^1.6.5",
"react-colorful": "^5.6.1",
"react-dropzone": "^14.2.1",
"react-focus-on": "^3.5.4",
"react-imask": "^7.1.3",
"react-loading-skeleton": "^3.1.0",
"react-popper": "^2.2.5",
"react-proptype-conditional-require": "^1.0.4",
"react-responsive": "^8.2.0",
"react-table": "^7.7.0",
"react-transition-group": "^4.4.2",
"tabbable": "^5.3.3",
"uncontrollable": "^7.2.1",
"uuid": "^9.0.0"
},
"bin": {
"paragon": "bin/paragon-scripts.js"
},
"peerDependencies": {
"react": "^16.8.6 || ^17.0.0",
"react-dom": "^16.8.6 || ^17.0.0",
"react-intl": "^5.25.1 || ^6.4.0"
}
},
"node_modules/@openedx/paragon/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz",
"integrity": "sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@openedx/paragon/node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.1.tgz",
"integrity": "sha512-MfRCYlQPXoLlpem+egxjfkEuP9UQswTrlCOsknus/NcMoblTH2g0jPrapbcIb04KGA7E2GZxbAccGZfWoYgsrQ==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@openedx/paragon/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@openedx/paragon/node_modules/cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
"integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
"dependencies": {
"restore-cursor": "^3.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@openedx/paragon/node_modules/cli-width": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz",
"integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==",
"engines": {
"node": ">= 10"
}
},
"node_modules/@openedx/paragon/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/@openedx/paragon/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@openedx/paragon/node_modules/figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
"integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==",
"dependencies": {
"escape-string-regexp": "^1.0.5"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@openedx/paragon/node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@openedx/paragon/node_modules/inquirer": {
"version": "8.2.6",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz",
"integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==",
"dependencies": {
"ansi-escapes": "^4.2.1",
"chalk": "^4.1.1",
"cli-cursor": "^3.1.0",
"cli-width": "^3.0.0",
"external-editor": "^3.0.3",
"figures": "^3.0.0",
"lodash": "^4.17.21",
"mute-stream": "0.0.8",
"ora": "^5.4.1",
"run-async": "^2.4.0",
"rxjs": "^7.5.5",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0",
"through": "^2.3.6",
"wrap-ansi": "^6.0.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/@openedx/paragon/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"engines": {
"node": ">=8"
}
},
"node_modules/@openedx/paragon/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@openedx/paragon/node_modules/mute-stream": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
},
"node_modules/@openedx/paragon/node_modules/restore-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
"integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
"dependencies": {
"onetime": "^5.1.0",
"signal-exit": "^3.0.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@openedx/paragon/node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/@openedx/paragon/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@optimizely/js-sdk-logging": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@optimizely/js-sdk-logging/-/js-sdk-logging-0.3.1.tgz",
@@ -8341,8 +8586,7 @@
"node_modules/chardet": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"dev": true
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="
},
"node_modules/check-types": {
"version": "7.3.0",
@@ -8465,6 +8709,11 @@
"node": ">= 6"
}
},
"node_modules/child_process": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz",
"integrity": "sha512-Wmza/JzL0SiWz7kl6MhIKT5ceIlnFPJX+lwUGj7Clhy5MMldsSoJR0+uvRzOS5Kv45Mq7t1PoE8TsOA9bzvb6g=="
},
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -8684,6 +8933,17 @@
"node": ">=4"
}
},
"node_modules/cli-spinners": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
"integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-width": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz",
@@ -8750,6 +9010,14 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/clone": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
"integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clone-deep": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
@@ -9633,6 +9901,17 @@
"node": ">= 10"
}
},
"node_modules/defaults": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
"integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==",
"dependencies": {
"clone": "^1.0.2"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/define-lazy-prop": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
@@ -11234,7 +11513,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
"integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==",
"dev": true,
"dependencies": {
"chardet": "^0.7.0",
"iconv-lite": "^0.4.24",
@@ -12839,6 +13117,17 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/imask": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/imask/-/imask-7.3.0.tgz",
"integrity": "sha512-TG+/rfb62JaQDM2KVrzEHMb4lv2srbsby7vHndXhqgQFB1MgPIXl60VQUfly/Xv5iWfA9ytB+rfQ+skUgINw7A==",
"dependencies": {
"@babel/runtime-corejs3": "^7.23.6"
},
"engines": {
"npm": ">=4.0.0"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -13480,6 +13769,14 @@
"node": ">=0.10.0"
}
},
"node_modules/is-interactive": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz",
"integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==",
"engines": {
"node": ">=8"
}
},
"node_modules/is-invalid-path": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-invalid-path/-/is-invalid-path-0.1.0.tgz",
@@ -13722,6 +14019,17 @@
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="
},
"node_modules/is-unicode-supported": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-valid-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-valid-path/-/is-valid-path-0.1.1.tgz",
@@ -17220,6 +17528,21 @@
"resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz",
"integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww=="
},
"node_modules/log-symbols": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
"integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
"dependencies": {
"chalk": "^4.1.0",
"is-unicode-supported": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/logform": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.5.1.tgz",
@@ -18739,11 +19062,55 @@
"node": ">= 0.8.0"
}
},
"node_modules/ora": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
"integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==",
"dependencies": {
"bl": "^4.1.0",
"chalk": "^4.1.0",
"cli-cursor": "^3.1.0",
"cli-spinners": "^2.5.0",
"is-interactive": "^1.0.0",
"is-unicode-supported": "^0.1.0",
"log-symbols": "^4.1.0",
"strip-ansi": "^6.0.0",
"wcwidth": "^1.0.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ora/node_modules/cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
"integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
"dependencies": {
"restore-cursor": "^3.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ora/node_modules/restore-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
"integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
"dependencies": {
"onetime": "^5.1.0",
"signal-exit": "^3.0.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/os-tmpdir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -20504,6 +20871,21 @@
"react": ">=16.3.0"
}
},
"node_modules/react-imask": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/react-imask/-/react-imask-7.3.0.tgz",
"integrity": "sha512-AHoQUeXil6PfqDzJHN08hO2liWxNDRJosNUa2XSqliFY2tXGL/3Elm0msupDNAyNPItAnyF9G5FGFoCfiCn+AQ==",
"dependencies": {
"imask": "^7.3.0",
"prop-types": "^15.8.1"
},
"engines": {
"npm": ">=4.0.0"
},
"peerDependencies": {
"react": ">=0.14.0"
}
},
"node_modules/react-intl": {
"version": "5.25.1",
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-5.25.1.tgz",
@@ -21371,9 +21753,9 @@
}
},
"node_modules/rosie": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/rosie/-/rosie-2.1.0.tgz",
"integrity": "sha512-Dbzdc+prLXZuB/suRptDnBUY29SdGvND3bLg6cll8n7PNqzuyCxSlRfrkn8PqjS9n4QVsiM7RCvxCkKAkTQRjA==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/rosie/-/rosie-2.1.1.tgz",
"integrity": "sha512-2AXB7WrIZXtKMZ6Q/PlozqPF5nu/x7NEvRJZOblrJuprrPfm5gL8JVvJPj9aaib9F8IUALnLUFhzXrwEtnI5cQ==",
"dev": true,
"engines": {
"node": ">=10"
@@ -21408,7 +21790,6 @@
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
"integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==",
"dev": true,
"engines": {
"node": ">=0.12.0"
}
@@ -23604,8 +23985,7 @@
"node_modules/through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"dev": true
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
},
"node_modules/thunky": {
"version": "1.1.0",
@@ -23636,7 +24016,6 @@
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
"dev": true,
"dependencies": {
"os-tmpdir": "~1.0.2"
},
@@ -24560,6 +24939,14 @@
"minimalistic-assert": "^1.0.0"
}
},
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
"integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==",
"dependencies": {
"defaults": "^1.0.3"
}
},
"node_modules/webidl-conversions": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz",

View File

@@ -32,10 +32,11 @@
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "12.2.1",
"@edx/frontend-component-header": "4.6.0",
"@edx/frontend-lib-learning-assistant": "^1.17.0",
"@edx/frontend-lib-special-exams": "2.23.3",
"@edx/frontend-lib-special-exams": "2.27.0",
"@edx/frontend-lib-learning-assistant": "^1.20.1",
"@edx/frontend-platform": "5.5.2",
"@edx/paragon": "20.46.0",
"@edx/openedx-atlas": "^0.6.0",
"@openedx/paragon": "22.0.0",
"@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-brands-svg-icons": "5.15.4",
@@ -79,6 +80,6 @@
"es-check": "6.2.1",
"husky": "7.0.4",
"jest": "29.5.0",
"rosie": "2.1.0"
"rosie": "2.1.1"
}
}

View File

@@ -8,7 +8,7 @@
"rebaseStalePrs": true,
"packageRules": [
{
"matchPackagePatterns": ["@edx"],
"matchPackagePatterns": ["@edx", "@openedx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}

View File

@@ -22,11 +22,13 @@ export const ROUTES = {
UNSUBSCRIBE: '/goal-unsubscribe/:token',
REDIRECT: '/redirect/*',
DASHBOARD: 'dashboard',
ENTERPRISE_LEARNER_DASHBOARD: 'enterprise-learner-dashboard',
CONSENT: 'consent',
};
export const REDIRECT_MODES = {
DASHBOARD_REDIRECT: 'dashboard-redirect',
ENTERPRISE_LEARNER_DASHBOARD_REDIRECT: 'enterprise-learner-dashboard-redirect',
CONSENT_REDIRECT: 'consent-redirect',
HOME_REDIRECT: 'home-redirect',
SURVEY_REDIRECT: 'survey-redirect',

View File

@@ -1,54 +1,62 @@
import React from 'react';
import React, { useMemo } from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Tabs, Tab } from '@edx/paragon';
import { useParams } from 'react-router';
import CoursewareSearchResults from './CoursewareSearchResults';
import messages from './messages';
import { useCoursewareSearchParams } from './hooks';
import { useModel } from '../../generic/model-store';
const noFilterKey = 'none';
const noFilterLabel = 'All content';
export const filteredResultsBySelection = ({ key = noFilterKey, results = [] }) => (
key === noFilterKey ? results : results.filter(({ type }) => type === key)
);
const allFilterKey = 'all';
const otherFilterKey = 'other';
const allowedFilterKeys = {
[allFilterKey]: true,
text: true,
video: true,
sequence: true,
[otherFilterKey]: true,
};
export const CoursewareSearchResultsFilter = ({ intl }) => {
const { courseId } = useParams();
const lastSearch = useModel('contentSearchResults', courseId);
const { filter: filterKeyword, setFilter } = useCoursewareSearchParams();
if (!lastSearch || !lastSearch?.results?.length) { return null; }
if (!lastSearch) { return null; }
const { total, results } = lastSearch;
const { results: data = [] } = lastSearch;
const filters = [
{
key: noFilterKey,
label: noFilterLabel,
count: total,
},
...lastSearch.filters,
];
const results = useMemo(() => data.reduce((acc, { type, ...rest }) => {
acc[allFilterKey] = [...(acc[allFilterKey] || []), { type: allFilterKey, ...rest }];
if (type === allFilterKey) { return acc; }
const getFilterTitle = (key, fallback) => {
const msg = messages[`filter:${key}`];
if (!msg) { return fallback; }
return intl.formatMessage(msg);
};
let targetKey = otherFilterKey;
if (allowedFilterKeys[type]) { targetKey = type; }
acc[targetKey] = [...(acc[targetKey] || []), { type: targetKey, ...rest }];
return acc;
}, {}), [data]);
const filters = useMemo(() => Object.keys(allowedFilterKeys).map((key) => ({
key,
label: intl.formatMessage(messages[`filter:${key}`]),
count: results[key]?.length || 0,
})), [results]);
const activeKey = allowedFilterKeys[filterKeyword] ? filterKeyword : allFilterKey;
return (
<Tabs
id="courseware-search-results-tabs"
className="courseware-search-results-tabs"
data-testid="courseware-search-results-tabs"
variant="tabs"
defaultActiveKey={noFilterKey}
activeKey={activeKey}
onSelect={setFilter}
>
{filters.map(({ key, label }) => (
<Tab key={key} eventKey={key} title={getFilterTitle(key, label)}>
<CoursewareSearchResults
results={filteredResultsBySelection({ key, results })}
/>
<Tab key={key} eventKey={key} title={label} data-testid={`courseware-search-results-tabs-${key}`}>
<CoursewareSearchResults results={results[key]} />
</Tab>
))}
</Tabs>

View File

@@ -8,35 +8,17 @@ import {
screen,
waitFor,
} from '../../setupTest';
import { CoursewareSearchResultsFilter, filteredResultsBySelection } from './CoursewareResultsFilter';
import { CoursewareSearchResultsFilter } from './CoursewareResultsFilter';
import { useCoursewareSearchParams } from './hooks';
import initializeStore from '../../store';
import { useModel } from '../../generic/model-store';
import searchResultsFactory from './test-data/search-results-factory';
jest.mock('./hooks');
jest.mock('../../generic/model-store', () => ({
useModel: jest.fn(),
}));
const mockResults = [
{
id: 'video-1', type: 'video', title: 'video_title', score: 3, contentHits: 1, url: '/video-1', location: ['path1', 'path2'],
},
{
id: 'video-2', type: 'video', title: 'video_title2', score: 2, contentHits: 1, url: '/video-2', location: ['path1', 'path2'],
},
{
id: 'document-1', type: 'document', title: 'document_title', score: 3, contentHits: 1, url: '/document-1', location: ['path1', 'path2'],
},
{
id: 'text-1', type: 'text', title: 'text_title1', score: 3, contentHits: 1, url: '/text-1', location: ['path1', 'path2'],
},
{
id: 'text-2', type: 'text', title: 'text_title2', score: 2, contentHits: 1, url: '/text-2', location: ['path1', 'path2'],
},
{
id: 'text-3', type: 'text', title: 'text_title3', score: 1, contentHits: 1, url: '/text-3', location: ['path1', 'path2'],
},
];
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
const decodedSequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction';
const decodedUnitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
@@ -46,6 +28,14 @@ const intl = {
formatMessage: (message) => message?.defaultMessage || '',
};
const coursewareSearch = {
query: '',
filter: '',
setQuery: jest.fn(),
setFilter: jest.fn(),
clearSearchParams: jest.fn(),
};
function renderComponent(props = {}) {
const store = initializeStore();
history.push(pathname);
@@ -62,55 +52,48 @@ function renderComponent(props = {}) {
describe('CoursewareSearchResultsFilter', () => {
beforeAll(initializeMockApp);
describe('filteredResultsBySelection', () => {
it('returns a no values array when no results are provided', () => {
const results = filteredResultsBySelection({ results: [] });
expect(results.length).toEqual(0);
});
it('returns all values when no key value is provided', () => {
const results = filteredResultsBySelection({ results: mockResults });
expect(results.length).toEqual(mockResults.length);
});
it('returns only "video"-typed elements when the key value "video" is given', () => {
const results = filteredResultsBySelection({ key: 'video', results: mockResults });
expect(results.length).toEqual(2);
});
it('returns only "course_outline"-typed elements when the key value "document" is given', () => {
const results = filteredResultsBySelection({ key: 'document', results: mockResults });
expect(results.length).toEqual(1);
});
it('returns only "text"-typed elements when the key value "text" is given', () => {
const results = filteredResultsBySelection({ key: 'text', results: mockResults });
expect(results.length).toEqual(3);
});
});
describe('</CoursewareSearchResultsFilter />', () => {
beforeEach(() => {
useCoursewareSearchParams.mockReturnValue(coursewareSearch);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should render', async () => {
useModel.mockReturnValue({
total: 6,
results: mockResults,
filters: [],
});
it('should render without errors', async () => {
useModel.mockReturnValue(searchResultsFactory());
await renderComponent();
await waitFor(() => {
expect(screen.queryByTestId('courseware-search-results-tabs')).toBeInTheDocument();
expect(screen.queryByText(/All content/)).toBeInTheDocument();
expect(screen.queryByTestId('courseware-search-results-tabs-all')).toBeInTheDocument();
expect(screen.queryByTestId('courseware-search-results-tabs-text')).toBeInTheDocument();
expect(screen.queryByTestId('courseware-search-results-tabs-video')).toBeInTheDocument();
expect(screen.queryByTestId('courseware-search-results-tabs-sequence')).toBeInTheDocument();
expect(screen.queryByTestId('courseware-search-results-tabs-other')).toBeInTheDocument();
});
});
describe('when there are not results', () => {
it('should render without errors', async () => {
useModel.mockReturnValue(searchResultsFactory('blah', {
results: [],
filters: [],
total: 0,
maxScore: null,
ms: 5,
}));
await renderComponent();
await waitFor(() => {
expect(screen.queryByTestId('courseware-search-results-tabs-all')).toBeInTheDocument();
expect(screen.queryByTestId('courseware-search-results-tabs-text')).toBeInTheDocument();
expect(screen.queryByTestId('courseware-search-results-tabs-video')).toBeInTheDocument();
expect(screen.queryByTestId('courseware-search-results-tabs-sequence')).toBeInTheDocument();
expect(screen.queryByTestId('courseware-search-results-tabs-other')).toBeInTheDocument();
});
});
});
});

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect } from 'react';
import { useParams } from 'react-router';
import { useDispatch } from 'react-redux';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
@@ -10,7 +10,7 @@ import {
Close,
} from '@edx/paragon/icons';
import { setShowSearch } from '../data/slice';
import { useElementBoundingBox, useLockScroll } from './hooks';
import { useCoursewareSearchParams, useElementBoundingBox, useLockScroll } from './hooks';
import messages from './messages';
import CoursewareSearchForm from './CoursewareSearchForm';
@@ -20,6 +20,7 @@ import { searchCourseContent } from '../data/thunks';
const CoursewareSearch = ({ intl, ...sectionProps }) => {
const { courseId } = useParams();
const { query: searchKeyword, setQuery, clearSearchParams } = useCoursewareSearchParams();
const dispatch = useDispatch();
const { org } = useModel('courseHomeMeta', courseId);
const {
@@ -28,7 +29,6 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
errors,
total,
} = useModel('contentSearchResults', courseId);
const [searchKeyword, setSearchKeyword] = useState(lastSearchKeyword);
useLockScroll();
@@ -36,6 +36,7 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
const top = info ? `${Math.floor(info.top)}px` : 0;
const clearSearch = () => {
clearSearchParams();
dispatch(updateModel({
modelType: 'contentSearchResults',
model: {
@@ -48,34 +49,35 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
}));
};
const handleSubmit = () => {
if (!searchKeyword) {
const handleSubmit = (value) => {
if (!value) {
clearSearch();
return;
}
const eventProperties = {
sendTrackingLogEvent('edx.course.home.courseware_search.submit', {
org_key: org,
courserun_key: courseId,
};
sendTrackingLogEvent('edx.course.home.courseware_search.submit', {
...eventProperties,
event_type: 'searchKeyword',
keyword: searchKeyword,
keyword: value,
});
dispatch(searchCourseContent(courseId, searchKeyword));
dispatch(searchCourseContent(courseId, value));
setQuery(value);
};
useEffect(() => {
handleSubmit(searchKeyword);
}, []);
const handleOnChange = (value) => {
if (value === searchKeyword) { return; }
if (!value) { clearSearch(); }
};
setSearchKeyword(value);
if (!value) {
clearSearch();
}
const handleSearchCloseClick = () => {
clearSearch();
dispatch(setShowSearch(false));
};
let status = 'idle';
@@ -94,14 +96,14 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
variant="tertiary"
className="p-1"
aria-label={intl.formatMessage(messages.searchCloseAction)}
onClick={() => dispatch(setShowSearch(false))}
onClick={handleSearchCloseClick}
data-testid="courseware-search-close-button"
><Icon src={Close} />
</Button>
</div>
<div className="courseware-search__outer-content">
<div className="courseware-search__content">
<h2>{intl.formatMessage(messages.searchModuleTitle)}</h2>
<h1 class="h2">{intl.formatMessage(messages.searchModuleTitle)}</h1>
<CoursewareSearchForm
searchTerm={searchKeyword}
onSubmit={handleSubmit}
@@ -109,26 +111,26 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
placeholder={intl.formatMessage(messages.searchBarPlaceholderText)}
/>
{status === 'loading' ? (
<div className="courseware-search__spinner">
<div className="courseware-search__spinner" data-testid="courseware-search-spinner">
<Spinner animation="border" variant="light" screenReaderText={intl.formatMessage(messages.loading)} />
</div>
) : null}
{status === 'error' && (
<Alert className="mt-4" variant="danger">
<Alert className="mt-4" variant="danger" data-testid="courseware-search-error">
{intl.formatMessage(messages.searchResultsError)}
</Alert>
)}
{status === 'results' ? (
<>
<div className="courseware-search__results-summary">{total > 0
? (
intl.formatMessage(
total === 1
? messages.searchResultsSingular
: messages.searchResultsPlural,
{ total, keyword: lastSearchKeyword },
)
) : intl.formatMessage(messages.searchResultsNone)}
<div
className="courseware-search__results-summary"
aria-live="polite"
aria-relevant="all"
aria-atomic="true"
data-testid="courseware-search-summary"
>{total > 0
? intl.formatMessage(messages.searchResultsLabel, { total, keyword: lastSearchKeyword })
: intl.formatMessage(messages.searchResultsNone)}
</div>
<CoursewareSearchResultsFilterContainer />
</>

View File

@@ -2,21 +2,45 @@ import React from 'react';
import { history } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { Route, Routes } from 'react-router-dom';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import {
initializeMockApp,
render,
screen,
waitFor,
fireEvent,
} from '../../setupTest';
import { CoursewareSearch } from './index';
import { useElementBoundingBox, useLockScroll } from './hooks';
import { useElementBoundingBox, useLockScroll, useCoursewareSearchParams } from './hooks';
import initializeStore from '../../store';
import { useModel } from '../../generic/model-store';
import { searchCourseContent } from '../data/thunks';
import { setShowSearch } from '../data/slice';
import { updateModel, useModel } from '../../generic/model-store';
jest.mock('./hooks');
jest.mock('../../generic/model-store', () => ({
updateModel: jest.fn(),
useModel: jest.fn(),
}));
jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackingLogEvent: jest.fn(),
}));
jest.mock('../data/thunks', () => ({
searchCourseContent: jest.fn(),
}));
jest.mock('../data/slice', () => ({
setShowSearch: jest.fn(),
}));
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
const decodedSequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction';
const decodedUnitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
@@ -32,6 +56,14 @@ const defaultProps = {
total: 0,
};
const coursewareSearch = {
query: '',
filter: '',
setQuery: jest.fn(),
setFilter: jest.fn(),
clearSearchParams: jest.fn(),
};
const intl = {
formatMessage: (message) => message?.defaultMessage || '',
};
@@ -50,7 +82,22 @@ function renderComponent(props = {}) {
}
const mockModels = ((props = defaultProps) => {
useModel.mockReturnValue(props);
useModel.mockReturnValue({
...defaultProps,
...props,
});
updateModel.mockReturnValue({
type: 'MOCK_ACTION',
payload: {
modelType: 'contentSearchResults',
model: defaultProps,
},
});
});
const mockSearchParams = ((props = coursewareSearch) => {
useCoursewareSearchParams.mockReturnValue(props);
});
describe('CoursewareSearch', () => {
@@ -65,16 +112,18 @@ describe('CoursewareSearch', () => {
useElementBoundingBox.mockImplementation(() => ({ top: tabsTopPosition }));
});
it('Should use useElementBoundingBox() and useLockScroll() hooks', () => {
it('should use useElementBoundingBox() and useLockScroll() hooks', () => {
mockModels();
mockSearchParams();
renderComponent();
expect(useElementBoundingBox).toBeCalledTimes(1);
expect(useLockScroll).toBeCalledTimes(1);
});
it('Should have a "--modal-top-position" CSS variable matching the CourseTabsNavigation top position', () => {
it('should have a "--modal-top-position" CSS variable matching the CourseTabsNavigation top position', () => {
mockModels();
mockSearchParams();
renderComponent();
const section = screen.getByTestId('courseware-search-section');
@@ -82,11 +131,26 @@ describe('CoursewareSearch', () => {
});
});
describe('when clicking on the "Close" button', () => {
it('should dispatch setShowSearch(false)', async () => {
mockModels();
renderComponent();
await waitFor(() => {
const close = screen.queryByTestId('courseware-search-close-button');
fireEvent.click(close);
});
expect(setShowSearch).toBeCalledWith(false);
});
});
describe('when CourseTabsNavigation is not present', () => {
it('Should use "--modal-top-position: 0" if nce element is not present', () => {
it('should use "--modal-top-position: 0" if nce element is not present', () => {
useElementBoundingBox.mockImplementation(() => undefined);
mockModels();
mockSearchParams();
renderComponent();
const section = screen.getByTestId('courseware-search-section');
@@ -95,12 +159,127 @@ describe('CoursewareSearch', () => {
});
describe('when passing extra props', () => {
it('Should pass on extra props to section element', () => {
it('should pass on extra props to section element', () => {
mockModels();
mockSearchParams();
renderComponent({ foo: 'bar' });
const section = screen.getByTestId('courseware-search-section');
expect(section).toHaveAttribute('foo', 'bar');
});
});
describe('when submitting an empty search', () => {
it('should clear the search by dispatch updateModel', async () => {
mockModels();
renderComponent();
await waitFor(() => {
const submit = screen.queryByTestId('courseware-search-form-submit');
fireEvent.click(submit);
});
expect(updateModel).toHaveBeenCalledWith({
modelType: 'contentSearchResults',
model: {
id: decodedCourseId,
searchKeyword: '',
results: [],
errors: undefined,
loading: false,
},
});
});
});
describe('when submitting a search', () => {
it('should show a loading state', () => {
mockModels({
loading: true,
});
renderComponent();
expect(screen.queryByTestId('courseware-search-spinner')).toBeInTheDocument();
});
it('should call searchCourseContent', async () => {
mockModels();
renderComponent();
const searchKeyword = 'course';
await waitFor(() => {
const input = screen.queryByTestId('courseware-search-form').querySelector('input');
fireEvent.change(input, { target: { value: searchKeyword } });
});
await waitFor(() => {
const submit = screen.queryByTestId('courseware-search-form-submit');
fireEvent.click(submit);
});
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.course.home.courseware_search.submit', {
org_key: defaultProps.org,
courserun_key: decodedCourseId,
event_type: 'searchKeyword',
keyword: searchKeyword,
});
expect(searchCourseContent).toHaveBeenCalledWith(decodedCourseId, searchKeyword);
});
it('should show an error state if any', () => {
mockModels({
errors: ['foo'],
});
renderComponent();
expect(screen.queryByTestId('courseware-search-error')).toBeInTheDocument();
});
it('should show "No results found." if results is empty', () => {
mockModels({
searchKeyword: 'test',
total: 0,
});
renderComponent();
expect(screen.queryByTestId('courseware-search-summary').textContent).toBe('No results found.');
});
it('should show a summary for the results', () => {
mockModels({
searchKeyword: 'fubar',
total: 1,
});
renderComponent();
expect(screen.queryByTestId('courseware-search-summary').textContent).toBe('Results for "fubar":');
});
});
describe('when clearing the search input', () => {
it('should clear the search by dispatch updateModel', async () => {
mockModels({
searchKeyword: 'fubar',
total: 2,
});
renderComponent();
await waitFor(() => {
const input = screen.queryByTestId('courseware-search-form').querySelector('input');
fireEvent.change(input, { target: { value: '' } });
});
expect(updateModel).toHaveBeenCalledWith({
modelType: 'contentSearchResults',
model: {
id: decodedCourseId,
searchKeyword: '',
results: [],
errors: undefined,
loading: false,
},
});
});
});
});

View File

@@ -0,0 +1,24 @@
import React from 'react';
import {
initializeMockApp,
render,
screen,
} from '../../setupTest';
import CoursewareSearchEmpty from './CoursewareSearchEmpty';
function renderComponent() {
const { container } = render(<CoursewareSearchEmpty />);
return container;
}
describe('CoursewareSearchEmpty', () => {
beforeAll(async () => {
initializeMockApp();
});
it('should match the snapshot', () => {
renderComponent();
expect(screen.getByTestId('no-results')).toMatchSnapshot();
});
});

View File

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

View File

@@ -47,8 +47,8 @@ describe('CoursewareSearchToggle', () => {
it('should call onSubmit handler when submit is clicked', async () => {
await act(async () => renderComponent(placeholderText, onSubmitHandlerMock, onChangeHandlerMock));
await waitFor(() => {
const element = screen.queryAllByText('Search')[0];
await waitFor(async () => {
const element = await screen.findByTestId('courseware-search-form-submit');
fireEvent.click(element);
expect(onSubmitHandlerMock).toHaveBeenCalledTimes(1);
});

View File

@@ -15,7 +15,7 @@ const iconTypeMapping = {
const defaultIcon = Article;
const CoursewareSearchResults = ({ results }) => {
const CoursewareSearchResults = ({ results = [] }) => {
if (!results?.length) {
return <CoursewareSearchEmpty />;
}
@@ -70,14 +70,13 @@ const CoursewareSearchResults = ({ results }) => {
};
CoursewareSearchResults.propTypes = {
results: PropTypes.arrayOf(PropTypes.objectOf({
results: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
title: PropTypes.string,
type: PropTypes.string,
location: PropTypes.arrayOf(PropTypes.string),
url: PropTypes.string,
contentHits: PropTypes.number,
score: PropTypes.number,
})),
};

View File

@@ -6,7 +6,7 @@ import {
} from '../../setupTest';
import CoursewareSearchResults from './CoursewareSearchResults';
import messages from './messages';
// import mockedData from './test-data/mockedResults'; // TODO: Update this test.
import searchResultsFactory from './test-data/search-results-factory';
jest.mock('react-redux');
@@ -28,11 +28,14 @@ describe('CoursewareSearchResults', () => {
});
});
/* describe('when list of results is provided', () => {
beforeEach(() => { renderComponent({ results: mockedData }); });
describe('when list of results is provided', () => {
beforeEach(() => {
const { results } = searchResultsFactory('course');
renderComponent({ results });
});
it('should match the snapshot', () => {
expect(screen.getByTestId('search-results')).toMatchSnapshot();
});
}); */
});
});

View File

@@ -1,17 +1,26 @@
import React from 'react';
import React, { useEffect } from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Icon } from '@edx/paragon';
import { Search } from '@edx/paragon/icons';
import { useDispatch } from 'react-redux';
import { setShowSearch } from '../data/slice';
import messages from './messages';
import { useCoursewareSearchFeatureFlag } from './hooks';
import { useCoursewareSearchFeatureFlag, useCoursewareSearchParams } from './hooks';
import { setShowSearch } from '../data/slice';
const CoursewareSearchToggle = ({
intl,
}) => {
const dispatch = useDispatch();
const enabled = useCoursewareSearchFeatureFlag();
const { query } = useCoursewareSearchParams();
const handleSearchOpenClick = () => {
dispatch(setShowSearch(true));
};
useEffect(() => {
if (enabled && !!query) { handleSearchOpenClick(); }
}, [enabled]);
if (!enabled) { return null; }
@@ -22,7 +31,7 @@ const CoursewareSearchToggle = ({
size="sm"
className="p-1 mt-2 mr-2 rounded-lg"
aria-label={intl.formatMessage(messages.searchOpenAction)}
onClick={() => dispatch(setShowSearch(true))}
onClick={handleSearchOpenClick}
data-testid="courseware-search-open-button"
>
<Icon src={Search} />

View File

@@ -12,14 +12,33 @@ import { setShowSearch } from '../data/slice';
import { CoursewareSearchToggle } from './index';
const mockDispatch = jest.fn();
const mockCoursewareSearchParams = jest.fn();
jest.mock('../data/thunks');
jest.mock('../data/slice');
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
jest.mock('./hooks', () => ({
...jest.requireActual('./hooks'),
useCoursewareSearchParams: () => mockCoursewareSearchParams,
}));
const coursewareSearch = {
query: '',
filter: '',
setQuery: jest.fn(),
setFilter: jest.fn(),
clearSearchParams: jest.fn(),
};
const mockSearchParams = ((props = coursewareSearch) => {
mockCoursewareSearchParams.mockReturnValue(props);
});
function renderComponent() {
const { container } = render(<CoursewareSearchToggle />);
return container;
@@ -36,6 +55,7 @@ describe('CoursewareSearchToggle', () => {
it('Should not render when the waffle flag is disabled', async () => {
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: false }));
mockSearchParams();
await act(async () => renderComponent());
await waitFor(() => {
@@ -46,6 +66,8 @@ describe('CoursewareSearchToggle', () => {
it('Should render when the waffle flag is enabled', async () => {
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
mockSearchParams();
await act(async () => renderComponent());
await waitFor(() => {
@@ -56,6 +78,8 @@ describe('CoursewareSearchToggle', () => {
it('Should dispatch setShowSearch(true) when clicking the search button', async () => {
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
mockSearchParams();
await act(async () => renderComponent());
const button = await screen.findByTestId('courseware-search-open-button');
fireEvent.click(button);

View File

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

View File

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

View File

@@ -18,6 +18,11 @@ Object {
"key": "text",
"label": "Text",
},
Object {
"count": 1,
"key": "unknown",
"label": "Unknown",
},
Object {
"count": 2,
"key": "video",
@@ -286,6 +291,15 @@ Object {
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
},
Object {
"contentHits": 0,
"id": "random-element-id",
"location": null,
"score": 0.82610154,
"title": "External Course Link Test",
"type": "unknown",
"url": "https://www.edx.org",
},
],
"total": 29,
}

View File

@@ -35,7 +35,7 @@
&__results-summary {
font-size: .9rem;
color: $gray-400;
color: $gray-500;
padding: 1rem 0 .5rem;
}
@@ -111,7 +111,7 @@
&__breadcrumbs {
display: flex;
gap: 1.25rem;
color: $gray-400;
color: $gray-500;
overflow: hidden;
list-style: none;
padding: 0;
@@ -141,6 +141,14 @@
}
}
.courseware-search-results-tabs {
border-bottom-color: $gray-400 !important;
&.nav-tabs .nav-link.active {
border-bottom-width: 4px !important;
}
}
@media (min-width: map-get($grid-breakpoints, 'md')) {
.courseware-search__content {
padding-top: 8rem;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useLayoutEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useParams, useSearchParams } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { debounce } from 'lodash';
import { fetchCoursewareSearchSettings } from '../data/thunks';
@@ -69,3 +69,18 @@ export function useLockScroll() {
};
}, []);
}
export function useCoursewareSearchParams() {
const [searchParams, setSearchParams] = useSearchParams();
const clearSearchParams = () => setSearchParams({ q: '', f: '' });
const query = searchParams.get('q');
const filter = searchParams.get('f');
const setQuery = (q) => setSearchParams((params) => ({ q, f: params.get('f') }));
const setFilter = (f) => setSearchParams((params) => ({ q: params.get('q'), f }));
return {
query, filter, setQuery, setFilter, clearSearchParams,
};
}

View File

@@ -1,8 +1,8 @@
const Joi = require('joi');
const endpointSchema = Joi.object({
took: Joi.number(),
total: Joi.number(),
took: Joi.number().required(),
total: Joi.number().required(),
maxScore: Joi.number().allow(null),
results: Joi.array().items(Joi.object({
id: Joi.string(),

View File

@@ -19,6 +19,7 @@ describe('mapSearchResponse', () => {
{ key: 'capa', label: 'CAPA', count: 7 },
{ key: 'sequence', label: 'Sequence', count: 2 },
{ key: 'text', label: 'Text', count: 9 },
{ key: 'unknown', label: 'Unknown', count: 1 },
{ key: 'video', label: 'Video', count: 2 },
];
expect(response.filters).toEqual(expectedFilters);

View File

@@ -6,6 +6,16 @@ const messages = defineMessages({
defaultMessage: 'Search within this course',
description: 'Aria-label for a button that will pop up Courseware Search.',
},
searchSubmitLabel: {
id: 'learn.coursewareSerch.submitLabel',
defaultMessage: 'Search',
description: 'Button label that will submit Courseware Search.',
},
searchClearAction: {
id: 'learn.coursewareSerch.clearAction',
defaultMessage: 'Clear search',
description: 'Button label that will the current Courseware Search input.',
},
searchCloseAction: {
id: 'learn.coursewareSerch.closeAction',
defaultMessage: 'Close the search form',
@@ -31,15 +41,10 @@ const messages = defineMessages({
defaultMessage: 'No results found.',
description: 'Text to show when the Courseware Search found no results matching the criteria.',
},
searchResultsSingular: {
id: 'learn.coursewareSerch.searchResultsSingular',
defaultMessage: '1 match found for "{keyword}":',
description: 'Text to show when the Courseware Search found only one result matching the criteria.',
},
searchResultsPlural: {
id: 'learn.coursewareSerch.searchResultsPlural',
defaultMessage: '{total} matches found for "{keyword}":',
description: 'Text to show when the Courseware Search found multiple results matching the criteria.',
searchResultsLabel: {
id: 'learn.coursewareSerch.searchResultsLabel',
defaultMessage: 'Results for "{keyword}":',
description: 'Text to show above the search results response list.',
},
searchResultsError: {
id: 'learn.coursewareSerch.searchResultsError',
@@ -48,8 +53,8 @@ const messages = defineMessages({
},
// These are translations for labeling the filters
'filter:none': {
id: 'learn.coursewareSerch.filter:none',
'filter:all': {
id: 'learn.coursewareSerch.filter:all',
defaultMessage: 'All content',
description: 'Label for the search results filter that shows all content (no filter).',
},
@@ -63,6 +68,16 @@ const messages = defineMessages({
defaultMessage: 'Video',
description: 'Label for the search results filter that shows results with video content.',
},
'filter:sequence': {
id: 'learn.coursewareSerch.filter:sequence',
defaultMessage: 'Section',
description: 'Label for the search results filter that shows results with section content.',
},
'filter:other': {
id: 'learn.coursewareSerch.filter:other',
defaultMessage: 'Other',
description: 'Label for the search results filter that shows results with other content.',
},
});
export default messages;

View File

@@ -542,6 +542,28 @@
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf"
},
"score": 0.82610154
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "some-external-reference",
"data": {
"course": "External Course",
"org": "edX",
"content": {
"display_name": "External Course Link Test",
"html_content": "This should open a new tab when following the link."
},
"content_type": "Unknown",
"id": "random-element-id",
"start_date": "2013-02-05T05:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": null,
"excerpt": "Just testing external links",
"url": "https://www.edx.org"
},
"score": 0.82610154
}
],
"access_denied_count": 0

View File

@@ -0,0 +1,17 @@
import { camelCaseObject } from '@edx/frontend-platform';
import mockedData from './mocked-response.json';
import mapSearchResponse from '../map-search-response';
function searchResultsFactory(searchKeywords = '', moreInfo = {}) {
const data = camelCaseObject(mockedData);
const info = mapSearchResponse(data, searchKeywords);
const result = {
...info,
...moreInfo,
};
return result;
}
export default searchResultsFactory;

View File

@@ -55,6 +55,29 @@ describe('Data layer integration tests', () => {
expect(store.getState().courseHome.courseStatus).toEqual('failed');
});
it('should result in fetch failed if course metadata call errored', async () => {
const datesTabData = Factory.build('datesTabData');
const datesUrl = `${datesBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(datesUrl).reply(200, datesTabData);
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().courseHome.courseStatus).toEqual('failed');
});
it('should result in fetch failed if course metadata call errored', async () => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).networkError();
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().courseHome.courseStatus).toEqual('failed');
});
it('Should fetch, normalize, and save metadata', async () => {
const datesTabData = Factory.build('datesTabData');
@@ -78,18 +101,14 @@ describe('Data layer integration tests', () => {
});
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
'should result in fetch denied if course access is denied, regardless of dates API status',
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);
expect(store.getState().courseHome.courseStatus).toEqual('denied');
},
);
});
@@ -129,18 +148,14 @@ describe('Data layer integration tests', () => {
});
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
'should result in fetch denied if course access is denied, regardless of outline API status',
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);
expect(store.getState().courseHome.courseStatus).toEqual('denied');
},
);
});

View File

@@ -38,28 +38,41 @@ export function fetchTab(courseId, tab, getTabData, targetUserId) {
return async (dispatch) => {
dispatch(fetchTabRequest({ courseId }));
try {
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, 'outline');
dispatch(addModel({
modelType: 'courseHomeMeta',
model: {
id: courseId,
...courseHomeCourseMetadata,
},
}));
const tabDataResult = getTabData && await getTabData(courseId, targetUserId);
if (tabDataResult) {
const promisesToFulfill = [getCourseHomeCourseMetadata(courseId, 'outline')];
if (getTabData) {
promisesToFulfill.push(getTabData(courseId, targetUserId));
}
const [
courseHomeCourseMetadataResult,
tabDataResult,
] = await Promise.allSettled(promisesToFulfill);
if (courseHomeCourseMetadataResult.status === 'fulfilled') {
dispatch(addModel({
modelType: 'courseHomeMeta',
model: {
id: courseId,
...courseHomeCourseMetadataResult.value,
},
}));
}
if (tabDataResult?.status === 'fulfilled') {
dispatch(addModel({
modelType: tab,
model: {
id: courseId,
...tabDataResult,
...tabDataResult.value,
},
}));
}
// Disable the access-denied path for now - it caused a regression
if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
if (courseHomeCourseMetadataResult.status === 'rejected') {
throw courseHomeCourseMetadataResult.reason;
} else if (!courseHomeCourseMetadataResult.value.courseAccess.hasAccess) {
// If the learner does not have access to the course, short cut to dispatch to a denied response regardless of
// the tabDataResult.
dispatch(fetchTabDenied({ courseId }));
} else if (tabDataResult || !getTabData) {
} else if (tabDataResult?.status === 'rejected') {
throw tabDataResult.reason;
} else {
dispatch(fetchTabSuccess({
courseId,
targetUserId,

View File

@@ -23,8 +23,26 @@ import { CERT_STATUS_TYPE } from './alerts/certificate-status-alert/CertificateS
import OutlineTab from './OutlineTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage';
const mockCoursewareSearchParams = jest.fn();
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
jest.mock('../courseware-search/hooks', () => ({
...jest.requireActual('../courseware-search/hooks'),
useCoursewareSearchParams: () => mockCoursewareSearchParams,
}));
const coursewareSearch = {
query: '',
filter: '',
setQuery: jest.fn(),
setFilter: jest.fn(),
clearSearchParams: jest.fn(),
};
const mockSearchParams = ((props = coursewareSearch) => {
mockCoursewareSearchParams.mockReturnValue(props);
});
describe('Outline Tab', () => {
let axiosMock;
@@ -77,9 +95,16 @@ describe('Outline Tab', () => {
expiration_date: null,
});
// Mock courseware search params
mockSearchParams();
logUnhandledRequests(axiosMock);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Course Outline', () => {
it('displays link to start course', async () => {
await fetchAndRender();

View File

@@ -16,8 +16,26 @@ import ProgressTab from './ProgressTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage';
import messages from './grades/messages';
const mockCoursewareSearchParams = jest.fn();
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
jest.mock('../courseware-search/hooks', () => ({
...jest.requireActual('../courseware-search/hooks'),
useCoursewareSearchParams: () => mockCoursewareSearchParams,
}));
const coursewareSearch = {
query: '',
filter: '',
setQuery: jest.fn(),
setFilter: jest.fn(),
clearSearchParams: jest.fn(),
};
const mockSearchParams = ((props = coursewareSearch) => {
mockCoursewareSearchParams.mockReturnValue(props);
});
describe('Progress Tab', () => {
let axiosMock;
@@ -58,9 +76,16 @@ describe('Progress Tab', () => {
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
// Mock courseware search params
mockSearchParams();
logUnhandledRequests(axiosMock);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Related links', () => {
beforeEach(() => {
sendTrackEvent.mockClear();

View File

@@ -15,9 +15,6 @@ const CourseTabsNavigation = ({
return (
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
<div className="float-right">
<CoursewareSearchToggle />
</div>
<div className="container-xl">
<Tabs
className="nav-underline-tabs"
@@ -34,6 +31,9 @@ const CourseTabsNavigation = ({
))}
</Tabs>
</div>
<div className="course-tabs-navigation__search-toggle">
<CoursewareSearchToggle />
</div>
{show && <CoursewareSearch />}
</div>
);

View File

@@ -3,13 +3,20 @@ import { AppProvider } from '@edx/frontend-platform/react';
import {
initializeMockApp, render, screen,
} from '../setupTest';
import { useCoursewareSearchState } from '../course-home/courseware-search/hooks';
import { useCoursewareSearchState, useCoursewareSearchParams } from '../course-home/courseware-search/hooks';
import { CourseTabsNavigation } from './index';
import initializeStore from '../store';
jest.mock('../course-home/courseware-search/hooks');
const mockDispatch = jest.fn();
const coursewareSearch = {
query: '',
filter: '',
setQuery: jest.fn(),
setFilter: jest.fn(),
clearSearchParams: jest.fn(),
};
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
@@ -33,6 +40,7 @@ describe('Course Tabs Navigation', () => {
beforeEach(() => {
useCoursewareSearchState.mockImplementation(() => ({ show: false }));
useCoursewareSearchParams.mockReturnValue(coursewareSearch);
});
afterEach(() => {

View File

@@ -0,0 +1,24 @@
.course-tabs-navigation {
position: relative;
border-bottom: solid 1px #eaeaea;
.nav a,
.nav button {
&:hover {
background-color: $light-400;
}
}
.nav a {
&:not(.active):hover {
background-color: $light-400;
border-bottom: none;
}
}
&__search-toggle {
position: absolute;
top: .05rem;
right: 0;
}
}

View File

@@ -515,5 +515,12 @@ describe('CoursewareContainer', () => {
const startDate = '2/5/2013'; // This date is based on our courseMetadata factory's sample data.
expect(global.location.href).toEqual(`http://localhost/redirect/dashboard?notlive=${startDate}`);
});
it('should go to the enterprise learner dashboard for a course_not_started_enterprise_learner error code', async () => {
setUpWithDeniedStatus('course_not_started_enterprise_learner');
await loadContainer();
expect(global.location.href).toEqual('http://localhost/redirect/enterprise-learner-dashboard');
});
});
});

View File

@@ -29,6 +29,10 @@ const CoursewareRedirectLandingPage = () => (
path={ROUTES.DASHBOARD}
element={<PageWrap><RedirectPage pattern="/dashboard" mode={REDIRECT_MODES.DASHBOARD_REDIRECT} /></PageWrap>}
/>
<Route
path={ROUTES.ENTERPRISE_LEARNER_DASHBOARD}
element={<PageWrap><RedirectPage mode={REDIRECT_MODES.ENTERPRISE_LEARNER_DASHBOARD_REDIRECT} /></PageWrap>}
/>
<Route
path={ROUTES.CONSENT}
element={<PageWrap><RedirectPage mode={REDIRECT_MODES.CONSENT_REDIRECT} /></PageWrap>}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { MemoryRouter as Router } from 'react-router-dom';
import { mergeConfig } from '@edx/frontend-platform';
import { render, initializeMockApp } from '../setupTest';
import CoursewareRedirectLandingPage from './CoursewareRedirectLandingPage';
@@ -11,6 +12,9 @@ jest.mock('../decode-page-route', () => jest.fn(({ children }) => <div>{children
describe('CoursewareRedirectLandingPage', () => {
beforeEach(async () => {
await initializeMockApp();
mergeConfig({
ENTERPRISE_LEARNER_PORTAL_URL: 'http://localhost:8734',
}, 'Add configs for URLs');
delete global.location;
global.location = { assign: redirectUrl };
});
@@ -34,4 +38,14 @@ describe('CoursewareRedirectLandingPage', () => {
expect(redirectUrl).toHaveBeenCalledWith('/course/course-v1:edX+DemoX+Demo_Course/home');
});
it('Redirects to correct enterprise dashboard URL', () => {
render(
<Router initialEntries={['/enterprise-learner-dashboard']}>
<CoursewareRedirectLandingPage />
</Router>,
);
expect(redirectUrl).toHaveBeenCalledWith('http://localhost:8734');
});
});

View File

@@ -14,20 +14,26 @@ const RedirectPage = ({
const location = useLocation();
const { consentPath } = queryString.parse(location?.search);
const BASE_URL = getConfig().LMS_BASE_URL;
const {
LMS_BASE_URL,
ENTERPRISE_LEARNER_PORTAL_URL,
} = getConfig();
switch (mode) {
case REDIRECT_MODES.DASHBOARD_REDIRECT:
global.location.assign(`${BASE_URL}${pattern}${location?.search}`);
global.location.assign(`${LMS_BASE_URL}${pattern}${location?.search}`);
break;
case REDIRECT_MODES.ENTERPRISE_LEARNER_DASHBOARD_REDIRECT:
global.location.assign(ENTERPRISE_LEARNER_PORTAL_URL);
break;
case REDIRECT_MODES.CONSENT_REDIRECT:
global.location.assign(`${BASE_URL}${consentPath}`);
global.location.assign(`${LMS_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 })}`);
global.location.assign(`${LMS_BASE_URL}${generatePath(pattern, { courseId })}`);
}
return null;

View File

@@ -15,6 +15,8 @@ import ContentTools from './content-tools';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import SidebarProvider from './sidebar/SidebarContextProvider';
import SidebarTriggers from './sidebar/SidebarTriggers';
import NewSidebarProvider from './new-sidebar/SidebarContextProvider';
import NewSidebarTriggers from './new-sidebar/SidebarTriggers';
import { useModel } from '../../generic/model-store';
@@ -34,6 +36,7 @@ const Course = ({
} = useModel('courseHomeMeta', courseId);
const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sequence ? sequence.sectionId : null);
const enableNewSidebar = getConfig().ENABLE_NEW_SIDEBAR;
const pageTitleBreadCrumbs = [
sequence,
@@ -64,12 +67,14 @@ const Course = ({
));
}, [sequenceId]);
const SidebarProviderComponent = enableNewSidebar === 'true' ? NewSidebarProvider : SidebarProvider;
return (
<SidebarProvider courseId={courseId} unitId={unitId}>
<SidebarProviderComponent courseId={courseId} unitId={unitId}>
<Helmet>
<title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title>
</Helmet>
<div className="position-relative d-flex align-items-start">
<div className="position-relative d-flex align-items-center mb-4 mt-1">
<CourseBreadcrumbs
courseId={courseId}
sectionId={section ? section.id : null}
@@ -85,8 +90,10 @@ const Course = ({
isStaff={isStaff}
courseId={courseId}
contentToolsEnabled={course.showCalculator || course.notes.enabled}
unitId={unitId}
endDate={course.end ? course.end : ''}
/>
<SidebarTriggers />
{enableNewSidebar === 'true' ? <NewSidebarTriggers /> : <SidebarTriggers /> }
</>
)}
</div>
@@ -112,7 +119,7 @@ const Course = ({
onClose={() => setWeeklyGoalCelebrationOpen(false)}
/>
<ContentTools course={course} />
</SidebarProvider>
</SidebarProviderComponent>
);
};

View File

@@ -1,18 +1,16 @@
import React from 'react';
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 {
act, fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
} from '../../setupTest';
import { buildTopicsFromUnits } from '../data/__factories__/discussionTopics.factory';
import { handleNextSectionCelebration } from './celebration';
import * as celebrationUtils from './celebration/utils';
import { handleNextSectionCelebration } from './celebration';
import Course from './Course';
import { executeThunk } from '../../utils';
import * as thunks from '../data/thunks';
import setupDiscussionSidebar from './test-utils';
jest.mock('@edx/frontend-platform/analytics');
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
@@ -43,8 +41,6 @@ describe('Course', () => {
sequenceId,
unitId: Object.values(models.units)[0].id,
});
getItemSpy = jest.spyOn(Object.getPrototypeOf(window.sessionStorage), 'getItem');
setItemSpy = jest.spyOn(Object.getPrototypeOf(window.sessionStorage), 'setItem');
global.innerWidth = breakpoints.extraLarge.minWidth;
});
@@ -53,25 +49,6 @@ describe('Course', () => {
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 () => {
render(<Course {...mockData} />, { wrapWithRouter: true });
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
@@ -146,17 +123,17 @@ describe('Course', () => {
const discussionsTrigger = await screen.getByRole('button', { name: /Show discussions tray/i });
const discussionsSideBar = await waitFor(() => screen.findByTestId('sidebar-DISCUSSIONS'));
expect(discussionsSideBar).toHaveClass('d-none');
await act(async () => {
fireEvent.click(discussionsTrigger);
});
await expect(discussionsSideBar).not.toHaveClass('d-none');
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 () => {
@@ -177,9 +154,6 @@ describe('Course', () => {
await waitFor(() => {
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toHaveClass('d-none');
const discussionsTrigger = screen.getByRole('button', { name: /Show discussions tray/i });
fireEvent.click(discussionsTrigger);
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
});
@@ -187,53 +161,11 @@ describe('Course', () => {
});
it('handles click to open/close notification tray', async () => {
sessionStorage.clear();
render(<Course {...mockData} />, { wrapWithRouter: true });
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe(null);
await setupDiscussionSidebar();
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
fireEvent.click(notificationShowButton);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('open');
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
});
it('handles reload persisting notification tray status', async () => {
sessionStorage.clear();
render(<Course {...mockData} />, { wrapWithRouter: true });
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe(null);
fireEvent.click(notificationShowButton);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('open');
// Mock reload window, this doesn't happen in the Course component,
// calling the reload to check if the tray remains closed
const { location } = window;
delete window.location;
window.location = { reload: jest.fn() };
window.location.reload();
expect(window.location.reload).toHaveBeenCalled();
window.location = location;
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('open');
expect(screen.queryByTestId('NotificationTray')).not.toBeInTheDocument();
});
it('handles sessionStorage from a different course for the notification tray', async () => {
sessionStorage.clear();
const courseMetadataSecondCourse = Factory.build('courseMetadata', { id: 'second_course' });
// set sessionStorage for a different course before rendering Course
sessionStorage.setItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`, 'open');
render(<Course {...mockData} />, { wrapWithRouter: true });
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe(null);
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
fireEvent.click(notificationShowButton);
// Verify sessionStorage was updated for the original course
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('open');
// Verify the second course sessionStorage was not changed
expect(sessionStorage.getItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`)).toBe('open');
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
});
it('renders course breadcrumbs as expected', async () => {

View File

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

View File

@@ -2,18 +2,16 @@ import { createPortal } from 'react-dom';
import PropTypes from 'prop-types';
import { Xpert } from '@edx/frontend-lib-learning-assistant';
import { hasActiveExamAttempt } from '@edx/frontend-lib-special-exams';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { useEffect, useState } from 'react';
const Chat = ({
enabled,
enrollmentMode,
isStaff,
courseId,
contentToolsEnabled,
unitId,
endDate,
}) => {
const VERIFIED_MODES = [
'professional',
@@ -26,45 +24,30 @@ const Chat = ({
'paid-bootcamp',
];
const AUDIT_MODES = [
'audit',
'honor',
'unpaid-executive-education',
'unpaid-bootcamp',
];
const [hasActiveAttempt, setHasActiveAttempt] = useState(true);
useEffect(() => {
setHasActiveAttempt(hasActiveExamAttempt());
console.log(hasActiveExamAttempt());
}, [hasActiveExamAttempt, setHasActiveAttempt]);
const isEnrolled = (
const hasVerifiedEnrollment = (
enrollmentMode !== null
&& enrollmentMode !== undefined
&& [...VERIFIED_MODES, ...AUDIT_MODES].some(mode => mode === enrollmentMode)
&& [...VERIFIED_MODES].some(mode => mode === enrollmentMode)
);
const endDatePassed = () => {
const date = new Date();
const utcDate = date.toISOString();
return endDate ? utcDate > endDate : false; // evaluate if end date has passed only if course has end date
};
const shouldDisplayChat = (
enabled
&& !hasActiveAttempt
&& (isEnrolled || isStaff) // display only to enrolled or staff
&& (hasVerifiedEnrollment || isStaff) // display only to verified learners or staff
&& !endDatePassed()
);
// 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} />,
<Xpert courseId={courseId} contentToolsEnabled={contentToolsEnabled} unitId={unitId} />,
document.body,
))}
</>
@@ -77,6 +60,8 @@ Chat.propTypes = {
enrollmentMode: PropTypes.string,
courseId: PropTypes.string.isRequired,
contentToolsEnabled: PropTypes.bool.isRequired,
unitId: PropTypes.string.isRequired,
endDate: PropTypes.string.isRequired,
};
Chat.defaultProps = {

View File

@@ -8,8 +8,6 @@ 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';
@@ -18,9 +16,10 @@ 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',
'paid-executive-education', 'paid-bootcamp',
];
const disabledModes = [null, undefined, 'xyz'];
const disabledModes = [null, undefined, 'xyz', 'audit', 'honor', 'unpaid-executive-education', 'unpaid-bootcamp'];
const currentTime = new Date();
describe('Chat', () => {
// Generate test cases.
@@ -46,6 +45,7 @@ describe('Chat', () => {
enabled
courseId={courseId}
contentToolsEnabled={false}
endDate={new Date(currentTime.getTime() + 10 * 60000).toISOString()}
/>
</BrowserRouter>,
{ store },
@@ -79,6 +79,7 @@ describe('Chat', () => {
enabled
courseId={courseId}
contentToolsEnabled={false}
endDate={new Date(currentTime.getTime() + 10 * 60000).toISOString()}
/>
</BrowserRouter>,
{ store },
@@ -140,6 +141,7 @@ describe('Chat', () => {
enabled={test.enabled}
courseId={courseId}
contentToolsEnabled={false}
endDate={new Date(currentTime.getTime() + 10 * 60000).toISOString()}
/>
</BrowserRouter>,
{ store },
@@ -154,4 +156,54 @@ describe('Chat', () => {
},
);
});
it('if course end date has passed, component should not be visible', async () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});
render(
<BrowserRouter>
<Chat
enrollmentMode="verified"
isStaff
enabled
courseId={courseId}
contentToolsEnabled={false}
endDate={new Date(currentTime.getTime() - 10 * 60000).toISOString()}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId('toggle-button');
expect(chat).not.toBeInTheDocument();
});
it('if course has no end date, component should be visible', async () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});
render(
<BrowserRouter>
<Chat
enrollmentMode="verified"
isStaff
enabled
courseId={courseId}
contentToolsEnabled={false}
endDate={null}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId('toggle-button');
expect(chat).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,17 @@
import React, { useContext } from 'react';
import SidebarContext from './SidebarContext';
import { SIDEBARS } from './sidebars';
const Sidebar = () => {
const { currentSidebar } = useContext(SidebarContext);
if (currentSidebar === null) { return null; }
const SidebarToRender = SIDEBARS[currentSidebar].Sidebar;
return (
<SidebarToRender />
);
};
export default Sidebar;

View File

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

View File

@@ -0,0 +1,103 @@
import React, {
useCallback, useEffect, useMemo, useState,
} from 'react';
import PropTypes from 'prop-types';
import isEmpty from 'lodash/isEmpty';
import { breakpoints, useWindowSize } from '@edx/paragon';
import { getLocalStorage, setLocalStorage } from '../../../data/localStorage';
import { useModel } from '../../../generic/model-store';
import WIDGETS from './constants';
import SidebarContext from './SidebarContext';
import { SIDEBARS } from './sidebars';
const SidebarProvider = ({
courseId,
unitId,
children,
}) => {
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
const query = new URLSearchParams(window.location.search);
const initialSidebar = (shouldDisplaySidebarOpen || query.get('sidebar') === 'true')
? SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID : null;
const [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
const [hideDiscussionbar, setHideDiscussionbar] = useState(false);
const [hideNotificationbar, setHideNotificationbar] = useState(false);
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(
getLocalStorage(`upgradeNotificationCurrentState.${courseId}`),
);
const topic = useModel('discussionTopics', unitId);
const { verifiedMode } = useModel('courseHomeMeta', courseId);
const isDiscussionbarAvailable = topic?.id && topic?.enabledInContext;
const isNotificationbarAvailable = !isEmpty(verifiedMode);
const onNotificationSeen = useCallback(() => {
setNotificationStatus('inactive');
setLocalStorage(`notificationStatus.${courseId}`, 'inactive');
}, [courseId]);
useEffect(() => {
setHideDiscussionbar(!isDiscussionbarAvailable);
setHideNotificationbar(!isNotificationbarAvailable);
setCurrentSidebar(SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID);
}, [unitId, topic]);
useEffect(() => {
if (hideDiscussionbar && hideNotificationbar) {
setCurrentSidebar(null);
}
}, [hideDiscussionbar, hideNotificationbar]);
const toggleSidebar = useCallback((sidebarId = null, widgetId = null) => {
if (widgetId) {
setHideDiscussionbar(prevWidgetId => (widgetId === WIDGETS.DISCUSSIONS ? true : prevWidgetId));
setHideNotificationbar(prevWidgetId => (widgetId === WIDGETS.NOTIFICATIONS ? true : prevWidgetId));
} else {
setCurrentSidebar(prevSidebar => (sidebarId === prevSidebar ? null : sidebarId));
setHideDiscussionbar(!isDiscussionbarAvailable);
setHideNotificationbar(!isNotificationbarAvailable);
}
}, [isDiscussionbarAvailable, isNotificationbarAvailable]);
const contextValue = useMemo(() => ({
toggleSidebar,
onNotificationSeen,
setNotificationStatus,
currentSidebar,
notificationStatus,
upgradeNotificationCurrentState,
setUpgradeNotificationCurrentState,
shouldDisplaySidebarOpen,
shouldDisplayFullScreen,
courseId,
unitId,
hideDiscussionbar,
hideNotificationbar,
isNotificationbarAvailable,
isDiscussionbarAvailable,
}), [courseId, currentSidebar, notificationStatus, onNotificationSeen, shouldDisplayFullScreen,
shouldDisplaySidebarOpen, toggleSidebar, unitId, upgradeNotificationCurrentState, hideDiscussionbar,
hideNotificationbar, isNotificationbarAvailable, isDiscussionbarAvailable]);
return (
<SidebarContext.Provider value={contextValue}>
{children}
</SidebarContext.Provider>
);
};
SidebarProvider.propTypes = {
courseId: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
children: PropTypes.node,
};
SidebarProvider.defaultProps = {
children: null,
};
export default SidebarProvider;

View File

@@ -0,0 +1,21 @@
import React, { useContext } from 'react';
import SidebarContext from './SidebarContext';
import { SIDEBAR_ORDER, SIDEBARS } from './sidebars';
const SidebarTriggers = () => {
const { toggleSidebar } = useContext(SidebarContext);
return (
<div className="d-flex ml-auto">
{SIDEBAR_ORDER.map((sidebarId) => {
const { Trigger } = SIDEBARS[sidebarId];
return (
<Trigger onClick={() => toggleSidebar(sidebarId)} key={sidebarId} />
);
})}
</div>
);
};
export default SidebarTriggers;

View File

@@ -0,0 +1,114 @@
import React, { useCallback, useContext } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, IconButton } from '@edx/paragon';
import { ArrowBackIos, Close } from '@edx/paragon/icons';
import { useEventListener } from '../../../../generic/hooks';
import WIDGETS from '../constants';
import messages from '../messages';
import SidebarContext from '../SidebarContext';
const SidebarBase = ({
title,
ariaLabel,
sidebarId,
className,
children,
showTitleBar,
width,
allowFullHeight,
showBorder,
}) => {
const intl = useIntl();
const {
toggleSidebar,
shouldDisplayFullScreen,
currentSidebar,
} = useContext(SidebarContext);
const receiveMessage = useCallback(({ data }) => {
const { type } = data;
if (type === 'learning.events.sidebar.close') {
toggleSidebar(currentSidebar, WIDGETS.DISCUSSIONS);
}
}, [toggleSidebar]);
useEventListener('message', receiveMessage);
return (
<section
className={classNames('ml-0 ml-lg-4 h-auto align-top', {
'min-vh-100': !shouldDisplayFullScreen && allowFullHeight,
'bg-white m-0 border-0 fixed-top vh-100 rounded-0': shouldDisplayFullScreen,
'd-none': currentSidebar !== sidebarId,
'border border-light-400 rounded-sm': showBorder,
}, className)}
data-testid={`sidebar-${sidebarId}`}
style={{ width: shouldDisplayFullScreen ? '100%' : width }}
aria-label={ariaLabel}
>
{shouldDisplayFullScreen
&& (
<div
className="pt-2 pb-2.5 border-bottom border-light-400 d-flex align-items-center ml-2"
onClick={() => toggleSidebar(null)}
onKeyDown={() => toggleSidebar(null)}
role="button"
tabIndex="0"
alt={intl.formatMessage(messages.responsiveCloseSidebarTray)}
>
<Icon src={ArrowBackIos} />
<span className="font-weight-bold m-2 d-inline-block">
{intl.formatMessage(messages.responsiveCloseSidebarTray)}
</span>
</div>
)}
{showTitleBar && (
<>
<div className="d-flex align-items-center">
<span className="p-2.5 d-inline-block">{title}</span>
<div className="d-inline-flex mr-2 mt-1.5 ml-auto">
<IconButton
src={Close}
size="sm"
iconAs={Icon}
onClick={() => toggleSidebar(sidebarId)}
alt={intl.formatMessage(messages.closeTrigger)}
className="icon-hover"
/>
</div>
</div>
<div className="py-1 bg-gray-100 border-top border-bottom border-light-400" />
</>
)}
{children}
</section>
);
};
SidebarBase.propTypes = {
title: PropTypes.string,
ariaLabel: PropTypes.string.isRequired,
sidebarId: PropTypes.string.isRequired,
className: PropTypes.string,
children: PropTypes.element.isRequired,
showTitleBar: PropTypes.bool,
width: PropTypes.string,
allowFullHeight: PropTypes.bool,
showBorder: PropTypes.bool,
};
SidebarBase.defaultProps = {
title: '',
width: '50rem',
allowFullHeight: false,
showTitleBar: true,
className: '',
showBorder: true,
};
export default SidebarBase;

View File

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

View File

@@ -0,0 +1,20 @@
import * as React from 'react';
const RightSidebarFilled = (props) => (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 22V2h20v20H2ZM14 4H4v16h10V4Z"
fill="currentColor"
/>
</svg>
);
export default RightSidebarFilled;

View File

@@ -0,0 +1,20 @@
import * as React from 'react';
const RightSidebarOutlined = (props) => (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 2v20h20V2H2Zm18 2h-4v16h4V4ZM4 4h10v16H4V4Z"
fill="currentColor"
/>
</svg>
);
export default RightSidebarOutlined;

View File

@@ -0,0 +1,2 @@
export { default as RightSidebarFilled } from './RightSidebarFilled';
export { default as RightSidebarOutlined } from './RightSidebarOutlined';

View File

@@ -0,0 +1,36 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
discussionsTitle: {
id: 'discussions.sidebar.title',
defaultMessage: 'Discussions',
description: 'Title text for a forum where users are able to discuss course topics',
},
discussionNotificationTray: {
id: 'discussions.notification.tray.container',
defaultMessage: 'Discussion and Notification tray',
description: 'Discussion and Notification tray container',
},
notificationTitle: {
id: 'notification.tray.title',
defaultMessage: 'Notifications',
description: 'Title text displayed for the notification tray',
},
closeTrigger: {
id: 'tray.close.button',
defaultMessage: 'Close tray',
description: 'Button for the learner to close the sidebar',
},
openSidebarTrigger: {
id: 'sidebar.open.button',
defaultMessage: 'Show sidebar tray',
description: 'Button to open the sidebar tray and shows notifications and didcussions',
},
responsiveCloseSidebarTray: {
id: 'responsive.close.sidebar',
defaultMessage: 'Back to course',
description: 'Responsive button to go back to course and close the sidebar tray',
},
});
export default messages;

View File

@@ -0,0 +1,31 @@
import React, { useContext } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import SidebarBase from '../../common/SidebarBase';
import messages from '../../messages';
import SidebarContext from '../../SidebarContext';
import DiscussionsSidebar from './discussions/DiscussionsWidget';
import NotificationTray from './notifications/NotificationsWidget';
import { ID } from './DiscussionsNotificationsTrigger';
const DiscussionsNotificationsSidebar = () => {
const intl = useIntl();
const { hideNotificationbar } = useContext(SidebarContext);
return (
<SidebarBase
ariaLabel={intl.formatMessage(messages.discussionNotificationTray)}
sidebarId={ID}
className="d-flex flex-column flex-fill"
showTitleBar={false}
showBorder={false}
>
<NotificationTray />
{!hideNotificationbar && <div className="my-1.5" />}
<DiscussionsSidebar />
</SidebarBase>
);
};
export default DiscussionsNotificationsSidebar;

View File

@@ -0,0 +1,97 @@
import React, { useContext, useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, IconButton } from '@edx/paragon';
import { getLocalStorage, setLocalStorage } from '../../../../../data/localStorage';
import { getSessionStorage, setSessionStorage } from '../../../../../data/sessionStorage';
import { useModel } from '../../../../../generic/model-store';
import { getCourseDiscussionTopics } from '../../../../data/thunks';
import { RightSidebarFilled, RightSidebarOutlined } from '../../icons';
import messages from '../../messages';
import SidebarContext from '../../SidebarContext';
export const ID = 'DISCUSSIONS_NOTIFICATIONS';
const DiscussionsNotificationsTrigger = ({ onClick }) => {
const {
courseId,
currentSidebar,
setNotificationStatus,
upgradeNotificationCurrentState,
isNotificationbarAvailable,
isDiscussionbarAvailable,
} = useContext(SidebarContext);
const dispatch = useDispatch();
const intl = useIntl();
const { tabs } = useModel('courseHomeMeta', courseId);
const baseUrl = getConfig().DISCUSSIONS_MFE_BASE_URL;
const edxProvider = useMemo(
() => tabs?.find(tab => tab.slug === 'discussion'),
[tabs],
);
useEffect(() => {
if (baseUrl && edxProvider) {
dispatch(getCourseDiscussionTopics(courseId));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseId, baseUrl, edxProvider]);
/* Re-show a red dot beside the notification trigger for each of the 7 UpgradeNotification stages
The upgradeNotificationCurrentState prop will be available after UpgradeNotification mounts. Once available,
compare with the last state they've seen, and if it's different then set dot back to red */
function updateUpgradeNotificationLastSeen() {
if (upgradeNotificationCurrentState) {
if (getLocalStorage(`upgradeNotificationLastSeen.${courseId}`) !== upgradeNotificationCurrentState) {
setNotificationStatus('active');
setLocalStorage(`notificationStatus.${courseId}`, 'active');
setLocalStorage(`upgradeNotificationLastSeen.${courseId}`, upgradeNotificationCurrentState);
}
}
}
if (!getLocalStorage(`notificationStatus.${courseId}`)) {
setLocalStorage(`notificationStatus.${courseId}`, 'active'); // Show red dot on notificationTrigger until seen
}
if (!getLocalStorage(`upgradeNotificationCurrentState.${courseId}`)) {
setLocalStorage(`upgradeNotificationCurrentState.${courseId}`, 'initialize');
}
useEffect(() => {
updateUpgradeNotificationLastSeen();
});
const handleClick = () => {
if (getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open') {
setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed');
} else {
setSessionStorage(`notificationTrayStatus.${courseId}`, 'open');
}
onClick();
};
if (!isDiscussionbarAvailable && !isNotificationbarAvailable) { return null; }
return (
<IconButton
src={currentSidebar ? RightSidebarFilled : RightSidebarOutlined}
iconAs={Icon}
onClick={handleClick}
alt={intl.formatMessage(messages.openSidebarTrigger)}
className="icon-hover"
/>
);
};
DiscussionsNotificationsTrigger.propTypes = {
onClick: PropTypes.func.isRequired,
};
export default DiscussionsNotificationsTrigger;

View File

@@ -0,0 +1,35 @@
import React, { useContext } from 'react';
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';
import SidebarContext from '../../../SidebarContext';
import messages from '../../../messages';
ensureConfig(['DISCUSSIONS_MFE_BASE_URL']);
const DiscussionsWidget = () => {
const intl = useIntl();
const {
unitId,
courseId,
hideDiscussionbar,
isDiscussionbarAvailable,
shouldDisplayFullScreen,
} = useContext(SidebarContext);
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/category/${unitId}`;
if (hideDiscussionbar || !isDiscussionbarAvailable) { return null; }
return (
<iframe
src={`${discussionsUrl}?inContextSidebar`}
className={classNames('d-flex w-100 flex-fill border border-light-400 rounded-sm', { 'vh-100': !shouldDisplayFullScreen })}
title={intl.formatMessage(messages.discussionsTitle)}
allow="clipboard-write"
loading="lazy"
/>
);
};
export default DiscussionsWidget;

View File

@@ -0,0 +1,80 @@
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
initializeMockApp, initializeTestStore, render, screen,
} from '../../../../../../setupTest';
import { executeThunk } from '../../../../../../utils';
import { buildTopicsFromUnits } from '../../../../../data/__factories__/discussionTopics.factory';
import { getCourseDiscussionTopics } from '../../../../../data/thunks';
import SidebarContext from '../../../SidebarContext';
import DiscussionsNotificationsSidebar from '../DiscussionsNotificationsSidebar';
import DiscussionsWidget from './DiscussionsWidget';
initializeMockApp();
describe('DiscussionsWidget', () => {
let axiosMock;
let mockData;
let courseId;
let unitId;
beforeEach(async () => {
const store = await initializeTestStore({
excludeFetchCourse: false,
excludeFetchSequence: false,
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const state = store.getState();
courseId = state.courseware.courseId;
[unitId] = Object.keys(state.models.units);
mockData = {
courseId,
unitId,
currentSidebar: 'NEWSIDEBAR',
hideDiscussionbar: false,
isDiscussionbarAvailable: true,
};
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/${courseId}`).reply(
200,
{
provider: 'openedx',
},
);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v2/course_topics/${courseId}`)
.reply(200, buildTopicsFromUnits(state.models.units));
await executeThunk(getCourseDiscussionTopics(courseId), store.dispatch);
});
function renderWithProvider(Component, testData = {}) {
const { container } = render(
<SidebarContext.Provider value={{ ...mockData, ...testData }}>
<Component />
</SidebarContext.Provider>,
);
return container;
}
it('should show up if unit discussions associated with it', async () => {
renderWithProvider(DiscussionsWidget);
expect(screen.queryByTitle('Discussions')).toBeInTheDocument();
expect(screen.queryByTitle('Discussions'))
.toHaveAttribute('src', `http://localhost:2002/${courseId}/category/${unitId}?inContextSidebar`);
});
it('should show nothing if unit has no discussions associated with it', async () => {
renderWithProvider(DiscussionsWidget, { isDiscussionbarAvailable: false });
expect(screen.queryByTitle('Discussions')).not.toBeInTheDocument();
});
it('should display the Back to course button on small screens.', async () => {
renderWithProvider(DiscussionsNotificationsSidebar, { shouldDisplayFullScreen: true });
expect(screen.queryByText('Back to course')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,2 @@
export { default as Sidebar } from './DiscussionsNotificationsSidebar';
export { default as Trigger, ID } from './DiscussionsNotificationsTrigger';

View File

@@ -0,0 +1,62 @@
import React, { useContext, useEffect } from 'react';
import { useModel } from '../../../../../../generic/model-store';
import UpgradeNotification from '../../../../../../generic/upgrade-notification/UpgradeNotification';
import WIDGETS from '../../../constants';
import SidebarContext from '../../../SidebarContext';
const NotificationsWidget = () => {
const {
courseId,
onNotificationSeen,
upgradeNotificationCurrentState,
setUpgradeNotificationCurrentState,
hideNotificationbar,
toggleSidebar,
isNotificationbarAvailable,
currentSidebar,
} = useContext(SidebarContext);
const course = useModel('coursewareMeta', courseId);
const {
accessExpiration,
contentTypeGatingEnabled,
marketingUrl,
offer,
timeOffsetMillis,
userTimezone,
} = course;
const {
org,
verifiedMode,
} = useModel('courseHomeMeta', courseId);
// After three seconds, update notificationSeen (to hide red dot)
useEffect(() => { setTimeout(onNotificationSeen, 3000); }, []);
if (hideNotificationbar || !isNotificationbarAvailable) { return null; }
return (
<div className="border border-light-400 rounded-sm" data-testid="notification-widget">
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="in_course"
userTimezone={userTimezone}
shouldDisplayBorder={false}
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
setupgradeNotificationCurrentState={setUpgradeNotificationCurrentState}
toggleSidebar={() => toggleSidebar(currentSidebar, WIDGETS.NOTIFICATIONS)}
/>
</div>
);
};
export default NotificationsWidget;

View File

@@ -0,0 +1,178 @@
/* eslint-disable react/jsx-no-constructed-context-values */
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { mergeConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { breakpoints } from '@edx/paragon';
import {
initializeMockApp, render, screen, within, act, fireEvent, waitFor,
} from '../../../../../../setupTest';
import initializeStore from '../../../../../../store';
import { appendBrowserTimezoneToUrl, executeThunk } from '../../../../../../utils';
import { fetchCourse } from '../../../../../data';
import SidebarContext from '../../../SidebarContext';
import NotificationsWidget from './NotificationsWidget';
import setupDiscussionSidebar from '../../../../test-utils';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
describe('NotificationsWidget', () => {
let axiosMock;
let store;
const ID = 'NEWSIDEBAR';
const defaultMetadata = Factory.build('courseMetadata');
const courseId = defaultMetadata.id;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${defaultMetadata.id}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const courseHomeMetadata = Factory.build('courseHomeMetadata');
const courseHomeMetadataUrl = appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`);
function setMetadata(attributes, options) {
const updatedCourseHomeMetadata = Factory.build('courseHomeMetadata', attributes, options);
axiosMock.onGet(courseHomeMetadataUrl).reply(200, updatedCourseHomeMetadata);
}
async function fetchAndRender(component) {
await executeThunk(fetchCourse(defaultMetadata.id), store.dispatch);
render(component, { store });
}
beforeEach(async () => {
global.innerWidth = breakpoints.large.minWidth;
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
mergeConfig({ ENABLE_NEW_SIDEBAR: 'true' }, 'Custom app config');
});
it('successfully Open/Hide sidebar tray.', async () => {
const userVerifiedMode = Factory.build('verifiedMode');
await setupDiscussionSidebar(userVerifiedMode);
const sidebarButton = await screen.getByRole('button', { name: /Show sidebar tray/i });
await act(async () => {
fireEvent.click(sidebarButton);
});
await waitFor(async () => {
expect(screen.queryByTestId('sidebar-DISCUSSIONS_NOTIFICATIONS')).toBeInTheDocument();
expect(screen.queryByTestId('notification-widget')).toBeInTheDocument();
expect(screen.queryByTitle('Discussions')).toBeInTheDocument();
});
await act(async () => {
fireEvent.click(sidebarButton);
});
await waitFor(async () => {
expect(screen.queryByTestId('sidebar-DISCUSSIONS_NOTIFICATIONS')).not.toBeInTheDocument();
expect(screen.queryByTestId('notification-widget')).not.toBeInTheDocument();
expect(screen.queryByTitle('Discussions')).not.toBeInTheDocument();
});
});
it('renders upgrade card', async () => {
await fetchAndRender(
<SidebarContext.Provider value={{
currentSidebar: ID,
courseId,
hideNotificationbar: false,
isNotificationbarAvailable: true,
}}
>
<NotificationsWidget />
</SidebarContext.Provider>,
);
const UpgradeNotification = document.querySelector('.upgrade-notification');
expect(UpgradeNotification)
.toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Upgrade for $149' }))
.toBeInTheDocument();
expect(screen.queryByText('You have no new notifications at this time.'))
.not
.toBeInTheDocument();
});
it('renders no notifications bar if no verified mode', async () => {
setMetadata({ verified_mode: null });
await fetchAndRender(
<SidebarContext.Provider value={{
currentSidebar: ID,
courseId,
hideNotificationbar: true,
isNotificationbarAvailable: false,
}}
>
<NotificationsWidget />
</SidebarContext.Provider>,
);
expect(screen.queryByText('Notifications'))
.not
.toBeInTheDocument();
});
it.each([
{
description: 'close the notification widget.',
enabledInContext: true,
testId:
'notification-widget',
},
{
description: 'close the sidebar when the notification widget is closed, and the discussion widget is unavailable.',
enabledInContext: false,
testId: 'sidebar-DISCUSSIONS_NOTIFICATIONS',
},
])('successfully %s', async ({ enabledInContext, testId }) => {
const userVerifiedMode = Factory.build('verifiedMode');
await setupDiscussionSidebar(userVerifiedMode, enabledInContext);
const sidebarButton = screen.getByRole('button', { name: /Show sidebar tray/i });
await act(async () => {
fireEvent.click(sidebarButton);
});
const notificationWidget = await waitFor(() => screen.getByTestId('notification-widget'));
const closeNotificationButton = within(notificationWidget).getByRole('button', { name: /Close/i });
await act(async () => {
fireEvent.click(closeNotificationButton);
});
await waitFor(() => {
expect(screen.queryByTestId(testId)).not.toBeInTheDocument();
});
});
it('marks notification as seen 3 seconds later', async () => {
jest.useFakeTimers();
const onNotificationSeen = jest.fn();
await fetchAndRender(
<SidebarContext.Provider value={{
currentSidebar: ID,
courseId,
onNotificationSeen,
hideNotificationbar: false,
isNotificationbarAvailable: true,
}}
>
<NotificationsWidget />
</SidebarContext.Provider>,
);
expect(onNotificationSeen).toHaveBeenCalledTimes(0);
jest.advanceTimersByTime(3000);
expect(onNotificationSeen).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,13 @@
import * as discussionsNotifications from './discussions-notifications';
export const SIDEBARS = {
[discussionsNotifications.ID]: {
ID: discussionsNotifications.ID,
Sidebar: discussionsNotifications.Sidebar,
Trigger: discussionsNotifications.Trigger,
},
};
export const SIDEBAR_ORDER = [
discussionsNotifications.ID,
];

View File

@@ -2,13 +2,14 @@
import React, {
useEffect, useState,
} from 'react';
import { getConfig } from '@edx/frontend-platform';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {
sendTrackEvent,
sendTrackingLogEvent,
} from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
import { breakpoints, useWindowSize } from '@edx/paragon';
@@ -19,7 +20,9 @@ import { useSequenceBannerTextAlert, useSequenceEntranceExamAlert } from '../../
import CourseLicense from '../course-license';
import Sidebar from '../sidebar/Sidebar';
import NewSidebar from '../new-sidebar/Sidebar';
import SidebarTriggers from '../sidebar/SidebarTriggers';
import NewSidebarTriggers from '../new-sidebar/SidebarTriggers';
import messages from './messages';
import HiddenAfterDue from './hidden-after-due';
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
@@ -32,8 +35,8 @@ const Sequence = ({
unitNavigationHandler,
nextSequenceHandler,
previousSequenceHandler,
intl,
}) => {
const intl = useIntl();
const course = useModel('coursewareMeta', courseId);
const {
isStaff,
@@ -44,6 +47,7 @@ const Sequence = ({
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const sequenceMightBeUnit = useSelector(state => state.courseware.sequenceMightBeUnit);
const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth;
const enableNewSidebar = getConfig().ENABLE_NEW_SIDEBAR;
const handleNext = () => {
const nextIndex = sequence.unitIds.indexOf(unitId) + 1;
@@ -159,7 +163,9 @@ const Sequence = ({
handlePrevious();
}}
/>
{shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />}
{shouldDisplayNotificationTriggerInSequence && (
enableNewSidebar === 'true' ? <NewSidebarTriggers /> : <SidebarTriggers />
)}
<div className="unit-container flex-grow-1">
<SequenceContent
@@ -185,7 +191,7 @@ const Sequence = ({
)}
</div>
</div>
<Sidebar />
{enableNewSidebar === 'true' ? <NewSidebar /> : <Sidebar />}
</div>
);
@@ -221,7 +227,6 @@ Sequence.propTypes = {
unitNavigationHandler: PropTypes.func.isRequired,
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
Sequence.defaultProps = {
@@ -229,4 +234,4 @@ Sequence.defaultProps = {
unitId: null,
};
export default injectIntl(Sequence);
export default Sequence;

View File

@@ -3,7 +3,7 @@ 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 { ModalDialog, Modal } from '@edx/paragon';
import PageLoading from '../../../../generic/PageLoading';
import * as hooks from './hooks';
@@ -64,6 +64,21 @@ const ContentIFrame = ({
onLoad: handleIFrameLoad,
};
let modalContent;
if (modalOptions.isOpen) {
modalContent = 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 }}
/>
);
}
return (
<>
{(shouldShowContent && !hasLoaded) && (
@@ -74,23 +89,28 @@ const ContentIFrame = ({
<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
/>
{modalOptions.isOpen && (modalOptions.isFullscreen
? (
<ModalDialog
dialogClassName="modal-lti"
onClose={handleModalClose}
size="fullscreen"
isOpen
hasCloseButton={false}
>
<ModalDialog.Body className={modalOptions.modalBodyClassName}>
{modalContent}
</ModalDialog.Body>
</ModalDialog>
) : (
<Modal
body={modalContent}
dialogClassName="modal-lti"
onClose={handleModalClose}
open
/>
)
)}
</>
);

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { ErrorPage } from '@edx/frontend-platform/react';
import { Modal } from '@edx/paragon';
import { ModalDialog, Modal } from '@edx/paragon';
import { shallow } from '@edx/react-unit-test-utils';
import PageLoading from '../../../../generic/PageLoading';
@@ -11,7 +11,13 @@ import ContentIFrame, { IFRAME_FEATURE_POLICY, testIDs } from './ContentIFrame';
jest.mock('@edx/frontend-platform/react', () => ({ ErrorPage: 'ErrorPage' }));
jest.mock('@edx/paragon', () => ({ Modal: 'Modal' }));
jest.mock('@edx/paragon', () => jest.requireActual('@edx/react-unit-test-utils')
.mockComponents({
Modal: 'Modal',
ModalDialog: {
Body: 'ModalDialog.Body',
},
}));
jest.mock('../../../../generic/PageLoading', () => 'PageLoading');
@@ -140,6 +146,49 @@ describe('ContentIFrame Component', () => {
expect(component.props.onClose).toEqual(modalIFrameData.handleModalClose);
});
};
describe('fullscreen modal', () => {
describe('body modal', () => {
beforeEach(() => {
hooks.useModalIFrameData.mockReturnValueOnce({
...modalIFrameData,
modalOptions: { ...modalOptions.withBody, isFullscreen: true },
});
el = shallow(<ContentIFrame {...props} />);
[component] = el.instance.findByType(ModalDialog);
});
it('displays Modal with div wrapping provided body content if modal.body is provided', () => {
const content = component.findByType(ModalDialog.Body)[0].children[0];
expect(content.matches(shallow(
<div className="unit-modal">{modalOptions.withBody.body}</div>,
))).toEqual(true);
});
testModalOpenAndHandleClose();
});
describe('url modal', () => {
beforeEach(() => {
hooks.useModalIFrameData
.mockReturnValueOnce({
...modalIFrameData,
modalOptions: { ...modalOptions.withUrl, isFullscreen: true },
});
el = shallow(<ContentIFrame {...props} />);
[component] = el.instance.findByType(ModalDialog);
});
testModalOpenAndHandleClose();
it('displays Modal with iframe to provided url if modal.body is not provided', () => {
const content = component.findByType(ModalDialog.Body)[0].children[0];
expect(content.matches(shallow(
<iframe
title={modalOptions.withUrl.title}
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.withUrl.url}
style={{ width: '100%', height: modalOptions.withUrl.height }}
/>,
))).toEqual(true);
});
});
});
describe('body modal', () => {
beforeEach(() => {
hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withBody });

View File

@@ -11,27 +11,35 @@ export const stateKeys = StrictDict({
export const DEFAULT_HEIGHT = '100vh';
const useModalIFrameBehavior = () => {
const useModalIFrameData = () => {
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;
const handleModalClose = () => {
const rootFrame = document.querySelector('iframe');
setIsOpen(false);
rootFrame.contentWindow.postMessage({ type: 'plugin.modal-close' }, '*');
};
const receiveMessage = React.useCallback((event) => {
const { type, payload } = event.data;
if (!type) {
return;
}
if (type === 'plugin.modal') {
setOptions((current) => ({ ...current, ...payload }));
setIsOpen(true);
}
if (type === 'plugin.modal-close') {
handleModalClose();
}
}, []);
useEventListener('message', receiveMessage);
const handleModalClose = () => {
setIsOpen(false);
};
return {
handleModalClose,
modalOptions: { isOpen, ...options },
};
};
export default useModalIFrameBehavior;
export default useModalIFrameData;

View File

@@ -2,7 +2,7 @@ import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import { useEventListener } from '../../../../../generic/hooks';
import { messageTypes } from '../constants';
import useModalIFrameBehavior, { stateKeys, DEFAULT_HEIGHT } from './useModalIFrameData';
import useModalIFrameData, { stateKeys, DEFAULT_HEIGHT } from './useModalIFrameData';
jest.mock('react', () => ({
...jest.requireActual('react'),
@@ -14,44 +14,73 @@ jest.mock('../../../../../generic/hooks', () => ({
const state = mockUseKeyedState(stateKeys);
describe('useModalIFrameBehavior', () => {
describe('useModalIFrameData', () => {
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
const testHandleModalClose = ({ trigger }) => {
const postMessage = jest.fn();
document.querySelector = jest.fn().mockReturnValue({ contentWindow: { postMessage } });
trigger();
state.expectSetStateCalledWith(stateKeys.isOpen, false);
expect(postMessage).toHaveBeenCalledWith({ type: 'plugin.modal-close' }, '*');
};
describe('behavior', () => {
it('initializes isOpen to false', () => {
useModalIFrameBehavior();
useModalIFrameData();
state.expectInitializedWith(stateKeys.isOpen, false);
});
it('initializes options with default height', () => {
useModalIFrameBehavior();
useModalIFrameData();
state.expectInitializedWith(stateKeys.options, { height: DEFAULT_HEIGHT });
});
describe('eventListener', () => {
const oldOptions = { some: 'old', options: 'yeah' };
const prepareListener = () => {
useModalIFrameData();
expect(useEventListener).toHaveBeenCalled();
const call = useEventListener.mock.calls[0][1];
expect(call.prereqs).toEqual([]);
return call.cb;
};
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 receiveMessage = prepareListener();
const payload = { test: 'values' };
cb({ data: { type: messageTypes.modal, payload } });
receiveMessage({ 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 });
});
it('ignores events with no type', () => {
state.mockVals({
[stateKeys.isOpen]: false,
[stateKeys.options]: oldOptions,
});
const receiveMessage = prepareListener();
const payload = { test: 'values' };
receiveMessage({ data: { payload } });
expect(state.setState.isOpen).not.toHaveBeenCalled();
expect(state.setState.options).not.toHaveBeenCalled();
});
it('calls handleModalClose behavior when receiving a "plugin.modal-close" event', () => {
const receiveMessage = prepareListener();
testHandleModalClose({
trigger: () => {
receiveMessage({ data: { type: 'plugin.modal-close' } });
},
});
});
});
});
describe('output', () => {
test('handleModalClose sets modal options to closed', () => {
useModalIFrameBehavior().handleModalClose();
state.expectSetStateCalledWith(stateKeys.isOpen, false);
test('returns handleModalClose callback', () => {
testHandleModalClose({ trigger: useModalIFrameData().handleModalClose });
});
it('forwards modalOptions from state values', () => {
const modalOptions = { test: 'options' };
@@ -59,7 +88,7 @@ describe('useModalIFrameBehavior', () => {
[stateKeys.options]: modalOptions,
[stateKeys.isOpen]: true,
});
expect(useModalIFrameBehavior().modalOptions).toEqual({
expect(useModalIFrameData().modalOptions).toEqual({
...modalOptions,
isOpen: true,
});

View File

@@ -6,7 +6,6 @@ import React, {
import { useModel } from '../../../generic/model-store';
import { getLocalStorage, setLocalStorage } from '../../../data/localStorage';
import { getSessionStorage } from '../../../data/sessionStorage';
import SidebarContext from './SidebarContext';
import { SIDEBARS } from './sidebars';
@@ -17,7 +16,6 @@ const SidebarProvider = ({
children,
}) => {
const { verifiedMode } = useModel('courseHomeMeta', courseId);
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
const query = new URLSearchParams(window.location.search);
@@ -27,12 +25,8 @@ const SidebarProvider = ({
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
useEffect(() => {
// if the user has not purchased the course or previously opened the notification tray, show the notification tray
const showNotificationsOnLoad = !!verifiedMode || getSessionStorage(`notificationTrayStatus.${courseId}`) !== 'closed';
if (showNotificationsOnLoad) {
setCurrentSidebar(SIDEBARS.NOTIFICATIONS.ID);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// if the user hasn't purchased the course, show the notifications sidebar
setCurrentSidebar(verifiedMode ? SIDEBARS.NOTIFICATIONS.ID : SIDEBARS.DISCUSSIONS.ID);
}, [unitId]);
const onNotificationSeen = useCallback(() => {

View File

@@ -1,5 +1,5 @@
import * as notifications from './notifications';
import * as discusssions from './discussions';
import * as discussions from './discussions';
export const SIDEBARS = {
[notifications.ID]: {
@@ -7,14 +7,14 @@ export const SIDEBARS = {
Sidebar: notifications.Sidebar,
Trigger: notifications.Trigger,
},
[discusssions.ID]: {
ID: discusssions.ID,
Sidebar: discusssions.Sidebar,
Trigger: discusssions.Trigger,
[discussions.ID]: {
ID: discussions.ID,
Sidebar: discussions.Sidebar,
Trigger: discussions.Trigger,
},
};
export const SIDEBAR_ORDER = [
discusssions.ID,
discussions.ID,
notifications.ID,
];

View File

@@ -42,6 +42,7 @@ const NotificationTray = ({ intl }) => {
title={intl.formatMessage(messages.notificationTitle)}
ariaLabel={intl.formatMessage(messages.notificationTray)}
sidebarId={ID}
width="50rem"
className={classNames({ 'h-100': !verifiedMode && !shouldDisplayFullScreen })}
>
<div>{verifiedMode

View File

@@ -2,7 +2,6 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import React, { useContext, useEffect } from 'react';
import { getLocalStorage, setLocalStorage } from '../../../../../data/localStorage';
import { getSessionStorage, setSessionStorage } from '../../../../../data/sessionStorage';
import messages from '../../../messages';
import SidebarTriggerBase from '../../common/TriggerBase';
import SidebarContext from '../../SidebarContext';
@@ -47,17 +46,8 @@ const NotificationTrigger = ({
UpdateUpgradeNotificationLastSeen();
});
const handleClick = () => {
if (getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open') {
setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed');
} else {
setSessionStorage(`notificationTrayStatus.${courseId}`, 'open');
}
onClick();
};
return (
<SidebarTriggerBase onClick={handleClick} ariaLabel={intl.formatMessage(messages.openNotificationTrigger)}>
<SidebarTriggerBase onClick={onClick} ariaLabel={intl.formatMessage(messages.openNotificationTrigger)}>
<NotificationIcon status={notificationStatus} notificationColor="bg-danger-500" />
</SidebarTriggerBase>
);

View File

@@ -0,0 +1,49 @@
import React from 'react';
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 { initializeTestStore, render } from '../../setupTest';
import { buildTopicsFromUnits } from '../data/__factories__/discussionTopics.factory';
import { executeThunk } from '../../utils';
import * as thunks from '../data/thunks';
import Course from './Course';
const mockData = {
nextSequenceHandler: () => {},
previousSequenceHandler: () => {},
unitNavigationHandler: () => {},
};
const setupDiscussionSidebar = async (verifiedMode = null, enabledInContext = true) => {
const store = await initializeTestStore();
const { courseware, models } = store.getState();
const { courseId, sequenceId } = courseware;
Object.assign(mockData, {
courseId,
sequenceId,
unitId: Object.values(models.units)[0].id,
});
global.innerWidth = breakpoints.extraLarge.minWidth;
const courseHomeMetadata = Factory.build('courseHomeMetadata', { verified_mode: verifiedMode });
const testStore = await initializeTestStore({ provider: 'openedx', courseHomeMetadata });
const state = testStore.getState();
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, enabledInContext);
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;
const wrapper = await render(<Course {...mockData} />, { store: testStore, wrapWithRouter: true });
return wrapper;
};
export default setupDiscussionSidebar;

View File

@@ -1,6 +1,13 @@
/* eslint-disable import/prefer-default-export */
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('verifiedMode')
.attr('currency', 'USD')
.attr('currencySymbol', '$')
.attr('price', '$149')
.attr('sku', '8CF08E5')
.attr('upgradeUrl', 'http://localhost:18130/basket/add/?sku=8CF08E5');
Factory.define('discussionTopic')
.option('topicPrefix', null, '')
.option('courseId', null, 'course-v1:edX+DemoX+Demo_Course')
@@ -11,13 +18,14 @@ Factory.define('discussionTopic')
['id', 'courseId'],
(idx, id, courseId) => `block-v1:${courseId.replace('course-v1:', '')}+type@vertical+block@${id}`,
)
.attr('enabled_in_context', null, true)
.attr('enabled_in_context', ['enabled_in_context'], (enabledInContext) => Boolean(enabledInContext))
.attr('thread_counts', [], {
discussion: 0,
question: 0,
});
// Given a pre-build units state, build topics from it.
export function buildTopicsFromUnits(units) {
return Object.values(units).map(unit => Factory.build('discussionTopic', { usage_key: unit.id }));
export function buildTopicsFromUnits(units, enabledInContext = true) {
return Object.values(units).map(unit => Factory.build('discussionTopic', { usage_key: unit.id, enabled_in_context: enabledInContext }));
}

View File

@@ -7,10 +7,7 @@
function getSessionStorage(key) {
try {
if (global.sessionStorage) {
const rawItem = global.sessionStorage.getItem(key);
if (rawItem) {
return JSON.parse(rawItem);
}
return global.sessionStorage.getItem(key);
}
} catch (e) {
// If this fails for some reason, just return null.

View File

@@ -1,9 +1,12 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {
useIntl, FormattedDate, FormattedMessage, injectIntl,
} from '@edx/frontend-platform/i18n';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { FormattedDate, FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { Button, Icon, IconButton } from '@edx/paragon';
import { Close } from '@edx/paragon/icons';
import { setLocalStorage } from '../../data/localStorage';
import { UpgradeButton } from '../upgrade-button';
import {
@@ -12,6 +15,7 @@ import {
FullAccessBullet,
SupportMissionBullet,
} from '../upsell-bullets/UpsellBullets';
import messages from '../messages';
const UpsellNoFBECardContent = () => (
<ul className="fa-ul upgrade-notification-ul pt-0">
@@ -122,7 +126,7 @@ const ExpirationCountdown = ({
expirationText = (
<FormattedMessage
id="learning.generic.upgradeNotification.expirationDays"
defaultMessage={`{dayCount, number} {dayCount, plural,
defaultMessage={`{dayCount, number} {dayCount, plural,
one {day}
other {days}} left`}
values={{
@@ -283,7 +287,9 @@ const UpgradeNotification = ({
upsellPageName,
userTimezone,
verifiedMode,
toggleSidebar,
}) => {
const intl = useIntl();
const dateNow = Date.now();
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const correctedTime = new Date(dateNow + timeOffsetMillis);
@@ -483,8 +489,25 @@ const UpgradeNotification = ({
return (
<section className={classNames('upgrade-notification small', { 'card mb-4': shouldDisplayBorder })}>
<div id="courseHome-upgradeNotification">
<h2 className="h5 upgrade-notification-header" id="outline-sidebar-upgrade-header">
<h2
className={classNames('h5 upgrade-notification-header', {
'd-flex align-items-center mr-2 ml-4 my-1.5 font-size-18': !!toggleSidebar,
})}
id="outline-sidebar-upgrade-header"
>
{upgradeNotificationHeaderText}
{!!toggleSidebar && (
<div className="d-inline-flex ml-auto">
<IconButton
src={Close}
size="sm"
iconAs={Icon}
onClick={toggleSidebar}
className="icon-hover"
alt={intl.formatMessage(messages.close)}
/>
</div>
)}
</h2>
{expirationBanner}
<div className="upgrade-notification-message">
@@ -512,6 +535,7 @@ UpgradeNotification.propTypes = {
percentage: PropTypes.number,
code: PropTypes.string,
}),
toggleSidebar: PropTypes.func,
shouldDisplayBorder: PropTypes.bool,
setupgradeNotificationCurrentState: PropTypes.func,
timeOffsetMillis: PropTypes.number,
@@ -534,6 +558,7 @@ UpgradeNotification.defaultProps = {
timeOffsetMillis: 0,
userTimezone: null,
verifiedMode: null,
toggleSidebar: null,
};
export default injectIntl(UpgradeNotification);

View File

@@ -41,3 +41,7 @@
padding-top: .75rem;
padding-bottom: .75rem;
}
.font-size-18 {
font-size: 18px !important;
}

View File

@@ -154,6 +154,7 @@ initialize({
DISCUSSIONS_MFE_BASE_URL: process.env.DISCUSSIONS_MFE_BASE_URL || null,
ENTERPRISE_LEARNER_PORTAL_HOSTNAME: process.env.ENTERPRISE_LEARNER_PORTAL_HOSTNAME || null,
ENABLE_JUMPNAV: process.env.ENABLE_JUMPNAV || null,
ENABLE_NEW_SIDEBAR: process.env.ENABLE_NEW_SIDEBAR || null,
ENABLE_NOTICES: process.env.ENABLE_NOTICES || null,
INSIGHTS_BASE_URL: process.env.INSIGHTS_BASE_URL || null,
SEARCH_CATALOG_URL: process.env.SEARCH_CATALOG_URL || null,

View File

@@ -4,7 +4,7 @@
@import "~@edx/brand/paragon/overrides";
@import "~@edx/frontend-component-footer/dist/footer";
@import "~@edx/frontend-component-header/dist/index";
@import "@edx/frontend-component-header/dist/index";
#root {
@@ -38,24 +38,6 @@
}
}
.course-tabs-navigation {
border-bottom: solid 1px #eaeaea;
.nav a,
.nav button {
&:hover {
background-color: $light-400;
}
}
.nav a {
&:not(.active):hover {
background-color: $light-400;
border-bottom: none;
}
}
}
.nav-underline-tabs {
margin: 0 0 -1px;
@@ -379,6 +361,13 @@
box-shadow: 0 .0625rem .125rem rgba(0, 0, 0, .2) !important;
}
.icon-hover {
&:hover {
color: $primary-500 !important;
background-color: $light-300 !important;
}
}
// Import component-specific sass files
@import "courseware/course/celebration/CelebrationModal.scss";
@import "courseware/course/sidebar/sidebars/notifications/NotificationIcon.scss";
@@ -396,3 +385,4 @@
@import "courseware/course/course-exit/CourseRecommendations";
@import "product-tours/newUserCourseHomeTour/NewUserCourseHomeTourModal.scss";
@import "course-home/courseware-search/courseware-search.scss";
@import "course-tabs/course-tabs-navigation.scss";

View File

@@ -15,6 +15,9 @@ export function getAccessDeniedRedirectUrl(courseId, activeTabSlug, courseAccess
const startDate = (new Intl.DateTimeFormat(getLocale())).format(new Date(start));
url = `/redirect/dashboard?notlive=${startDate}`;
break;
case 'course_not_started_enterprise_learner':
url = '/redirect/enterprise-learner-dashboard';
break;
case 'survey_required':
url = `/redirect/survey/${courseId}`;
break;