Compare commits
31 Commits
alangsto/e
...
manwar/VAN
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6162174be8 | ||
|
|
55e2332b00 | ||
|
|
25d746b7c8 | ||
|
|
e790e2636f | ||
|
|
c428222125 | ||
|
|
58365ba18e | ||
|
|
ab87167052 | ||
|
|
4928f505bd | ||
|
|
59a68afa5d | ||
|
|
486faa5744 | ||
|
|
023f5ac254 | ||
|
|
4dc4725ba1 | ||
|
|
600f5b4dff | ||
|
|
06d79ce16d | ||
|
|
b3a06fd07e | ||
|
|
684ac495f5 | ||
|
|
79575330c9 | ||
|
|
2bf326fc67 | ||
|
|
e004ead21d | ||
|
|
4d723335bf | ||
|
|
d065c23e32 | ||
|
|
83c600737b | ||
|
|
a30dccd3cf | ||
|
|
4e1346716b | ||
|
|
5906576c6e | ||
|
|
d7cffcd414 | ||
|
|
cf49d92951 | ||
|
|
2fd5e92d2d | ||
|
|
bce25c462a | ||
|
|
fe773d1700 | ||
|
|
748e73d128 |
1
.env
1
.env
@@ -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=''
|
||||
|
||||
@@ -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=''
|
||||
|
||||
@@ -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'
|
||||
|
||||
6
Makefile
6
Makefile
@@ -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
4
global-setup.js
Normal 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';
|
||||
};
|
||||
@@ -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
435
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"rebaseStalePrs": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["@edx"],
|
||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}); */
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
24
src/course-tabs/course-tabs-navigation.scss
Normal file
24
src/course-tabs/course-tabs-navigation.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
17
src/courseware/course/new-sidebar/Sidebar.jsx
Normal file
17
src/courseware/course/new-sidebar/Sidebar.jsx
Normal 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;
|
||||
5
src/courseware/course/new-sidebar/SidebarContext.js
Normal file
5
src/courseware/course/new-sidebar/SidebarContext.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
const SidebarContext = React.createContext({});
|
||||
|
||||
export default SidebarContext;
|
||||
103
src/courseware/course/new-sidebar/SidebarContextProvider.jsx
Normal file
103
src/courseware/course/new-sidebar/SidebarContextProvider.jsx
Normal 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;
|
||||
21
src/courseware/course/new-sidebar/SidebarTriggers.jsx
Normal file
21
src/courseware/course/new-sidebar/SidebarTriggers.jsx
Normal 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;
|
||||
114
src/courseware/course/new-sidebar/common/SidebarBase.jsx
Normal file
114
src/courseware/course/new-sidebar/common/SidebarBase.jsx
Normal 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;
|
||||
6
src/courseware/course/new-sidebar/constants.js
Normal file
6
src/courseware/course/new-sidebar/constants.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const WIDGETS = {
|
||||
DISCUSSIONS: 'DISCUSSIONS',
|
||||
NOTIFICATIONS: 'NOTIFICATIONS',
|
||||
};
|
||||
|
||||
export default WIDGETS;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
2
src/courseware/course/new-sidebar/icons/index.js
Normal file
2
src/courseware/course/new-sidebar/icons/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as RightSidebarFilled } from './RightSidebarFilled';
|
||||
export { default as RightSidebarOutlined } from './RightSidebarOutlined';
|
||||
36
src/courseware/course/new-sidebar/messages.js
Normal file
36
src/courseware/course/new-sidebar/messages.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Sidebar } from './DiscussionsNotificationsSidebar';
|
||||
export { default as Trigger, ID } from './DiscussionsNotificationsTrigger';
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
13
src/courseware/course/new-sidebar/sidebars/index.js
Normal file
13
src/courseware/course/new-sidebar/sidebars/index.js
Normal 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,
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
49
src/courseware/course/test-utils.jsx
Normal file
49
src/courseware/course/test-utils.jsx
Normal 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;
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -41,3 +41,7 @@
|
||||
padding-top: .75rem;
|
||||
padding-bottom: .75rem;
|
||||
}
|
||||
|
||||
.font-size-18 {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user