Compare commits
31 Commits
dependabot
...
renovate/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe07cd3cb7 | ||
|
|
1efd559786 | ||
|
|
df79861685 | ||
|
|
24e1c73f6b | ||
|
|
449af65d01 | ||
|
|
fce65c0215 | ||
|
|
abcef4a502 | ||
|
|
5c1cdcf01c | ||
|
|
4dccc12883 | ||
|
|
f0e735b3a1 | ||
|
|
091d9a1c3e | ||
|
|
7157d17a4e | ||
|
|
65096fde0e | ||
|
|
af204c78a5 | ||
|
|
2e6209314f | ||
|
|
060f7d4618 | ||
|
|
815b80a944 | ||
|
|
e8cd7c2dcc | ||
|
|
c4a09a2b43 | ||
|
|
3599630cd7 | ||
|
|
56726448fc | ||
|
|
b57386b9b6 | ||
|
|
e9bf4566de | ||
|
|
1324b33789 | ||
|
|
b256036527 | ||
|
|
ee6006e0e3 | ||
|
|
8f8c6d8dd2 | ||
|
|
7c1eb59f18 | ||
|
|
5ccf39d130 | ||
|
|
42f26e7404 | ||
|
|
01ddf0d2ad |
16
.github/workflows/validate.yml
vendored
16
.github/workflows/validate.yml
vendored
@@ -17,29 +17,17 @@ jobs:
|
||||
node-version-file: '.nvmrc'
|
||||
- run: make validate.ci
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: code-coverage-report
|
||||
path: coverage/*.*
|
||||
# We are trying out oxlint for a while. Please report if you ever see lint issues that eslint catches but oxlint
|
||||
# misses. We expect the opposite (oxlint should catch more issues).
|
||||
lint-preview:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
- run: npm install
|
||||
- run: npm run oxlint
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: code-coverage-report
|
||||
path: coverage
|
||||
|
||||
4
Makefile
4
Makefile
@@ -51,7 +51,9 @@ validate-no-uncommitted-package-lock-changes:
|
||||
validate:
|
||||
make validate-no-uncommitted-package-lock-changes
|
||||
npm run i18n_extract
|
||||
npm run lint -- --max-warnings 0
|
||||
# We are trying out oxlint. Now that it's been working well for a while with both oxlint and eslint, we have disabled
|
||||
# eslint, and after a few weeks we'll evaluate whether any problems are slipping through if only oxlint is used.
|
||||
npm run oxlint
|
||||
npm run types
|
||||
npm run test:ci
|
||||
npm run build
|
||||
|
||||
142
package-lock.json
generated
142
package-lock.json
generated
@@ -27,6 +27,7 @@
|
||||
"@edx/frontend-platform": "^8.4.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
||||
"@openedx-plugins/course-app-dates": "file:plugins/course-apps/dates",
|
||||
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
||||
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
||||
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
|
||||
@@ -94,7 +95,7 @@
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"oxlint": "^1.42.0",
|
||||
"oxlint-tsgolint": "^0.11.2",
|
||||
"oxlint-tsgolint": "^0.16.0",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"redux-mock-store": "^1.5.4"
|
||||
}
|
||||
@@ -2304,9 +2305,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.4.tgz",
|
||||
"integrity": "sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==",
|
||||
"version": "6.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
|
||||
"integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -2335,9 +2336,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.39.14",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.14.tgz",
|
||||
"integrity": "sha512-WJcvgHm/6Q7dvGT0YFv/6PSkoc36QlR0VCESS6x9tGsnF1lWLmmYxOgX3HH6v8fo6AvSLgpcs+H0Olre6MKXlg==",
|
||||
"version": "6.39.16",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.16.tgz",
|
||||
"integrity": "sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
@@ -2664,15 +2665,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-platform": {
|
||||
"version": "8.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-8.5.4.tgz",
|
||||
"integrity": "sha512-dGzTJ8slx81qAEhVK/sysrwChzrFSpQ9CMaBSYZUOHPS1D4Ww6lhtttDNvBtqcKrwRjzSSseZPuLiKG9ybHSGw==",
|
||||
"version": "8.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-8.5.5.tgz",
|
||||
"integrity": "sha512-imExY37cxE7qzKYg3gaqcdfhc0rzpV1DEFmy6PPCJg4m+cycQNiXtAKl3nITkcQkzhV0JYh3qttEgq6d4a1QXw==",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@cospired/i18n-iso-languages": "4.2.0",
|
||||
"@formatjs/intl-pluralrules": "4.3.3",
|
||||
"@formatjs/intl-relativetimeformat": "10.0.1",
|
||||
"axios": "1.13.2",
|
||||
"axios": "1.13.5",
|
||||
"axios-cache-interceptor": "1.11.4",
|
||||
"form-urlencoded": "4.1.4",
|
||||
"glob": "7.2.3",
|
||||
@@ -2710,17 +2711,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-platform/node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-platform/node_modules/cookie": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz",
|
||||
@@ -5170,6 +5160,10 @@
|
||||
"resolved": "plugins/course-apps/calculator",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@openedx-plugins/course-app-dates": {
|
||||
"resolved": "plugins/course-apps/dates",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@openedx-plugins/course-app-edxnotes": {
|
||||
"resolved": "plugins/course-apps/edxnotes",
|
||||
"link": true
|
||||
@@ -5580,9 +5574,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/darwin-arm64": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.11.2.tgz",
|
||||
"integrity": "sha512-LXQ47SH4MjzgI8xXMMB22N9G6yXojL8YNemPgvwtMw37by5H2rOBXsdViX2r0ubV75ak1/7GlxVAFEKQ9lc+Dw==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.16.0.tgz",
|
||||
"integrity": "sha512-WQt5lGwRPJBw7q2KNR0mSPDAaMmZmVvDlEEti96xLO7ONhyomQc6fBZxxwZ4qTFedjJnrHX94sFelZ4OKzS7UQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5594,9 +5588,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/darwin-x64": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.11.2.tgz",
|
||||
"integrity": "sha512-am1cy2mhq56DhG5gdErCfAnHYr2JiJIxRtRyXfAkAVekteaAwRwK1OytjO7s455oGNUVKPD3M8bkEJ3L/FWk8A==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.16.0.tgz",
|
||||
"integrity": "sha512-VJo29XOzdkalvCTiE2v6FU3qZlgHaM8x8hUEVJGPU2i5W+FlocPpmn00+Ld2n7Q0pqIjyD5EyvZ5UmoIEJMfqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5608,9 +5602,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/linux-arm64": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.11.2.tgz",
|
||||
"integrity": "sha512-KNMXweLVdUevvi7XvDiiJbQSBKZQmRyBAwS2G8R32AxUusdDccmt0yB++0nH5WN+U5tLLEa0BlkaVTVHhxThAw==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.16.0.tgz",
|
||||
"integrity": "sha512-MPfqRt1+XRHv9oHomcBMQ3KpTE+CSkZz14wUxDQoqTNdUlV0HWdzwIE9q65I3D9YyxEnqpM7j4qtDQ3apqVvbQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5622,9 +5616,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/linux-x64": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.11.2.tgz",
|
||||
"integrity": "sha512-bkKayG26rLua4RVhtZOk8GbplBTTD9k+NI8EA+qwP7TSC3ndtIlj/LHNo17+DPa4IYrhd+2vLsUxTvGh7TeTgg==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.16.0.tgz",
|
||||
"integrity": "sha512-XQSwVUsnwLokMhe1TD6IjgvW5WMTPzOGGkdFDtXWQmlN2YeTw94s/NN0KgDrn2agM1WIgAenEkvnm0u7NgwEyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5636,9 +5630,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/win32-arm64": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.11.2.tgz",
|
||||
"integrity": "sha512-0imJQy2VhFeOms961lgAEbmlr3LdepBb2ClWYeu0HPc8Mi05x/bT4BReFY7L4gdctajYIrKDS2Dzp2zEqeHn1g==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.16.0.tgz",
|
||||
"integrity": "sha512-EWdlspQiiFGsP2AiCYdhg5dTYyAlj6y1nRyNI2dQWq4Q/LITFHiSRVPe+7m7K7lcsZCEz2icN/bCeSkZaORqIg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5650,9 +5644,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/win32-x64": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.11.2.tgz",
|
||||
"integrity": "sha512-kAYRB8WP+t6TRzO/4DALoggtw8NjE6mPk8VzEOK3EJRtE3Pdo1fdVVCE2xaPctQEf7JZ+1D55ZNLnTR7lT8Bxg==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.16.0.tgz",
|
||||
"integrity": "sha512-1ufk8cgktXJuJZHKF63zCHAkaLMwZrEXnZ89H2y6NO85PtOXqu4zbdNl0VBpPP3fCUuUBu9RvNqMFiv0VsbXWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -7063,9 +7057,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
|
||||
"version": "4.17.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
|
||||
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
@@ -8601,14 +8595,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
|
||||
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -8985,12 +8978,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
|
||||
"version": "2.10.8",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
|
||||
"integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/batch": {
|
||||
@@ -9365,9 +9361,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001770",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz",
|
||||
"integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==",
|
||||
"version": "1.0.30001779",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz",
|
||||
"integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -17895,21 +17891,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oxlint-tsgolint": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.11.2.tgz",
|
||||
"integrity": "sha512-CgtoZ4vAQCWYaJwQRPIFp6aId+db/s1cgIPJky7Sx8hA/nEO/ZSfvL4bee1GmldU84GcVC8nNiF6FJEdj2xDEw==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.16.0.tgz",
|
||||
"integrity": "sha512-4RuJK2jP08XwqtUu+5yhCbxEauCm6tv2MFHKEMsjbosK2+vy5us82oI3VLuHwbNyZG7ekZA26U2LLHnGR4frIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tsgolint": "bin/tsgolint.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxlint-tsgolint/darwin-arm64": "0.11.2",
|
||||
"@oxlint-tsgolint/darwin-x64": "0.11.2",
|
||||
"@oxlint-tsgolint/linux-arm64": "0.11.2",
|
||||
"@oxlint-tsgolint/linux-x64": "0.11.2",
|
||||
"@oxlint-tsgolint/win32-arm64": "0.11.2",
|
||||
"@oxlint-tsgolint/win32-x64": "0.11.2"
|
||||
"@oxlint-tsgolint/darwin-arm64": "0.16.0",
|
||||
"@oxlint-tsgolint/darwin-x64": "0.16.0",
|
||||
"@oxlint-tsgolint/linux-arm64": "0.16.0",
|
||||
"@oxlint-tsgolint/linux-x64": "0.16.0",
|
||||
"@oxlint-tsgolint/win32-arm64": "0.16.0",
|
||||
"@oxlint-tsgolint/win32-x64": "0.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
@@ -24725,6 +24721,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins/course-apps/dates": {
|
||||
"name": "@openedx-plugins/course-app-dates",
|
||||
"version": "0.1.0",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins/course-apps/edxnotes": {
|
||||
"name": "@openedx-plugins/course-app-edxnotes",
|
||||
"version": "0.1.0",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"@edx/frontend-platform": "^8.4.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
||||
"@openedx-plugins/course-app-dates": "file:plugins/course-apps/dates",
|
||||
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
||||
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
||||
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
|
||||
@@ -83,7 +84,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-datepicker": "^8.10.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-onclickoutside": "^6.13.0",
|
||||
"react-redux": "7.2.9",
|
||||
@@ -118,7 +119,7 @@
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"oxlint": "^1.42.0",
|
||||
"oxlint-tsgolint": "^0.11.2",
|
||||
"oxlint-tsgolint": "^0.16.0",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"redux-mock-store": "^1.5.4"
|
||||
}
|
||||
|
||||
29
plugins/course-apps/dates/Settings.tsx
Normal file
29
plugins/course-apps/dates/Settings.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
type DatesSettingsProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const DatesSettings: React.FC<DatesSettingsProps> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId="dates"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableAppHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableAppLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.learnMore)}
|
||||
onClose={onClose}
|
||||
validationSchema={{}}
|
||||
initialValues={{}}
|
||||
onSettingsSave={async () => true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatesSettings;
|
||||
26
plugins/course-apps/dates/messages.ts
Normal file
26
plugins/course-apps/dates/messages.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'course-authoring.pages-resources.dates.heading',
|
||||
defaultMessage: 'Configure dates',
|
||||
description: 'Heading for the Dates settings modal shown in Pages & Resources.',
|
||||
},
|
||||
enableAppLabel: {
|
||||
id: 'course-authoring.pages-resources.dates.enable-app.label',
|
||||
defaultMessage: 'Dates',
|
||||
description: 'Label for the toggle that enables the Dates experience.',
|
||||
},
|
||||
enableAppHelp: {
|
||||
id: 'course-authoring.pages-resources.dates.enable-app.help',
|
||||
defaultMessage: 'Show the Dates tab in course navigation, where learners can view important course dates.',
|
||||
description: 'Helper text explaining what enabling the Dates experience does.',
|
||||
},
|
||||
learnMore: {
|
||||
id: 'course-authoring.pages-resources.dates.learn-more',
|
||||
defaultMessage: 'Learn more about dates',
|
||||
description: 'Link text that leads to documentation about the Dates experience.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
17
plugins/course-apps/dates/package.json
Normal file
17
plugins/course-apps/dates/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-dates",
|
||||
"version": "0.1.0",
|
||||
"description": "Dates configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,17 @@ import {
|
||||
} from 'react';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks';
|
||||
import { getCourseItem } from '@src/course-outline/data/api';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
addSection, addSubsection, addUnit, updateSavingStatus,
|
||||
} from '@src/course-outline/data/slice';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { getOutlineIndexData } from '@src/course-outline/data/selectors';
|
||||
import { useToggleWithValue } from '@src/hooks';
|
||||
import { SelectionState, type UnitXBlock, type XBlock } from '@src/data/types';
|
||||
import { CourseDetailsData } from './data/api';
|
||||
import { useCourseDetails, useWaffleFlags } from './data/apiHooks';
|
||||
import { RequestStatus, RequestStatusType } from './data/constants';
|
||||
import { RequestStatusType } from './data/constants';
|
||||
|
||||
type ModalState = {
|
||||
value: XBlock | UnitXBlock;
|
||||
value?: XBlock | UnitXBlock;
|
||||
subsectionId?: string;
|
||||
sectionId?: string;
|
||||
};
|
||||
@@ -30,10 +26,8 @@ export type CourseAuthoringContextData = {
|
||||
courseDetails?: CourseDetailsData;
|
||||
courseDetailStatus: RequestStatusType;
|
||||
canChangeProviders: boolean;
|
||||
handleAddSection: ReturnType<typeof useCreateCourseBlock>;
|
||||
handleAddSubsection: ReturnType<typeof useCreateCourseBlock>;
|
||||
handleAddAndOpenUnit: ReturnType<typeof useCreateCourseBlock>;
|
||||
handleAddUnit: ReturnType<typeof useCreateCourseBlock>;
|
||||
handleAddBlock: ReturnType<typeof useCreateCourseBlock>;
|
||||
openUnitPage: (locator: string) => void;
|
||||
getUnitUrl: (locator: string) => string;
|
||||
isUnlinkModalOpen: boolean;
|
||||
@@ -66,7 +60,6 @@ export const CourseAuthoringProvider = ({
|
||||
children,
|
||||
courseId,
|
||||
}: CourseAuthoringProviderProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const waffleFlags = useWaffleFlags();
|
||||
const { data: courseDetails, status: courseDetailStatus } = useCourseDetails(courseId);
|
||||
@@ -114,47 +107,11 @@ export const CourseAuthoringProvider = ({
|
||||
window.location.assign(url);
|
||||
}
|
||||
};
|
||||
|
||||
const addSectionToCourse = /* istanbul ignore next */ async (locator: string) => {
|
||||
try {
|
||||
const data = await getCourseItem(locator);
|
||||
// Page should scroll to newly added section.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addSection(data));
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
|
||||
const addSubsectionToCourse = /* istanbul ignore next */ async (locator: string, parentLocator: string) => {
|
||||
try {
|
||||
const data = await getCourseItem(locator);
|
||||
// Page should scroll to newly added subsection.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addSubsection({ parentLocator, data }));
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
|
||||
const addUnitToCourse = /* istanbul ignore next */ async (locator: string, parentLocator: string) => {
|
||||
try {
|
||||
const data = await getCourseItem(locator);
|
||||
// Page should scroll to newly added subsection.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addUnit({ parentLocator, data }));
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSection = useCreateCourseBlock(addSectionToCourse);
|
||||
const handleAddSubsection = useCreateCourseBlock(addSubsectionToCourse);
|
||||
/**
|
||||
* import a unit block from library and redirect user to this unit page.
|
||||
*/
|
||||
const handleAddAndOpenUnit = useCreateCourseBlock(openUnitPage);
|
||||
const handleAddUnit = useCreateCourseBlock(addUnitToCourse);
|
||||
const handleAddAndOpenUnit = useCreateCourseBlock(courseId, openUnitPage);
|
||||
const handleAddBlock = useCreateCourseBlock(courseId);
|
||||
|
||||
const context = useMemo<CourseAuthoringContextData>(() => ({
|
||||
courseId,
|
||||
@@ -162,9 +119,7 @@ export const CourseAuthoringProvider = ({
|
||||
courseDetails,
|
||||
courseDetailStatus,
|
||||
canChangeProviders,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddBlock,
|
||||
handleAddAndOpenUnit,
|
||||
getUnitUrl,
|
||||
openUnitPage,
|
||||
@@ -184,9 +139,7 @@ export const CourseAuthoringProvider = ({
|
||||
courseDetails,
|
||||
courseDetailStatus,
|
||||
canChangeProviders,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddBlock,
|
||||
handleAddAndOpenUnit,
|
||||
getUnitUrl,
|
||||
openUnitPage,
|
||||
|
||||
@@ -31,6 +31,8 @@ import GroupConfigurations from './group-configurations';
|
||||
import { CourseLibraries } from './course-libraries';
|
||||
import { IframeProvider } from './generic/hooks/context/iFrameContext';
|
||||
import { CourseAuthoringProvider } from './CourseAuthoringContext';
|
||||
import { CourseImportProvider } from './import-page/CourseImportContext';
|
||||
import { CourseExportProvider } from './export-page/CourseExportContext';
|
||||
|
||||
/**
|
||||
* As of this writing, these routes are mounted at a path prefixed with the following:
|
||||
@@ -141,11 +143,23 @@ const CourseAuthoringRoutes = () => {
|
||||
/>
|
||||
<Route
|
||||
path="import"
|
||||
element={<PageWrap><CourseImportPage /></PageWrap>}
|
||||
element={(
|
||||
<PageWrap>
|
||||
<CourseImportProvider>
|
||||
<CourseImportPage />
|
||||
</CourseImportProvider>
|
||||
</PageWrap>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="export"
|
||||
element={<PageWrap><CourseExportPage /></PageWrap>}
|
||||
element={(
|
||||
<PageWrap>
|
||||
<CourseExportProvider>
|
||||
<CourseExportPage />
|
||||
</CourseExportProvider>
|
||||
</PageWrap>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="optimizer"
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
|
||||
import {
|
||||
render as baseRender,
|
||||
fireEvent,
|
||||
initializeMocks,
|
||||
waitFor,
|
||||
} from '../testUtils';
|
||||
import { executeThunk } from '../utils';
|
||||
import { advancedSettingsMock } from './__mocks__';
|
||||
import { getCourseAdvancedSettingsApiUrl } from './data/api';
|
||||
import { updateCourseAppSetting } from './data/thunks';
|
||||
import AdvancedSettings from './AdvancedSettings';
|
||||
import messages from './messages';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
|
||||
// Mock the TextareaAutosize component
|
||||
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
<textarea
|
||||
{...props}
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
)));
|
||||
|
||||
const render = () => baseRender(
|
||||
<CourseAuthoringProvider courseId={courseId}>
|
||||
<AdvancedSettings />
|
||||
</CourseAuthoringProvider>,
|
||||
{ path: mockPathname },
|
||||
);
|
||||
|
||||
describe('<AdvancedSettings />', () => {
|
||||
beforeEach(() => {
|
||||
const mocks = initializeMocks();
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
axiosMock
|
||||
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
|
||||
.reply(200, advancedSettingsMock);
|
||||
});
|
||||
it('should render without errors', async () => {
|
||||
const { getByText } = render();
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
const advancedSettingsElement = getByText(messages.headingTitle.defaultMessage, {
|
||||
selector: 'h2.sub-header-title',
|
||||
});
|
||||
expect(advancedSettingsElement).toBeInTheDocument();
|
||||
expect(getByText(messages.policy.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should render setting element', async () => {
|
||||
const { getByText, queryByText } = render();
|
||||
await waitFor(() => {
|
||||
const advancedModuleListTitle = getByText(/Advanced Module List/i);
|
||||
expect(advancedModuleListTitle).toBeInTheDocument();
|
||||
expect(queryByText('Certificate web/html view enabled')).toBeNull();
|
||||
});
|
||||
});
|
||||
it('should change to onСhange', async () => {
|
||||
const { getByLabelText } = render();
|
||||
await waitFor(() => {
|
||||
const textarea = getByLabelText(/Advanced Module List/i);
|
||||
expect(textarea).toBeInTheDocument();
|
||||
fireEvent.change(textarea, { target: { value: '[1, 2, 3]' } });
|
||||
expect(textarea.value).toBe('[1, 2, 3]');
|
||||
});
|
||||
});
|
||||
it('should display a warning alert', async () => {
|
||||
const { getByLabelText, getByText } = render();
|
||||
await waitFor(() => {
|
||||
const textarea = getByLabelText(/Advanced Module List/i);
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
expect(getByText(messages.buttonCancelText.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.buttonSaveText.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.alertWarning.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.alertWarningDescriptions.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should display a tooltip on clicking on the icon', async () => {
|
||||
const { getByLabelText, getByText } = render();
|
||||
await waitFor(() => {
|
||||
const button = getByLabelText(/Show help text/i);
|
||||
fireEvent.click(button);
|
||||
expect(getByText(/Enter the names of the advanced modules to use in your course./i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should change deprecated button text ', async () => {
|
||||
const { getByText } = render();
|
||||
await waitFor(() => {
|
||||
const showDeprecatedItemsBtn = getByText(/Show Deprecated Settings/i);
|
||||
expect(showDeprecatedItemsBtn).toBeInTheDocument();
|
||||
fireEvent.click(showDeprecatedItemsBtn);
|
||||
expect(getByText(/Hide Deprecated Settings/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
|
||||
});
|
||||
it('should reset to default value on click on Cancel button', async () => {
|
||||
const { getByLabelText, getByText } = render();
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
});
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
expect(textarea.value).toBe('[3, 2, 1]');
|
||||
fireEvent.click(getByText(messages.buttonCancelText.defaultMessage));
|
||||
expect(textarea.value).toBe('[]');
|
||||
});
|
||||
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
|
||||
const { getByLabelText, getByText } = render();
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
});
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1,' } });
|
||||
expect(textarea.value).toBe('[3, 2, 1,');
|
||||
fireEvent.click(getByText('Save changes'));
|
||||
fireEvent.click(getByText('Change manually'));
|
||||
expect(textarea.value).toBe('[3, 2, 1,');
|
||||
});
|
||||
it('should show success alert after save', async () => {
|
||||
const { getByLabelText, getByText } = render();
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
});
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
expect(textarea.value).toBe('[3, 2, 1]');
|
||||
axiosMock
|
||||
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
|
||||
.reply(200, {
|
||||
...advancedSettingsMock,
|
||||
advancedModules: {
|
||||
...advancedSettingsMock.advancedModules,
|
||||
value: [3, 2, 1],
|
||||
},
|
||||
});
|
||||
fireEvent.click(getByText('Save changes'));
|
||||
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
|
||||
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
190
src/advanced-settings/AdvancedSettings.test.tsx
Normal file
190
src/advanced-settings/AdvancedSettings.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
|
||||
import { useUserPermissions } from '@src/authz/data/apiHooks';
|
||||
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
|
||||
import {
|
||||
render as baseRender,
|
||||
fireEvent,
|
||||
initializeMocks,
|
||||
screen,
|
||||
} from '@src/testUtils';
|
||||
import { advancedSettingsMock } from './__mocks__';
|
||||
import { getCourseAdvancedSettingsApiUrl } from './data/api';
|
||||
import AdvancedSettings from './AdvancedSettings';
|
||||
import messages from './messages';
|
||||
|
||||
let axiosMock;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
|
||||
// Mock the TextareaAutosize component
|
||||
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
<textarea
|
||||
{...props}
|
||||
onFocus={() => { }}
|
||||
/>
|
||||
)));
|
||||
|
||||
jest.mock('@src/authz/data/apiHooks', () => ({
|
||||
useUserPermissions: jest.fn(),
|
||||
}));
|
||||
|
||||
const render = () => baseRender(
|
||||
<CourseAuthoringProvider courseId={courseId}>
|
||||
<AdvancedSettings />
|
||||
</CourseAuthoringProvider>,
|
||||
{ path: mockPathname },
|
||||
);
|
||||
|
||||
describe('<AdvancedSettings />', () => {
|
||||
beforeEach(() => {
|
||||
const mocks = initializeMocks();
|
||||
axiosMock = mocks.axiosMock;
|
||||
axiosMock
|
||||
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
|
||||
.reply(200, advancedSettingsMock);
|
||||
|
||||
jest.mocked(useUserPermissions).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: { canManageAdvancedSettings: true },
|
||||
} as unknown as ReturnType<typeof useUserPermissions>);
|
||||
});
|
||||
|
||||
it('should render placeholder when settings fetch returns 403', async () => {
|
||||
axiosMock
|
||||
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
|
||||
.reply(403);
|
||||
render();
|
||||
expect(await screen.findByText(/Under Construction/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render without errors', async () => {
|
||||
render();
|
||||
expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.headingTitle.defaultMessage, {
|
||||
selector: 'h2.sub-header-title',
|
||||
})).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.policy.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render setting element', async () => {
|
||||
render();
|
||||
expect(await screen.findByText(/Advanced Module List/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Certificate web/html view enabled')).toBeNull();
|
||||
});
|
||||
|
||||
it('should change to onСhange', async () => {
|
||||
render();
|
||||
const textarea = await screen.findByLabelText(/Advanced Module List/i);
|
||||
expect(textarea).toBeInTheDocument();
|
||||
fireEvent.change(textarea, { target: { value: '[1, 2, 3]' } });
|
||||
expect(textarea).toHaveValue('[1, 2, 3]');
|
||||
});
|
||||
|
||||
it('should display a warning alert', async () => {
|
||||
render();
|
||||
const textarea = await screen.findByLabelText(/Advanced Module List/i);
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
expect(screen.getByText(messages.buttonCancelText.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.buttonSaveText.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.alertWarning.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.alertWarningDescriptions.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display a tooltip on clicking on the icon', async () => {
|
||||
const user = userEvent.setup();
|
||||
render();
|
||||
const button = await screen.findByLabelText(/Show help text/i);
|
||||
await user.click(button);
|
||||
expect(screen.getByText(/Enter the names of the advanced modules to use in your course./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should change deprecated button text', async () => {
|
||||
const user = userEvent.setup();
|
||||
render();
|
||||
const showDeprecatedItemsBtn = await screen.findByText(/Show Deprecated Settings/i);
|
||||
expect(showDeprecatedItemsBtn).toBeInTheDocument();
|
||||
await user.click(showDeprecatedItemsBtn);
|
||||
expect(screen.getByText(/Hide Deprecated Settings/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Certificate web/html view enabled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should reset to default value on click on Cancel button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render();
|
||||
const textarea = await screen.findByLabelText(/Advanced Module List/i);
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
expect(textarea).toHaveValue('[3, 2, 1]');
|
||||
await user.click(screen.getByText(messages.buttonCancelText.defaultMessage));
|
||||
expect(textarea).toHaveValue('[]');
|
||||
});
|
||||
|
||||
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
|
||||
const user = userEvent.setup();
|
||||
render();
|
||||
const textarea = await screen.findByLabelText(/Advanced Module List/i);
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1,' } });
|
||||
fireEvent.blur(textarea);
|
||||
expect(textarea).toHaveValue('[3, 2, 1,');
|
||||
await user.click(screen.getByText('Save changes'));
|
||||
await user.click(await screen.findByText('Change manually'));
|
||||
expect(textarea).toHaveValue('[3, 2, 1,');
|
||||
});
|
||||
|
||||
it('should show success alert after save', async () => {
|
||||
const user = userEvent.setup();
|
||||
render();
|
||||
const textarea = await screen.findByLabelText(/Advanced Module List/i);
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
expect(textarea).toHaveValue('[3, 2, 1]');
|
||||
axiosMock
|
||||
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
|
||||
.reply(200, {
|
||||
...advancedSettingsMock,
|
||||
advancedModules: {
|
||||
...advancedSettingsMock.advancedModules,
|
||||
value: [3, 2, 1],
|
||||
},
|
||||
});
|
||||
await user.click(screen.getByText('Save changes'));
|
||||
expect(screen.getByText('Your policy changes have been saved.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show error modal on save failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
render();
|
||||
const textarea = await screen.findByLabelText(/Advanced Module List/i);
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
axiosMock
|
||||
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
|
||||
.reply(500);
|
||||
await user.click(screen.getByText('Save changes'));
|
||||
expect(await screen.findByText('Validation error while saving')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render without errors when authz.enable_course_authoring flag is enabled and the user is authorized', async () => {
|
||||
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
|
||||
jest.mocked(useUserPermissions).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: { canManageAdvancedSettings: true },
|
||||
} as unknown as ReturnType<typeof useUserPermissions>);
|
||||
render();
|
||||
expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.headingTitle.defaultMessage, {
|
||||
selector: 'h2.sub-header-title',
|
||||
})).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.policy.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show permission alert when authz.enable_course_authoring flag is enabled and the user is not authorized', async () => {
|
||||
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
|
||||
jest.mocked(useUserPermissions).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: { canManageAdvancedSettings: false },
|
||||
} as unknown as ReturnType<typeof useUserPermissions>);
|
||||
render();
|
||||
expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,58 +1,73 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import {
|
||||
Container, Button, Layout, StatefulButton, TransitionReplace,
|
||||
} from '@openedx/paragon';
|
||||
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import Placeholder from '../editors/Placeholder';
|
||||
import { useWaffleFlags } from '@src/data/apiHooks';
|
||||
import { useUserPermissions } from '@src/authz/data/apiHooks';
|
||||
import { COURSE_PERMISSIONS } from '@src/authz/constants';
|
||||
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
|
||||
import AlertProctoringError from '@src/generic/AlertProctoringError';
|
||||
import { LoadingSpinner } from '@src/generic/Loading';
|
||||
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
|
||||
import { parseArrayOrObjectValues } from '@src/utils';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import SubHeader from '@src/generic/sub-header/SubHeader';
|
||||
import AlertMessage from '@src/generic/alert-message';
|
||||
import getPageHeadTitle from '@src/generic/utils';
|
||||
import Placeholder from '@src/editors/Placeholder';
|
||||
|
||||
import AlertProctoringError from '../generic/AlertProctoringError';
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
import { parseArrayOrObjectValues } from '../utils';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import AlertMessage from '../generic/alert-message';
|
||||
import { fetchCourseAppSettings, updateCourseAppSetting, fetchProctoringExamErrors } from './data/thunks';
|
||||
import {
|
||||
getCourseAppSettings, getSavingStatus, getProctoringExamErrors, getSendRequestErrors, getLoadingStatus,
|
||||
} from './data/selectors';
|
||||
import SettingCard from './setting-card/SettingCard';
|
||||
import SettingsSidebar from './settings-sidebar/SettingsSidebar';
|
||||
import validateAdvancedSettingsData from './utils';
|
||||
import messages from './messages';
|
||||
import ModalError from './modal-error/ModalError';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import { useCourseAdvancedSettings, useProctoringExamErrors, useUpdateCourseAdvancedSettings } from './data/apiHooks';
|
||||
|
||||
const AdvancedSettings = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
|
||||
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||
const [errorModal, showErrorModal] = useState(false);
|
||||
const [editedSettings, setEditedSettings] = useState({});
|
||||
const [errorFields, setErrorFields] = useState([]);
|
||||
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
|
||||
const [isQueryPending, setIsQueryPending] = useState(false);
|
||||
const [isEditableState, setIsEditableState] = useState(false);
|
||||
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
|
||||
|
||||
const { courseId, courseDetails } = useCourseAuthoringContext();
|
||||
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseAppSettings(courseId));
|
||||
dispatch(fetchProctoringExamErrors(courseId));
|
||||
}, [courseId]);
|
||||
const waffleFlags = useWaffleFlags(courseId);
|
||||
const isAuthzEnabled = waffleFlags.enableAuthzCourseAuthoring;
|
||||
const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({
|
||||
canManageAdvancedSettings: {
|
||||
action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS,
|
||||
scope: courseId,
|
||||
},
|
||||
}, isAuthzEnabled);
|
||||
|
||||
const advancedSettingsData = useSelector(getCourseAppSettings);
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
const proctoringExamErrors = useSelector(getProctoringExamErrors);
|
||||
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
|
||||
const loadingSettingsStatus = useSelector(getLoadingStatus);
|
||||
const {
|
||||
data: advancedSettingsData = {},
|
||||
isPending: isPendingSettingsStatus,
|
||||
failureReason: settingsStatusError,
|
||||
} = useCourseAdvancedSettings(courseId);
|
||||
|
||||
const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
|
||||
const {
|
||||
data: proctoringExamErrors = {},
|
||||
} = useProctoringExamErrors(courseId);
|
||||
|
||||
const updateMutation = useUpdateCourseAdvancedSettings(courseId);
|
||||
|
||||
const {
|
||||
isPending: isQueryPending,
|
||||
isSuccess: isQuerySuccess,
|
||||
error: queryError,
|
||||
} = updateMutation;
|
||||
|
||||
const isLoading = isPendingSettingsStatus || (isAuthzEnabled && isLoadingUserPermissions);
|
||||
const updateSettingsButtonState = {
|
||||
labels: {
|
||||
default: intl.formatMessage(messages.buttonSaveText),
|
||||
@@ -60,30 +75,34 @@ const AdvancedSettings = () => {
|
||||
},
|
||||
disabledStates: ['pending'],
|
||||
};
|
||||
|
||||
const {
|
||||
proctoringErrors,
|
||||
mfeProctoredExamSettingsUrl,
|
||||
} = proctoringExamErrors;
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
setIsQueryPending(false);
|
||||
if (isQuerySuccess) {
|
||||
setShowSuccessAlert(true);
|
||||
setIsEditableState(false);
|
||||
setTimeout(() => setShowSuccessAlert(false), 15000);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
showSaveSettingsPrompt(false);
|
||||
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
|
||||
setErrorFields(settingsWithSendErrors);
|
||||
} else if (queryError && !hasInternetConnectionError) {
|
||||
// @ts-ignore
|
||||
setErrorFields(queryError?.response?.data ?? []);
|
||||
showErrorModal(true);
|
||||
}
|
||||
}, [savingStatus]);
|
||||
}, [isQuerySuccess, queryError]);
|
||||
|
||||
if (isLoading) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
return (
|
||||
<div className="row justify-content-center m-6">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loadingSettingsStatus === RequestStatus.DENIED) {
|
||||
if (settingsStatusError?.response?.status === 403) {
|
||||
return (
|
||||
<div className="row justify-content-center m-6">
|
||||
<Placeholder />
|
||||
@@ -105,31 +124,42 @@ const AdvancedSettings = () => {
|
||||
const handleUpdateAdvancedSettingsData = () => {
|
||||
const isValid = validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
|
||||
if (isValid) {
|
||||
setIsQueryPending(true);
|
||||
setShowSuccessAlert(false);
|
||||
updateMutation.mutate(parseArrayOrObjectValues(editedSettings));
|
||||
} else {
|
||||
showSaveSettingsPrompt(false);
|
||||
showErrorModal(!errorModal);
|
||||
}
|
||||
};
|
||||
|
||||
/* istanbul ignore next */
|
||||
const handleInternetConnectionFailed = () => {
|
||||
setInternetConnectionError(true);
|
||||
showSaveSettingsPrompt(false);
|
||||
setShowSuccessAlert(false);
|
||||
};
|
||||
|
||||
const handleQueryProcessing = () => {
|
||||
setShowSuccessAlert(false);
|
||||
dispatch(updateCourseAppSetting(courseId, parseArrayOrObjectValues(editedSettings)));
|
||||
};
|
||||
|
||||
const handleManuallyChangeClick = (setToState) => {
|
||||
showErrorModal(setToState);
|
||||
showSaveSettingsPrompt(true);
|
||||
};
|
||||
|
||||
// Show permission denied alert when authz is enabled and user doesn't have permission
|
||||
const authzIsEnabledAndNoPermission = isAuthzEnabled
|
||||
&& !isLoadingUserPermissions
|
||||
&& !userPermissions?.canManageAdvancedSettings;
|
||||
|
||||
if (authzIsEnabledAndNoPermission) {
|
||||
return <PermissionDeniedAlert />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{getPageHeadTitle(courseDetails?.name ?? '', intl.formatMessage(messages.headingTitle))}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Container size="xl" className="advanced-settings px-4">
|
||||
<div className="setting-header mt-5">
|
||||
{(proctoringErrors?.length > 0) && (
|
||||
@@ -139,7 +169,11 @@ const AdvancedSettings = () => {
|
||||
aria-hidden="true"
|
||||
aria-labelledby={intl.formatMessage(messages.alertProctoringAriaLabelledby)}
|
||||
aria-describedby={intl.formatMessage(messages.alertProctoringDescribedby)}
|
||||
/>
|
||||
>
|
||||
{/* Empty children to satisfy the type checker */}
|
||||
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
|
||||
<></>
|
||||
</AlertProctoringError>
|
||||
)}
|
||||
<TransitionReplace>
|
||||
{showSuccessAlert ? (
|
||||
@@ -192,8 +226,8 @@ const AdvancedSettings = () => {
|
||||
defaultMessage="{visibility} deprecated settings"
|
||||
values={{
|
||||
visibility:
|
||||
showDeprecated ? intl.formatMessage(messages.deprecatedButtonHideText)
|
||||
: intl.formatMessage(messages.deprecatedButtonShowText),
|
||||
showDeprecated ? intl.formatMessage(messages.deprecatedButtonHideText)
|
||||
: intl.formatMessage(messages.deprecatedButtonShowText),
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
@@ -235,9 +269,8 @@ const AdvancedSettings = () => {
|
||||
<div className="alert-toast">
|
||||
{isQueryPending && (
|
||||
<InternetConnectionAlert
|
||||
isFailed={savingStatus === RequestStatus.FAILED}
|
||||
isFailed={Boolean(queryError)}
|
||||
isQueryPending={isQueryPending}
|
||||
onQueryProcessing={handleQueryProcessing}
|
||||
onInternetConnectionFailed={handleInternetConnectionFailed}
|
||||
/>
|
||||
)}
|
||||
@@ -248,18 +281,18 @@ const AdvancedSettings = () => {
|
||||
aria-describedby={intl.formatMessage(messages.alertWarningAriaDescribedby)}
|
||||
role="dialog"
|
||||
actions={[
|
||||
!isQueryPending && (
|
||||
!isQueryPending ? (
|
||||
<Button variant="tertiary" onClick={handleResetSettingsValues}>
|
||||
{intl.formatMessage(messages.buttonCancelText)}
|
||||
</Button>
|
||||
),
|
||||
) : /* istanbul ignore next */ null,
|
||||
<StatefulButton
|
||||
key="statefulBtn"
|
||||
onClick={handleUpdateAdvancedSettingsData}
|
||||
state={isQueryPending ? RequestStatus.PENDING : 'default'}
|
||||
{...updateSettingsButtonState}
|
||||
/>,
|
||||
].filter(Boolean)}
|
||||
].filter((action): action is JSX.Element => action !== null)}
|
||||
variant="warning"
|
||||
icon={Warning}
|
||||
title={intl.formatMessage(messages.alertWarning)}
|
||||
@@ -1,11 +1,10 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import {
|
||||
camelCaseObject,
|
||||
getConfig,
|
||||
} from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { camelCase } from 'lodash';
|
||||
import { convertObjectToSnakeCase } from '../../utils';
|
||||
import { convertObjectToSnakeCase } from '@src/utils';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getCourseAdvancedSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings/${courseId}`;
|
||||
@@ -13,10 +12,8 @@ const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/
|
||||
|
||||
/**
|
||||
* Get's advanced setting for a course.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getCourseAdvancedSettings(courseId) {
|
||||
export async function getCourseAdvancedSettings(courseId: string): Promise<Record<string, any>> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
|
||||
const keepValues = {};
|
||||
@@ -36,11 +33,11 @@ export async function getCourseAdvancedSettings(courseId) {
|
||||
|
||||
/**
|
||||
* Updates advanced setting for a course.
|
||||
* @param {string} courseId
|
||||
* @param {object} settings
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function updateCourseAdvancedSettings(courseId, settings) {
|
||||
export async function updateCourseAdvancedSettings(
|
||||
courseId: string,
|
||||
settings: Record<string, any>,
|
||||
): Promise<Record<string, any>> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
|
||||
const keepValues = {};
|
||||
@@ -60,10 +57,8 @@ export async function updateCourseAdvancedSettings(courseId, settings) {
|
||||
|
||||
/**
|
||||
* Gets proctoring exam errors.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getProctoringExamErrors(courseId) {
|
||||
export async function getProctoringExamErrors(courseId: string): Promise<Record<string, any>> {
|
||||
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
|
||||
const keepValues = {};
|
||||
Object.keys(data).forEach((key) => {
|
||||
@@ -77,5 +72,6 @@ export async function getProctoringExamErrors(courseId) {
|
||||
value: keepValues[key]?.value,
|
||||
};
|
||||
});
|
||||
|
||||
return formattedData;
|
||||
}
|
||||
56
src/advanced-settings/data/apiHooks.ts
Normal file
56
src/advanced-settings/data/apiHooks.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
getCourseAdvancedSettings,
|
||||
getProctoringExamErrors,
|
||||
updateCourseAdvancedSettings,
|
||||
} from './api';
|
||||
|
||||
export const advancedSettingsQueryKeys = {
|
||||
all: ['advancedSettings'],
|
||||
/** Base key for advanced settings specific to a courseId */
|
||||
courseAdvancedSettings: (courseId: string) => [...advancedSettingsQueryKeys.all, courseId],
|
||||
/** Key for proctoring exam errors specific to a courseId */
|
||||
proctoringExamErrors: (courseId: string) => [...advancedSettingsQueryKeys.all, courseId, 'proctoringErrors'],
|
||||
};
|
||||
|
||||
const sortSettingsByDisplayName = (settings: Record<string, any>): Record<string, any> => (
|
||||
Object.fromEntries(Object.entries(settings).sort(
|
||||
([, v1], [, v2]) => v1.displayName.localeCompare(v2.displayName),
|
||||
))
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetches the advanced settings for a course, sorted alphabetically by display name.
|
||||
*/
|
||||
export const useCourseAdvancedSettings = (courseId: string) => (
|
||||
useQuery<Record<string, any>, AxiosError>({
|
||||
queryKey: advancedSettingsQueryKeys.courseAdvancedSettings(courseId),
|
||||
queryFn: () => getCourseAdvancedSettings(courseId),
|
||||
select: sortSettingsByDisplayName,
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetches the proctoring exam errors for a course.
|
||||
*/
|
||||
export const useProctoringExamErrors = (courseId: string) => (
|
||||
useQuery({
|
||||
queryKey: advancedSettingsQueryKeys.proctoringExamErrors(courseId),
|
||||
queryFn: () => getProctoringExamErrors(courseId),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns a mutation to update the advanced settings for a course.
|
||||
*/
|
||||
export const useUpdateCourseAdvancedSettings = (courseId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Record<string, any>, AxiosError, Record<string, any>>({
|
||||
mutationFn: (settings: Record<string, any>) => updateCourseAdvancedSettings(courseId, settings),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: advancedSettingsQueryKeys.courseAdvancedSettings(courseId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export const getLoadingStatus = (state) => state.advancedSettings.loadingStatus;
|
||||
export const getCourseAppSettings = state => state.advancedSettings.courseAppSettings;
|
||||
export const getSavingStatus = (state) => state.advancedSettings.savingStatus;
|
||||
export const getProctoringExamErrors = (state) => state.advancedSettings.proctoringErrors;
|
||||
export const getSendRequestErrors = (state) => state.advancedSettings.sendRequestErrors.developer_message;
|
||||
@@ -1,48 +0,0 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'advancedSettings',
|
||||
initialState: {
|
||||
loadingStatus: RequestStatus.IN_PROGRESS,
|
||||
savingStatus: '',
|
||||
courseAppSettings: {},
|
||||
proctoringErrors: {},
|
||||
sendRequestErrors: {},
|
||||
},
|
||||
reducers: {
|
||||
updateLoadingStatus: (state, { payload }) => {
|
||||
state.loadingStatus = payload.status;
|
||||
},
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
fetchCourseAppsSettingsSuccess: (state, { payload }) => {
|
||||
Object.assign(state.courseAppSettings, payload);
|
||||
},
|
||||
updateCourseAppsSettingsSuccess: (state, { payload }) => {
|
||||
Object.assign(state.courseAppSettings, payload);
|
||||
},
|
||||
getDataSendErrors: (state, { payload }) => {
|
||||
Object.assign(state.sendRequestErrors, payload);
|
||||
},
|
||||
fetchProctoringExamErrorsSuccess: (state, { payload }) => {
|
||||
Object.assign(state.proctoringErrors, payload);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
getDataSendErrors,
|
||||
fetchCourseAppsSettingsSuccess,
|
||||
updateCourseAppsSettingsSuccess,
|
||||
fetchProctoringExamErrorsSuccess,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
@@ -1,85 +0,0 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import {
|
||||
getCourseAdvancedSettings,
|
||||
updateCourseAdvancedSettings,
|
||||
getProctoringExamErrors,
|
||||
} from './api';
|
||||
import {
|
||||
fetchCourseAppsSettingsSuccess,
|
||||
updateCourseAppsSettingsSuccess,
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
fetchProctoringExamErrorsSuccess,
|
||||
getDataSendErrors,
|
||||
} from './slice';
|
||||
|
||||
export function fetchCourseAppSettings(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const settingValues = await getCourseAdvancedSettings(courseId);
|
||||
const sortedDisplayName = [];
|
||||
Object.values(settingValues).forEach(value => {
|
||||
const { displayName } = value;
|
||||
sortedDisplayName.push(displayName);
|
||||
});
|
||||
const sortedSettingValues = {};
|
||||
sortedDisplayName.sort((a, b) => a.localeCompare(b)).forEach((displayName => {
|
||||
Object.entries(settingValues).forEach(([key, value]) => {
|
||||
if (value.displayName === displayName) {
|
||||
sortedSettingValues[key] = value;
|
||||
}
|
||||
});
|
||||
}));
|
||||
dispatch(fetchCourseAppsSettingsSuccess(sortedSettingValues));
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 403) {
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED }));
|
||||
} else {
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCourseAppSetting(courseId, settings) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const settingValues = await updateCourseAdvancedSettings(courseId, settings);
|
||||
dispatch(updateCourseAppsSettingsSuccess(settingValues));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
let errorData;
|
||||
try {
|
||||
const { customAttributes: { httpErrorResponseData } } = error;
|
||||
errorData = JSON.parse(httpErrorResponseData);
|
||||
} catch {
|
||||
errorData = {};
|
||||
}
|
||||
|
||||
dispatch(getDataSendErrors(errorData));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchProctoringExamErrors(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const settingValues = await getProctoringExamErrors(courseId);
|
||||
dispatch(fetchProctoringExamErrorsSuccess(settingValues));
|
||||
return true;
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -14,3 +14,7 @@ export const CONTENT_LIBRARY_PERMISSIONS = {
|
||||
MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team',
|
||||
VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team',
|
||||
};
|
||||
|
||||
export const COURSE_PERMISSIONS = {
|
||||
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',
|
||||
};
|
||||
|
||||
@@ -403,10 +403,19 @@ mockTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getTaxonomyTagsData').mo
|
||||
/**
|
||||
* Mock for `getContentData()`
|
||||
*/
|
||||
export async function mockContentData(): Promise<any> {
|
||||
return mockContentData.data;
|
||||
export async function mockContentData(contentId: string): Promise<any> {
|
||||
switch (contentId) {
|
||||
case mockContentData.textXBlock:
|
||||
return mockContentData.textXBlockData;
|
||||
default:
|
||||
return mockContentData.data;
|
||||
}
|
||||
}
|
||||
mockContentData.data = {
|
||||
displayName: 'Unit 1',
|
||||
};
|
||||
mockContentData.textXBlock = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4';
|
||||
mockContentData.textXBlockData = {
|
||||
displayName: 'Text XBlock 1',
|
||||
};
|
||||
mockContentData.applyMock = () => jest.spyOn(api, 'getContentData').mockImplementation(mockContentData);
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import {
|
||||
render,
|
||||
waitFor,
|
||||
screen,
|
||||
initializeMocks,
|
||||
} from '@src/testUtils';
|
||||
import '@testing-library/jest-dom';
|
||||
import { getConfig, setConfig } from '@edx/frontend-platform';
|
||||
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { executeThunk } from '../utils';
|
||||
import { getCourseLaunchApiUrl, getCourseBestPracticesApiUrl } from './data/api';
|
||||
import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks';
|
||||
import {
|
||||
courseId,
|
||||
generateCourseLaunchData,
|
||||
@@ -20,7 +16,6 @@ import messages from './messages';
|
||||
import CourseChecklist from './index';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
@@ -33,22 +28,18 @@ const renderComponent = () => {
|
||||
const mockStore = async (status) => {
|
||||
axiosMock.onGet(getCourseLaunchApiUrl(courseId)).reply(status, generateCourseLaunchData());
|
||||
axiosMock.onGet(getCourseBestPracticesApiUrl(courseId)).reply(status, generateCourseBestPracticesData());
|
||||
|
||||
await executeThunk(fetchCourseLaunchQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseBestPracticesQuery(courseId), store.dispatch);
|
||||
};
|
||||
|
||||
describe('CourseChecklistPage', () => {
|
||||
beforeEach(async () => {
|
||||
const mocks = initializeMocks();
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
});
|
||||
describe('renders', () => {
|
||||
describe('if enable_quality prop is true', () => {
|
||||
it('two checklist components ', async () => {
|
||||
renderComponent();
|
||||
await mockStore(200);
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByText(messages.launchChecklistLabel.defaultMessage)).toBeVisible();
|
||||
|
||||
@@ -56,9 +47,9 @@ describe('CourseChecklistPage', () => {
|
||||
});
|
||||
|
||||
describe('an aria-live region with', () => {
|
||||
it('an aria-live region', () => {
|
||||
it('an aria-live region', async () => {
|
||||
renderComponent();
|
||||
const ariaLiveRegion = screen.getByRole('status');
|
||||
const ariaLiveRegion = await screen.findByRole('status');
|
||||
|
||||
expect(ariaLiveRegion).toBeDefined();
|
||||
|
||||
@@ -66,29 +57,17 @@ describe('CourseChecklistPage', () => {
|
||||
});
|
||||
|
||||
it('correct content when the launch checklist has loaded', async () => {
|
||||
renderComponent();
|
||||
await mockStore(404);
|
||||
await waitFor(() => {
|
||||
const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
|
||||
|
||||
expect(launchChecklistStatus).not.toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
expect(screen.getByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
renderComponent();
|
||||
expect(await screen.findByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('correct content when the best practices checklist is loading', async () => {
|
||||
renderComponent();
|
||||
await mockStore(404);
|
||||
await waitFor(() => {
|
||||
const { bestPracticeChecklistStatus } = store.getState().courseChecklist.loadingStatus;
|
||||
|
||||
expect(bestPracticeChecklistStatus).not.toEqual(RequestStatus.IN_PROGRESS);
|
||||
|
||||
expect(
|
||||
screen.getByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
renderComponent();
|
||||
expect(
|
||||
await screen.findByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -111,27 +90,15 @@ describe('CourseChecklistPage', () => {
|
||||
|
||||
describe('an aria-live region with', () => {
|
||||
it('correct content when the launch checklist has loaded', async () => {
|
||||
renderComponent();
|
||||
await mockStore(404);
|
||||
await waitFor(() => {
|
||||
const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
|
||||
|
||||
expect(launchChecklistStatus).not.toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
expect(screen.getByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
renderComponent();
|
||||
expect(await screen.findByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('correct content when the best practices checklist is loading', async () => {
|
||||
renderComponent();
|
||||
await mockStore(404);
|
||||
await waitFor(() => {
|
||||
const { bestPracticeChecklistStatus } = store.getState().courseChecklist.loadingStatus;
|
||||
|
||||
expect(bestPracticeChecklistStatus).not.toEqual(RequestStatus.IN_PROGRESS);
|
||||
|
||||
expect(screen.queryByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage)).toBeNull();
|
||||
});
|
||||
renderComponent();
|
||||
expect(screen.queryByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -144,11 +111,7 @@ describe('CourseChecklistPage', () => {
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
|
||||
expect(launchChecklistStatus).toEqual(RequestStatus.DENIED);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
expect(await screen.findByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,42 +1,33 @@
|
||||
import { useEffect } from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Container, Stack } from '@openedx/paragon';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { DeprecatedReduxState } from '@src/store';
|
||||
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import messages from './messages';
|
||||
import AriaLiveRegion from './AriaLiveRegion';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import ChecklistSection from './ChecklistSection';
|
||||
import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks';
|
||||
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
|
||||
import { useCourseBestPractices, useCourseLaunch } from './data/apiHooks';
|
||||
|
||||
const CourseChecklist = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const { courseId, courseDetails } = useCourseAuthoringContext();
|
||||
const enableQuality = getConfig().ENABLE_CHECKLIST_QUALITY === 'true';
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseLaunchQuery({ courseId }));
|
||||
dispatch(fetchCourseBestPracticesQuery({ courseId }));
|
||||
}, [courseId]);
|
||||
const {
|
||||
data: bestPracticeData,
|
||||
isPending: isPendingBestPacticeData,
|
||||
} = useCourseBestPractices({ courseId });
|
||||
|
||||
const {
|
||||
loadingStatus,
|
||||
launchData,
|
||||
bestPracticeData,
|
||||
} = useSelector((state: DeprecatedReduxState) => state.courseChecklist);
|
||||
data: launchData,
|
||||
isPending: isPendingLaunchData,
|
||||
failureReason: launchError,
|
||||
} = useCourseLaunch({ courseId });
|
||||
|
||||
const { bestPracticeChecklistLoadingStatus, launchChecklistLoadingStatus, launchChecklistStatus } = loadingStatus;
|
||||
|
||||
const isCourseLaunchChecklistLoading = bestPracticeChecklistLoadingStatus === RequestStatus.IN_PROGRESS;
|
||||
const isCourseBestPracticeChecklistLoading = launchChecklistLoadingStatus === RequestStatus.IN_PROGRESS;
|
||||
const isLoadingDenied = launchChecklistStatus === RequestStatus.DENIED;
|
||||
const isLoadingDenied = launchError?.response?.status === 403;
|
||||
|
||||
if (isLoadingDenied) {
|
||||
return (
|
||||
@@ -64,8 +55,8 @@ const CourseChecklist = () => {
|
||||
/>
|
||||
<AriaLiveRegion
|
||||
{...{
|
||||
isCourseLaunchChecklistLoading,
|
||||
isCourseBestPracticeChecklistLoading,
|
||||
isCourseLaunchChecklistLoading: isPendingLaunchData,
|
||||
isCourseBestPracticeChecklistLoading: isPendingBestPacticeData,
|
||||
enableQuality,
|
||||
}}
|
||||
/>
|
||||
@@ -75,7 +66,7 @@ const CourseChecklist = () => {
|
||||
dataHeading={intl.formatMessage(messages.launchChecklistLabel)}
|
||||
data={launchData}
|
||||
idPrefix="launchChecklist"
|
||||
isLoading={isCourseLaunchChecklistLoading}
|
||||
isLoading={isPendingLaunchData}
|
||||
/>
|
||||
{enableQuality && (
|
||||
<ChecklistSection
|
||||
@@ -83,7 +74,7 @@ const CourseChecklist = () => {
|
||||
dataHeading={intl.formatMessage(messages.bestPracticesChecklistLabel)}
|
||||
data={bestPracticeData}
|
||||
idPrefix="bestPracticesChecklist"
|
||||
isLoading={isCourseBestPracticeChecklistLoading}
|
||||
isLoading={isPendingBestPacticeData}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
// @ts-check
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
export const getCourseBestPracticesApiUrl = ({
|
||||
courseId,
|
||||
excludeGraded,
|
||||
all,
|
||||
}) => `${getApiBaseUrl()}/api/courses/v1/quality/${courseId}/?exclude_graded=${excludeGraded}&all=${all}`;
|
||||
|
||||
export const getCourseLaunchApiUrl = ({
|
||||
courseId,
|
||||
gradedOnly,
|
||||
validateOras,
|
||||
all,
|
||||
}) => `${getApiBaseUrl()}/api/courses/v1/validation/${courseId}/?graded_only=${gradedOnly}&validate_oras=${validateOras}&all=${all}`;
|
||||
|
||||
/**
|
||||
* Get course best practices.
|
||||
* @param {{courseId: string, excludeGraded: boolean, all: boolean}} options
|
||||
* @returns {Promise<{isSelfPaced: boolean, sections: any, subsection: any, units: any, videos: any }>}
|
||||
*/
|
||||
export async function getCourseBestPractices({
|
||||
courseId,
|
||||
excludeGraded,
|
||||
all,
|
||||
}) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseBestPracticesApiUrl({ courseId, excludeGraded, all }));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/** @typedef {object} courseLaunchData
|
||||
* @property {boolean} isSelfPaced
|
||||
* @property {object} dates
|
||||
* @property {object} assignments
|
||||
* @property {object} grades
|
||||
* @property {number} grades.sum_of_weights
|
||||
* @property {object} certificates
|
||||
* @property {object} updates
|
||||
* @property {object} proctoring
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get course launch.
|
||||
* @param {{courseId: string, gradedOnly: boolean, validateOras: boolean, all: boolean}} options
|
||||
* @returns {Promise<courseLaunchData>}
|
||||
*/
|
||||
export async function getCourseLaunch({
|
||||
courseId,
|
||||
gradedOnly,
|
||||
validateOras,
|
||||
all,
|
||||
}) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseLaunchApiUrl({
|
||||
courseId, gradedOnly, validateOras, all,
|
||||
}));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
52
src/course-checklist/data/api.test.ts
Normal file
52
src/course-checklist/data/api.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { initializeMocks } from '@src/testUtils';
|
||||
import {
|
||||
CourseBestPracticesRequest,
|
||||
CourseLaunchRequest,
|
||||
getCourseBestPractices,
|
||||
getCourseBestPracticesApiUrl,
|
||||
getCourseLaunch,
|
||||
getCourseLaunchApiUrl,
|
||||
} from './api';
|
||||
|
||||
let axiosMock;
|
||||
|
||||
describe('course checklist data API', () => {
|
||||
beforeEach(() => {
|
||||
({ axiosMock } = initializeMocks());
|
||||
});
|
||||
|
||||
describe('getCourseBestPractices', () => {
|
||||
it('should fetch course best practices', async () => {
|
||||
const params: CourseBestPracticesRequest = {
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
excludeGraded: true,
|
||||
all: true,
|
||||
};
|
||||
const url = getCourseBestPracticesApiUrl(params);
|
||||
axiosMock.onGet(url).reply(200, { is_self_paced: false });
|
||||
|
||||
const result = await getCourseBestPractices(params);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(url);
|
||||
expect(result).toEqual({ isSelfPaced: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCourseLaunch', () => {
|
||||
it('should fetch course launch validation', async () => {
|
||||
const params: CourseLaunchRequest = {
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
gradedOnly: true,
|
||||
validateOras: true,
|
||||
all: true,
|
||||
};
|
||||
const url = getCourseLaunchApiUrl(params);
|
||||
axiosMock.onGet(url).reply(200, { is_self_paced: false });
|
||||
|
||||
const result = await getCourseLaunch(params);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(url);
|
||||
expect(result).toEqual({ isSelfPaced: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
98
src/course-checklist/data/api.ts
Normal file
98
src/course-checklist/data/api.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
export interface CourseBestPracticesRequest {
|
||||
courseId: string;
|
||||
excludeGraded?: boolean;
|
||||
all?: boolean;
|
||||
}
|
||||
|
||||
export const getCourseBestPracticesApiUrl = ({
|
||||
courseId,
|
||||
excludeGraded = true,
|
||||
all = true,
|
||||
}: CourseBestPracticesRequest) => (
|
||||
`${getApiBaseUrl()}/api/courses/v1/quality/${courseId}/?exclude_graded=${excludeGraded}&all=${all}`
|
||||
);
|
||||
|
||||
export interface CourseLaunchRequest {
|
||||
courseId: string;
|
||||
gradedOnly?: boolean;
|
||||
validateOras?: boolean;
|
||||
all?: boolean;
|
||||
}
|
||||
|
||||
export const getCourseLaunchApiUrl = ({
|
||||
courseId,
|
||||
gradedOnly = true,
|
||||
validateOras = true,
|
||||
all = true,
|
||||
}: CourseLaunchRequest) => (
|
||||
`${getApiBaseUrl()}/api/courses/v1/validation/${courseId}/?graded_only=${gradedOnly}&validate_oras=${validateOras}&all=${all}`
|
||||
);
|
||||
|
||||
export interface CourseBestPractices {
|
||||
isSelfPaced: boolean;
|
||||
sections: Record<string, any>;
|
||||
subsection: Record<string, any>;
|
||||
units: Record<string, any>;
|
||||
videos: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get course best practices.
|
||||
*/
|
||||
export async function getCourseBestPractices({
|
||||
courseId,
|
||||
excludeGraded,
|
||||
all,
|
||||
}: CourseBestPracticesRequest): Promise<CourseBestPractices> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseBestPracticesApiUrl({ courseId, excludeGraded, all }));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export interface CourseLaunchData {
|
||||
isSelfPaced: boolean;
|
||||
dates: {
|
||||
hasEndDate: boolean;
|
||||
hasStartDate: boolean;
|
||||
};
|
||||
assignments: Record<string, any>;
|
||||
grades: {
|
||||
hasGradingPolicy: boolean;
|
||||
sumOfWeights: number;
|
||||
};
|
||||
certificates: {
|
||||
hasCertificate: boolean;
|
||||
isActivated: boolean;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
updates: {
|
||||
hasUpdate: boolean;
|
||||
};
|
||||
proctoring: {
|
||||
hasProctoringEscalationEmail: boolean;
|
||||
needsProctoringEscalationEmail: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get course launch.
|
||||
*/
|
||||
export async function getCourseLaunch({
|
||||
courseId,
|
||||
gradedOnly,
|
||||
validateOras,
|
||||
all,
|
||||
}: CourseLaunchRequest): Promise<CourseLaunchData> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseLaunchApiUrl({
|
||||
courseId, gradedOnly, validateOras, all,
|
||||
}));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
52
src/course-checklist/data/apiHooks.ts
Normal file
52
src/course-checklist/data/apiHooks.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { AxiosError } from 'axios';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
CourseBestPracticesRequest,
|
||||
CourseLaunchData,
|
||||
CourseLaunchRequest,
|
||||
getCourseBestPractices,
|
||||
getCourseLaunch,
|
||||
} from './api';
|
||||
|
||||
export const courseChecklistQueryKeys = {
|
||||
all: ['courseChecklist'],
|
||||
courseBestPractices: (params: CourseBestPracticesRequest) => [
|
||||
...courseChecklistQueryKeys.all,
|
||||
'bestPractices',
|
||||
params,
|
||||
],
|
||||
courseLaunch: (params: CourseLaunchRequest) => [
|
||||
...courseChecklistQueryKeys.all,
|
||||
'launch',
|
||||
params,
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch course best practices.
|
||||
*
|
||||
* It is necessary to update on each mount, because it is not known
|
||||
* for sure whether the checklist has been updated or not.
|
||||
*/
|
||||
export const useCourseBestPractices = (params: CourseBestPracticesRequest) => (
|
||||
useQuery({
|
||||
queryKey: courseChecklistQueryKeys.courseBestPractices(params),
|
||||
queryFn: () => getCourseBestPractices(params),
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook to fetch course launch validation.
|
||||
*
|
||||
* It is necessary to update on each mount, because it is not known
|
||||
* for sure whether the checklist has been updated or not.
|
||||
*/
|
||||
export const useCourseLaunch = (params: CourseLaunchRequest) => (
|
||||
useQuery<CourseLaunchData, AxiosError>({
|
||||
queryKey: courseChecklistQueryKeys.courseLaunch(params),
|
||||
queryFn: () => getCourseLaunch(params),
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
);
|
||||
@@ -1,41 +0,0 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'courseChecklist',
|
||||
initialState: {
|
||||
loadingStatus: {
|
||||
launchChecklistStatus: RequestStatus.IN_PROGRESS,
|
||||
bestPracticeChecklistStatus: RequestStatus.IN_PROGRESS,
|
||||
},
|
||||
launchData: {},
|
||||
bestPracticeData: {},
|
||||
},
|
||||
reducers: {
|
||||
fetchLaunchChecklistSuccess: (state, { payload }) => {
|
||||
state.launchData = payload.data;
|
||||
},
|
||||
updateLaunchChecklistStatus: (state, { payload }) => {
|
||||
state.loadingStatus.launchChecklistStatus = payload.status;
|
||||
},
|
||||
fetchBestPracticeChecklistSuccess: (state, { payload }) => {
|
||||
state.bestPracticeData = payload.data;
|
||||
},
|
||||
updateBestPracticeChecklisttStatus: (state, { payload }) => {
|
||||
state.loadingStatus.bestPracticeChecklistStatus = payload.status;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchLaunchChecklistSuccess,
|
||||
updateLaunchChecklistStatus,
|
||||
fetchBestPracticeChecklistSuccess,
|
||||
updateBestPracticeChecklisttStatus,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
@@ -1,50 +0,0 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import {
|
||||
getCourseBestPractices,
|
||||
getCourseLaunch,
|
||||
} from './api';
|
||||
import {
|
||||
fetchLaunchChecklistSuccess,
|
||||
updateLaunchChecklistStatus,
|
||||
fetchBestPracticeChecklistSuccess,
|
||||
updateBestPracticeChecklisttStatus,
|
||||
} from './slice';
|
||||
|
||||
export function fetchCourseLaunchQuery({
|
||||
courseId,
|
||||
gradedOnly = true,
|
||||
validateOras = true,
|
||||
all = true,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const data = await getCourseLaunch({
|
||||
courseId, gradedOnly, validateOras, all,
|
||||
});
|
||||
dispatch(fetchLaunchChecklistSuccess({ data }));
|
||||
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 403) {
|
||||
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.DENIED }));
|
||||
} else {
|
||||
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseBestPracticesQuery({
|
||||
courseId,
|
||||
excludeGraded = true,
|
||||
all = true,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const data = await getCourseBestPractices({ courseId, excludeGraded, all });
|
||||
dispatch(fetchBestPracticeChecklistSuccess({ data }));
|
||||
dispatch(updateBestPracticeChecklisttStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch {
|
||||
dispatch(updateBestPracticeChecklisttStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -36,10 +36,9 @@ import {
|
||||
fetchCourseBestPracticesQuery,
|
||||
fetchCourseLaunchQuery,
|
||||
fetchCourseOutlineIndexQuery, syncDiscussionsTopics,
|
||||
updateCourseSectionHighlightsQuery,
|
||||
} from './data/thunk';
|
||||
import {
|
||||
courseOutlineIndexMock,
|
||||
courseOutlineIndexMock as originalCourseOutlineIndexMock,
|
||||
courseOutlineIndexWithoutSections,
|
||||
courseBestPracticesMock,
|
||||
courseLaunchMock,
|
||||
@@ -71,6 +70,7 @@ const getContainerKey = jest.fn().mockReturnValue('lct:org:lib:unit:1');
|
||||
const getContainerType = jest.fn().mockReturnValue('unit');
|
||||
const clearSelection = jest.fn();
|
||||
let selectedContainerId: string | undefined;
|
||||
let courseOutlineIndexMock = cloneDeep(originalCourseOutlineIndexMock);
|
||||
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
|
||||
@@ -96,13 +96,6 @@ jest.mock('@src/help-urls/hooks', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform/i18n'),
|
||||
useIntl: () => ({
|
||||
formatMessage: (message) => message.defaultMessage,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('./data/api', () => ({
|
||||
...jest.requireActual('./data/api'),
|
||||
getTagsCount: () => jest.fn().mockResolvedValue({}),
|
||||
@@ -163,6 +156,8 @@ describe('<CourseOutline />', () => {
|
||||
beforeEach(async () => {
|
||||
const mocks = initializeMocks();
|
||||
selectedContainerId = undefined;
|
||||
// restore index mock
|
||||
courseOutlineIndexMock = cloneDeep(originalCourseOutlineIndexMock);
|
||||
|
||||
jest.mocked(useLocation).mockReturnValue({
|
||||
pathname: mockPathname,
|
||||
@@ -300,7 +295,7 @@ describe('<CourseOutline />', () => {
|
||||
expect(alertElements.find(
|
||||
(el) => el.classList.contains('alert-content'),
|
||||
)).toHaveTextContent(
|
||||
pageAlertMessages.alertFailedGeneric.defaultMessage,
|
||||
'Unable to save changes. Please try again.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -404,6 +399,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('adds new subsection correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { findAllByTestId } = renderComponent();
|
||||
const [section] = await findAllByTestId('section-card');
|
||||
let subsections = await within(section).findAllByTestId('subsection-card');
|
||||
@@ -428,10 +424,14 @@ describe('<CourseOutline />', () => {
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(courseSubsectionMock.id))
|
||||
.reply(200, courseSubsectionMock);
|
||||
const firstSectionData = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
// @ts-ignore
|
||||
firstSectionData.childInfo.children.push(courseSubsectionMock);
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(firstSectionData.id))
|
||||
.reply(200, firstSectionData);
|
||||
const newSubsectionButton = await within(section).findByRole('button', { name: 'New subsection' });
|
||||
await act(async () => {
|
||||
fireEvent.click(newSubsectionButton);
|
||||
});
|
||||
await user.click(newSubsectionButton);
|
||||
|
||||
subsections = await within(section).findAllByTestId('subsection-card');
|
||||
expect(subsections.length).toBe(3);
|
||||
@@ -540,6 +540,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('adds a section from library correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
getContainerKey.mockReturnValue('lct:org:lib:section:1');
|
||||
getContainerKey.mockReturnValue('section');
|
||||
renderComponent();
|
||||
@@ -552,11 +553,14 @@ describe('<CourseOutline />', () => {
|
||||
locator: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@chaptersdafdd',
|
||||
courseKey: 'course-v1:UNIX+UX1+2025_T3',
|
||||
});
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl('block-v1:UNIX+UX1+2025_T3+type@chapter+block@chaptersdafdd'))
|
||||
.reply(200, courseSectionMock);
|
||||
|
||||
const addSectionFromLibraryButton = await screen.findByRole('button', {
|
||||
name: /use section from library/i,
|
||||
});
|
||||
fireEvent.click(addSectionFromLibraryButton);
|
||||
await user.click(addSectionFromLibraryButton);
|
||||
|
||||
// click dummy button to execute onComponentSelected prop.
|
||||
const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' });
|
||||
@@ -705,26 +709,54 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check edit title works for section, subsection and unit', async () => {
|
||||
const { findAllByTestId } = renderComponent();
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const checkEditTitle = async (element, item, newName, elementName) => {
|
||||
axiosMock.reset();
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(item.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
if (item.id === section.id) {
|
||||
// return normal section data the first time to keep original name first
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
// @ts-ignore
|
||||
.replyOnce(section);
|
||||
}
|
||||
|
||||
// mock section, subsection and unit name and check within the elements.
|
||||
// this is done to avoid adding conditions to this mock.
|
||||
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(item.id))
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, {
|
||||
...item,
|
||||
...section,
|
||||
display_name: newName,
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0],
|
||||
display_name: newName,
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0].childInfo.children[0],
|
||||
display_name: newName,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const editButton = await within(element).findByTestId(`${elementName}-edit-button`);
|
||||
fireEvent.click(editButton);
|
||||
const editField = await within(element).findByTestId(`${elementName}-edit-field`);
|
||||
fireEvent.change(editField, { target: { value: newName } });
|
||||
await act(async () => fireEvent.blur(editField));
|
||||
await user.keyboard('{enter}');
|
||||
expect(
|
||||
axiosMock.history.post[axiosMock.history.post.length - 1].data,
|
||||
).toBe(JSON.stringify({
|
||||
@@ -737,8 +769,7 @@ describe('<CourseOutline />', () => {
|
||||
};
|
||||
|
||||
// check section
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [sectionElement] = await screen.findAllByTestId('section-card');
|
||||
await checkEditTitle(sectionElement, section, 'New section name', 'section');
|
||||
|
||||
// check subsection
|
||||
@@ -1627,15 +1658,14 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check update highlights when update highlights query is successfully', async () => {
|
||||
const { getByRole } = renderComponent();
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const highlights = [
|
||||
'New Highlight 1',
|
||||
'New Highlight 2',
|
||||
'New Highlight 3',
|
||||
'New Highlight 4',
|
||||
'New Highlight 5',
|
||||
];
|
||||
|
||||
axiosMock
|
||||
@@ -1653,12 +1683,21 @@ describe('<CourseOutline />', () => {
|
||||
...section,
|
||||
highlights,
|
||||
});
|
||||
|
||||
await executeThunk(updateCourseSectionHighlightsQuery(section.id, highlights), store.dispatch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByRole('button', { name: '5 Section highlights' })).toBeInTheDocument();
|
||||
const highlightBtn = await screen.findAllByRole('button', { name: '0 Section highlights' });
|
||||
await user.click(highlightBtn[0]);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
fireEvent.change(await within(dialog).findByRole('textbox', { name: 'Highlight 1' }), {
|
||||
target: { value: 'New Highlight 1' },
|
||||
});
|
||||
fireEvent.change(await within(dialog).findByRole('textbox', { name: 'Highlight 2' }), {
|
||||
target: { value: 'New Highlight 2' },
|
||||
});
|
||||
fireEvent.change(await within(dialog).findByRole('textbox', { name: 'Highlight 3' }), {
|
||||
target: { value: 'New Highlight 3' },
|
||||
});
|
||||
await user.click(await within(dialog).findByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(await screen.findByRole('button', { name: '3 Section highlights' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check whether section move up and down options work correctly', async () => {
|
||||
@@ -2270,6 +2309,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit copy & paste option works correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
// get first section -> first subsection -> first unit element
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
@@ -2328,13 +2368,6 @@ describe('<CourseOutline />', () => {
|
||||
const lastUnitElement = (await within(subsectionElement).findAllByTestId('unit-card')).slice(-1)[0];
|
||||
expect(lastUnitElement).toHaveTextContent(unit.displayName);
|
||||
|
||||
// check pasteFileNotices in store
|
||||
expect(store.getState().courseOutline.pasteFileNotices).toEqual({
|
||||
newFiles: ['some.css'],
|
||||
conflictingFiles: ['con.css'],
|
||||
errorFiles: ['error.css'],
|
||||
});
|
||||
|
||||
let alerts = await screen.findAllByRole('alert');
|
||||
// Exclude processing notification toast
|
||||
alerts = alerts.filter((el) => !el.classList.contains('toast-container'));
|
||||
@@ -2343,18 +2376,18 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
// check alerts for errorFiles
|
||||
let dismissBtn = await within(alerts[0]).findByText('Dismiss');
|
||||
fireEvent.click(dismissBtn);
|
||||
await user.click(dismissBtn);
|
||||
|
||||
// check alerts for conflictingFiles
|
||||
dismissBtn = await within(alerts[1]).findByText('Dismiss');
|
||||
fireEvent.click(dismissBtn);
|
||||
await user.click(dismissBtn);
|
||||
|
||||
// check alerts for newFiles
|
||||
dismissBtn = await within(alerts[2]).findByText('Dismiss');
|
||||
fireEvent.click(dismissBtn);
|
||||
await user.click(dismissBtn);
|
||||
|
||||
// check pasteFileNotices in store
|
||||
expect(store.getState().courseOutline.pasteFileNotices).toEqual({});
|
||||
// check that all alerts are gone
|
||||
expect((screen.queryAllByRole('alert')).length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should show toats on export tags', async () => {
|
||||
|
||||
@@ -71,10 +71,8 @@ const CourseOutline = () => {
|
||||
const {
|
||||
courseId,
|
||||
courseUsageKey,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddBlock,
|
||||
handleAddAndOpenUnit,
|
||||
handleAddSection,
|
||||
isUnlinkModalOpen,
|
||||
closeUnlinkModal,
|
||||
currentSelection,
|
||||
@@ -97,6 +95,7 @@ const CourseOutline = () => {
|
||||
isDisabledReindexButton,
|
||||
isHighlightsModalOpen,
|
||||
isConfigureModalOpen,
|
||||
isConfigureOpPending,
|
||||
isDeleteModalOpen,
|
||||
closeHighlightsModal,
|
||||
handleConfigureModalClose,
|
||||
@@ -109,14 +108,17 @@ const CourseOutline = () => {
|
||||
handleEnableHighlightsSubmit,
|
||||
handleInternetConnectionFailed,
|
||||
handleOpenHighlightsModal,
|
||||
isSectionHighlightsUpdatePending,
|
||||
handleHighlightsFormSubmit,
|
||||
handleConfigureItemSubmit,
|
||||
handleDeleteItemSubmit,
|
||||
handleDuplicateSectionSubmit,
|
||||
handleDuplicateSubsectionSubmit,
|
||||
handleDuplicateUnitSubmit,
|
||||
isDuplicatingItem,
|
||||
handleVideoSharingOptionChange,
|
||||
handlePasteClipboardClick,
|
||||
isPasting,
|
||||
notificationDismissUrl,
|
||||
discussionsSettings,
|
||||
discussionsIncontextLearnmoreUrl,
|
||||
@@ -129,7 +131,6 @@ const CourseOutline = () => {
|
||||
handleSubsectionDragAndDrop,
|
||||
handleUnitDragAndDrop,
|
||||
errors,
|
||||
resetScrollState,
|
||||
handleUnlinkItemSubmit,
|
||||
} = useCourseOutline({ courseId });
|
||||
|
||||
@@ -386,7 +387,6 @@ const CourseOutline = () => {
|
||||
onDuplicateSubmit={handleDuplicateSectionSubmit}
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
onOrderChange={updateSectionOrderByIndex}
|
||||
resetScrollState={resetScrollState}
|
||||
>
|
||||
<SortableContext
|
||||
id={section.id}
|
||||
@@ -413,7 +413,6 @@ const CourseOutline = () => {
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onOrderChange={updateSubsectionOrderByIndex}
|
||||
onPasteClick={handlePasteClipboardClick}
|
||||
resetScrollState={resetScrollState}
|
||||
>
|
||||
<SortableContext
|
||||
id={subsection.id}
|
||||
@@ -523,10 +522,12 @@ const CourseOutline = () => {
|
||||
// Show processing toast if any mutation is running
|
||||
isShow={
|
||||
isShowProcessingNotification
|
||||
|| handleAddUnit.isPending
|
||||
|| handleAddBlock.isPending
|
||||
|| handleAddAndOpenUnit.isPending
|
||||
|| handleAddSubsection.isPending
|
||||
|| handleAddSection.isPending
|
||||
|| isConfigureOpPending
|
||||
|| isSectionHighlightsUpdatePending
|
||||
|| isDuplicatingItem
|
||||
|| isPasting
|
||||
}
|
||||
// HACK: Use saving as default title till we have a need for better messages
|
||||
title={processingNotificationTitle || NOTIFICATION_MESSAGES.saving}
|
||||
|
||||
@@ -14,10 +14,8 @@ jest.mock('@src/studio-home/data/selectors', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const handleAddSection = { mutateAsync: jest.fn() };
|
||||
const handleAddSubsection = { mutateAsync: jest.fn() };
|
||||
const handleAddAndOpenUnit = { mutateAsync: jest.fn() };
|
||||
const handleAddUnit = { mutateAsync: jest.fn() };
|
||||
const handleAddBlock = { mutateAsync: jest.fn() };
|
||||
const courseUsageKey = 'some/usage/key';
|
||||
const setCurrentSelection = jest.fn();
|
||||
jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
@@ -25,10 +23,8 @@ jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
courseId: 5,
|
||||
courseUsageKey,
|
||||
getUnitUrl: (id: string) => `/some/${id}`,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddAndOpenUnit,
|
||||
handleAddUnit,
|
||||
handleAddBlock,
|
||||
setCurrentSelection,
|
||||
}),
|
||||
}));
|
||||
@@ -81,9 +77,11 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
|
||||
|
||||
it('calls appropriate new handlers', async () => {
|
||||
const parentLocator = `parent-of-${containerType}`;
|
||||
const grandParentLocator = `grandparent-of-${containerType}`;
|
||||
render(<OutlineAddChildButtons
|
||||
childType={containerType}
|
||||
parentLocator={parentLocator}
|
||||
grandParentLocator={grandParentLocator}
|
||||
/>, { extraWrapper: OutlineSidebarProvider });
|
||||
|
||||
const newBtn = await screen.findByRole('button', { name: `New ${containerType}` });
|
||||
@@ -91,17 +89,18 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
|
||||
await userEvent.click(newBtn);
|
||||
switch (containerType) {
|
||||
case ContainerType.Section:
|
||||
await waitFor(() => expect(handleAddSection.mutateAsync).toHaveBeenCalledWith({
|
||||
await waitFor(() => expect(handleAddBlock.mutateAsync).toHaveBeenCalledWith({
|
||||
type: ContainerType.Chapter,
|
||||
parentLocator: courseUsageKey,
|
||||
displayName: 'Section',
|
||||
}));
|
||||
break;
|
||||
case ContainerType.Subsection:
|
||||
await waitFor(() => expect(handleAddSubsection.mutateAsync).toHaveBeenCalledWith({
|
||||
await waitFor(() => expect(handleAddBlock.mutateAsync).toHaveBeenCalledWith({
|
||||
type: ContainerType.Sequential,
|
||||
parentLocator,
|
||||
displayName: 'Subsection',
|
||||
sectionId: parentLocator,
|
||||
}));
|
||||
break;
|
||||
case ContainerType.Unit:
|
||||
@@ -109,6 +108,7 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
|
||||
type: ContainerType.Vertical,
|
||||
parentLocator,
|
||||
displayName: 'Unit',
|
||||
sectionId: grandParentLocator,
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -28,9 +28,7 @@ const AddPlaceholder = ({ parentLocator }: { parentLocator?: string }) => {
|
||||
const intl = useIntl();
|
||||
const { isCurrentFlowOn, currentFlow, stopCurrentFlow } = useOutlineSidebarContext();
|
||||
const {
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddBlock,
|
||||
handleAddAndOpenUnit,
|
||||
} = useCourseAuthoringContext();
|
||||
|
||||
@@ -58,10 +56,7 @@ const AddPlaceholder = ({ parentLocator }: { parentLocator?: string }) => {
|
||||
>
|
||||
<Col className="py-3">
|
||||
<Stack direction="horizontal" gap={3}>
|
||||
{(handleAddSection.isPending
|
||||
|| handleAddSubsection.isPending
|
||||
|| handleAddAndOpenUnit.isPending
|
||||
|| handleAddUnit.isPending) && (
|
||||
{(handleAddAndOpenUnit.isPending || handleAddBlock.isPending) && (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
<h3 className="mb-0">{getTitle()}</h3>
|
||||
@@ -86,11 +81,11 @@ interface BaseProps {
|
||||
btnClasses?: string;
|
||||
btnSize?: 'sm' | 'md' | 'lg' | 'inline';
|
||||
parentLocator: string;
|
||||
grandParentLocator?: string;
|
||||
}
|
||||
|
||||
interface NewChildButtonsProps extends BaseProps {
|
||||
handleUseFromLibraryClick?: () => void;
|
||||
grandParentLocator?: string;
|
||||
}
|
||||
|
||||
const NewOutlineAddChildButtons = ({
|
||||
@@ -113,8 +108,7 @@ const NewOutlineAddChildButtons = ({
|
||||
const intl = useIntl();
|
||||
const {
|
||||
courseUsageKey,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddBlock,
|
||||
handleAddAndOpenUnit,
|
||||
} = useCourseAuthoringContext();
|
||||
const { startCurrentFlow } = useOutlineSidebarContext();
|
||||
@@ -132,7 +126,7 @@ const NewOutlineAddChildButtons = ({
|
||||
newButton: messages.newSectionButton,
|
||||
importButton: messages.useSectionFromLibraryButton,
|
||||
};
|
||||
onNewCreateContent = () => handleAddSection.mutateAsync({
|
||||
onNewCreateContent = () => handleAddBlock.mutateAsync({
|
||||
type: ContainerType.Chapter,
|
||||
parentLocator: courseUsageKey,
|
||||
displayName: COURSE_BLOCK_NAMES.chapter.name,
|
||||
@@ -144,10 +138,11 @@ const NewOutlineAddChildButtons = ({
|
||||
newButton: messages.newSubsectionButton,
|
||||
importButton: messages.useSubsectionFromLibraryButton,
|
||||
};
|
||||
onNewCreateContent = () => handleAddSubsection.mutateAsync({
|
||||
onNewCreateContent = () => handleAddBlock.mutateAsync({
|
||||
type: ContainerType.Sequential,
|
||||
parentLocator,
|
||||
displayName: COURSE_BLOCK_NAMES.sequential.name,
|
||||
sectionId: parentLocator,
|
||||
});
|
||||
flowType = ContainerType.Subsection;
|
||||
break;
|
||||
@@ -160,6 +155,7 @@ const NewOutlineAddChildButtons = ({
|
||||
type: ContainerType.Vertical,
|
||||
parentLocator,
|
||||
displayName: COURSE_BLOCK_NAMES.vertical.name,
|
||||
sectionId: grandParentLocator,
|
||||
});
|
||||
flowType = ContainerType.Unit;
|
||||
break;
|
||||
@@ -226,6 +222,7 @@ const LegacyOutlineAddChildButtons = ({
|
||||
btnClasses = 'mt-4 border-gray-500 rounded-0',
|
||||
btnSize,
|
||||
parentLocator,
|
||||
grandParentLocator,
|
||||
onClickCard,
|
||||
}: BaseProps) => {
|
||||
// WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below,
|
||||
@@ -237,8 +234,7 @@ const LegacyOutlineAddChildButtons = ({
|
||||
const intl = useIntl();
|
||||
const {
|
||||
courseUsageKey,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddBlock,
|
||||
handleAddAndOpenUnit,
|
||||
} = useCourseAuthoringContext();
|
||||
const [
|
||||
@@ -263,12 +259,12 @@ const LegacyOutlineAddChildButtons = ({
|
||||
importButton: messages.useSectionFromLibraryButton,
|
||||
modalTitle: messages.sectionPickerModalTitle,
|
||||
};
|
||||
onNewCreateContent = () => handleAddSection.mutateAsync({
|
||||
onNewCreateContent = () => handleAddBlock.mutateAsync({
|
||||
type: ContainerType.Chapter,
|
||||
parentLocator: courseUsageKey,
|
||||
displayName: COURSE_BLOCK_NAMES.chapter.name,
|
||||
});
|
||||
onUseLibraryContent = (selected: SelectedComponent) => handleAddSection.mutateAsync({
|
||||
onUseLibraryContent = (selected: SelectedComponent) => handleAddBlock.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Chapter,
|
||||
parentLocator: courseUsageKey,
|
||||
@@ -283,16 +279,18 @@ const LegacyOutlineAddChildButtons = ({
|
||||
importButton: messages.useSubsectionFromLibraryButton,
|
||||
modalTitle: messages.subsectionPickerModalTitle,
|
||||
};
|
||||
onNewCreateContent = () => handleAddSubsection.mutateAsync({
|
||||
onNewCreateContent = () => handleAddBlock.mutateAsync({
|
||||
type: ContainerType.Sequential,
|
||||
parentLocator,
|
||||
displayName: COURSE_BLOCK_NAMES.sequential.name,
|
||||
sectionId: parentLocator,
|
||||
});
|
||||
onUseLibraryContent = (selected: SelectedComponent) => handleAddSubsection.mutateAsync({
|
||||
onUseLibraryContent = (selected: SelectedComponent) => handleAddBlock.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Sequential,
|
||||
parentLocator,
|
||||
libraryContentKey: selected.usageKey,
|
||||
sectionId: parentLocator,
|
||||
});
|
||||
visibleTabs = [ContentType.subsections];
|
||||
query = ['block_type = "subsection"'];
|
||||
@@ -307,12 +305,14 @@ const LegacyOutlineAddChildButtons = ({
|
||||
type: ContainerType.Vertical,
|
||||
parentLocator,
|
||||
displayName: COURSE_BLOCK_NAMES.vertical.name,
|
||||
sectionId: grandParentLocator,
|
||||
});
|
||||
onUseLibraryContent = (selected: SelectedComponent) => handleAddAndOpenUnit.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Vertical,
|
||||
parentLocator,
|
||||
libraryContentKey: selected.usageKey,
|
||||
sectionId: grandParentLocator,
|
||||
});
|
||||
visibleTabs = [ContentType.units];
|
||||
query = ['block_type = "unit"'];
|
||||
|
||||
@@ -48,6 +48,7 @@ interface CardHeaderProps {
|
||||
onClickMoveDown: () => void;
|
||||
onClickCopy?: () => void;
|
||||
onClickCard?: (e: React.MouseEvent) => void;
|
||||
onClickManageTags?: () => void;
|
||||
titleComponent: ReactNode;
|
||||
namePrefix: string;
|
||||
proctoringExamConfigurationLink?: string,
|
||||
@@ -86,6 +87,7 @@ const CardHeader = ({
|
||||
onClickMoveDown,
|
||||
onClickCopy,
|
||||
onClickCard,
|
||||
onClickManageTags,
|
||||
titleComponent,
|
||||
namePrefix,
|
||||
actions,
|
||||
@@ -113,6 +115,7 @@ const CardHeader = ({
|
||||
if (showNewSidebar && showAlignSidebar) {
|
||||
setCurrentPageKey('align');
|
||||
onClickMenuButton();
|
||||
onClickManageTags?.();
|
||||
} else {
|
||||
openLegacyTagsDrawer();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { CourseOutline, CourseDetails, CourseItemUpdateResult } from './types';
|
||||
import {
|
||||
CourseOutline,
|
||||
CourseDetails,
|
||||
CourseItemUpdateResult,
|
||||
ConfigureSectionData,
|
||||
ConfigureSubsectionData,
|
||||
ConfigureUnitData,
|
||||
StaticFileNotices,
|
||||
} from './types';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
@@ -212,23 +220,15 @@ export async function publishCourseItem(itemId: string): Promise<CourseItemUpdat
|
||||
|
||||
/**
|
||||
* Configure course section
|
||||
* @param {string} sectionId
|
||||
* @param {boolean} isVisibleToStaffOnly
|
||||
* @param {string} startDatetime
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function configureCourseSection(
|
||||
sectionId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
startDatetime: string,
|
||||
): Promise<object> {
|
||||
export async function configureCourseSection(variables: ConfigureSectionData): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(sectionId), {
|
||||
.post(getCourseItemApiUrl(variables.sectionId), {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
// The backend expects metadata.visible_to_staff_only to either true or null
|
||||
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
|
||||
start: startDatetime,
|
||||
visible_to_staff_only: variables.isVisibleToStaffOnly ? true : null,
|
||||
start: variables.startDatetime,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -236,66 +236,30 @@ export async function configureCourseSection(
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure course section
|
||||
* @param {string} itemId
|
||||
* @param {string} isVisibleToStaffOnly
|
||||
* @param {string} releaseDate
|
||||
* @param {string} graderType
|
||||
* @param {string} dueDate
|
||||
* @param {boolean} isProctoredExam,
|
||||
* @param {boolean} isOnboardingExam,
|
||||
* @param {boolean} isPracticeExam,
|
||||
* @param {string} examReviewRules,
|
||||
* @param {boolean} isTimeLimited
|
||||
* @param {number} defaultTimeLimitMin
|
||||
* @param {string} hideAfterDue
|
||||
* @param {string} showCorrectness
|
||||
* @param {boolean} isPrereq,
|
||||
* @param {string} prereqUsageKey,
|
||||
* @param {number} prereqMinScore,
|
||||
* @param {number} prereqMinCompletion,
|
||||
* @returns {Promise<Object>}
|
||||
* Configure course subsection
|
||||
*/
|
||||
export async function configureCourseSubsection(
|
||||
itemId: string,
|
||||
isVisibleToStaffOnly: string,
|
||||
releaseDate: string,
|
||||
graderType: string,
|
||||
dueDate: string,
|
||||
isTimeLimited: boolean,
|
||||
isProctoredExam: boolean,
|
||||
isOnboardingExam: boolean,
|
||||
isPracticeExam: boolean,
|
||||
examReviewRules: string,
|
||||
defaultTimeLimitMin: number,
|
||||
hideAfterDue: string,
|
||||
showCorrectness: string,
|
||||
isPrereq: boolean,
|
||||
prereqUsageKey: string,
|
||||
prereqMinScore: number,
|
||||
prereqMinCompletion: number,
|
||||
): Promise<object> {
|
||||
export async function configureCourseSubsection(variables: ConfigureSubsectionData): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(itemId), {
|
||||
.post(getCourseItemApiUrl(variables.itemId), {
|
||||
publish: 'republish',
|
||||
graderType,
|
||||
isPrereq,
|
||||
prereqUsageKey,
|
||||
prereqMinScore,
|
||||
prereqMinCompletion,
|
||||
graderType: variables.graderType,
|
||||
isPrereq: variables.isPrereq,
|
||||
prereqUsageKey: variables.prereqUsageKey,
|
||||
prereqMinScore: variables.prereqMinScore,
|
||||
prereqMinCompletion: variables.prereqMinCompletion,
|
||||
metadata: {
|
||||
// The backend expects metadata.visible_to_staff_only to either true or null
|
||||
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
|
||||
due: dueDate,
|
||||
hide_after_due: hideAfterDue,
|
||||
show_correctness: showCorrectness,
|
||||
is_practice_exam: isPracticeExam,
|
||||
is_time_limited: isTimeLimited,
|
||||
is_proctored_enabled: isProctoredExam || isPracticeExam || isOnboardingExam,
|
||||
exam_review_rules: examReviewRules,
|
||||
default_time_limit_minutes: defaultTimeLimitMin,
|
||||
is_onboarding_exam: isOnboardingExam,
|
||||
start: releaseDate,
|
||||
visible_to_staff_only: variables.isVisibleToStaffOnly ? true : null,
|
||||
due: variables.dueDate,
|
||||
hide_after_due: variables.hideAfterDue,
|
||||
show_correctness: variables.showCorrectness,
|
||||
is_practice_exam: variables.isPracticeExam,
|
||||
is_time_limited: variables.isTimeLimited,
|
||||
is_proctored_enabled: variables.isProctoredExam || variables.isPracticeExam || variables.isOnboardingExam,
|
||||
exam_review_rules: variables.examReviewRules,
|
||||
default_time_limit_minutes: variables.defaultTimeLimitMin,
|
||||
is_onboarding_exam: variables.isOnboardingExam,
|
||||
start: variables.releaseDate,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
@@ -303,26 +267,16 @@ export async function configureCourseSubsection(
|
||||
|
||||
/**
|
||||
* Configure course unit
|
||||
* @param {string} unitId
|
||||
* @param {boolean} isVisibleToStaffOnly
|
||||
* @param {object} groupAccess
|
||||
* @param {boolean} discussionEnabled
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function configureCourseUnit(
|
||||
unitId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
groupAccess: object,
|
||||
discussionEnabled: boolean,
|
||||
): Promise<object> {
|
||||
export async function configureCourseUnit(variables: ConfigureUnitData): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(unitId), {
|
||||
.post(getCourseItemApiUrl(variables.unitId), {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
// The backend expects metadata.visible_to_staff_only to either true or null
|
||||
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
|
||||
group_access: groupAccess,
|
||||
discussion_enabled: discussionEnabled,
|
||||
visible_to_staff_only: variables.isVisibleToStaffOnly ? true : null,
|
||||
group_access: variables.groupAccess,
|
||||
discussion_enabled: variables.discussionEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -361,7 +315,10 @@ export async function deleteCourseItem(itemId: string): Promise<object> {
|
||||
/**
|
||||
* Duplicate course section
|
||||
*/
|
||||
export async function duplicateCourseItem(itemId: string, parentId: string): Promise<XBlock> {
|
||||
export async function duplicateCourseItem(itemId: string, parentId: string): Promise<{
|
||||
courseKey: string;
|
||||
locator: string;
|
||||
}> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(), {
|
||||
duplicate_source_locator: itemId,
|
||||
@@ -467,7 +424,12 @@ export async function setVideoSharingOption(
|
||||
* @param {string} parentLocator
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function pasteBlock(parentLocator: string): Promise<object> {
|
||||
export async function pasteBlock(parentLocator: string): Promise<{
|
||||
locator: string;
|
||||
courseKey: string;
|
||||
staticFileNotices: StaticFileNotices;
|
||||
upstreamRef: string;
|
||||
}> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(), {
|
||||
parent_locator: parentLocator,
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { containerComparisonQueryKeys } from '@src/container-comparison/data/apiHooks';
|
||||
import type { XBlock } from '@src/data/types';
|
||||
import { getCourseKey } from '@src/generic/key-utils';
|
||||
import { addSection, duplicateSection, updateSectionList } from '@src/course-outline/data/slice';
|
||||
import {
|
||||
ConfigureSectionData,
|
||||
ConfigureSubsectionData,
|
||||
ConfigureUnitData,
|
||||
StaticFileNotices,
|
||||
} from '@src/course-outline/data/types';
|
||||
import { createGlobalState } from '@src/data/apiHooks';
|
||||
import type { XBlockBase, XblockChildInfo } from '@src/data/types';
|
||||
import { getBlockType, getCourseKey } from '@src/generic/key-utils';
|
||||
import { handleResponseErrors } from '@src/generic/saving-error-alert';
|
||||
import { ParentIds } from '@src/generic/types';
|
||||
import {
|
||||
QueryClient,
|
||||
skipToken, useMutation, useQuery, useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import {
|
||||
createCourseXblock,
|
||||
type CreateCourseXBlockType,
|
||||
@@ -14,6 +24,12 @@ import {
|
||||
getCourseDetails,
|
||||
getCourseItem,
|
||||
publishCourseItem,
|
||||
configureCourseSection,
|
||||
configureCourseSubsection,
|
||||
configureCourseUnit,
|
||||
updateCourseSectionHighlights,
|
||||
duplicateCourseItem,
|
||||
pasteBlock,
|
||||
} from './api';
|
||||
|
||||
export const courseOutlineQueryKeys = {
|
||||
@@ -26,6 +42,14 @@ export const courseOutlineQueryKeys = {
|
||||
...courseOutlineQueryKeys.course(itemId ? getCourseKey(itemId) : undefined),
|
||||
itemId,
|
||||
],
|
||||
scrollToCourseItemId: (courseId?: string) => [
|
||||
...courseOutlineQueryKeys.course(courseId),
|
||||
'scroll',
|
||||
],
|
||||
pasteFileNotices: (courseId?: string) => [
|
||||
...courseOutlineQueryKeys.course(courseId),
|
||||
'pasteFileNotices',
|
||||
],
|
||||
courseDetails: (courseId?: string) => [
|
||||
...courseOutlineQueryKeys.course(courseId),
|
||||
'details',
|
||||
@@ -41,22 +65,30 @@ export const courseOutlineQueryKeys = {
|
||||
],
|
||||
};
|
||||
|
||||
type ParentIds = {
|
||||
/** This id will be used to invalidate data of parent subsection */
|
||||
subsectionId?: string;
|
||||
/** This id will be used to invalidate data of parent section */
|
||||
sectionId?: string;
|
||||
type ScrollState = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export const useScrollState = createGlobalState<ScrollState>(courseOutlineQueryKeys.scrollToCourseItemId, {
|
||||
id: undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
* Invalidate parent Subsection and Section data.
|
||||
*
|
||||
* This function ensures that cached data for parent subsection and section is invalidated
|
||||
* when child items are created, updated, or deleted.
|
||||
*
|
||||
* Priority:
|
||||
* 1. If sectionId exists, invalidate section data which also updates all children block data
|
||||
* 2. Else If subsectionId exists, invalidate subsection data
|
||||
*/
|
||||
const invalidateParentQueries = async (queryClient: QueryClient, variables: ParentIds) => {
|
||||
if (variables.subsectionId) {
|
||||
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.subsectionId) });
|
||||
}
|
||||
if (variables.sectionId) {
|
||||
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.sectionId) });
|
||||
} else if (variables.subsectionId) {
|
||||
// istanbul ignore next
|
||||
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.subsectionId) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -66,31 +98,74 @@ type CreateCourseXBlockMutationProps = CreateCourseXBlockType & ParentIds;
|
||||
* Hook to create an XBLOCK in a course .
|
||||
* The `locator` is the ID of the parent block where this new XBLOCK should be created.
|
||||
* Can also be used to import block from library by passing `libraryContentKey` in request body
|
||||
*
|
||||
* @param callback - Optional function called after successful creation to handle additional logic
|
||||
* @returns Mutation object for creating course blocks
|
||||
*/
|
||||
export const useCreateCourseBlock = (
|
||||
courseKey: string,
|
||||
callback?: ((locator: string, parentLocator: string) => Promise<void>),
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { setData } = useScrollState(courseKey);
|
||||
const dispatch = useDispatch();
|
||||
return useMutation({
|
||||
mutationFn: (variables: CreateCourseXBlockMutationProps) => createCourseXblock(variables),
|
||||
onSettled: async (data: { locator: string; }, _err, variables) => {
|
||||
onSuccess: async (data: { locator: string; }, variables) => {
|
||||
await callback?.(data.locator, variables.parentLocator);
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.parentLocator) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(data.locator)),
|
||||
});
|
||||
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
|
||||
await invalidateParentQueries(queryClient, variables);
|
||||
// scroll to newly added block
|
||||
setData({ id: data.locator });
|
||||
// if newly created block is chapter or section, fetch and add it to store
|
||||
// all other types are handled by invalidateParentQueries and useCourseItemData
|
||||
if (getBlockType(data.locator) === 'chapter') {
|
||||
const newBlock = await getCourseItem(data.locator);
|
||||
dispatch(addSection(newBlock));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCourseItemData = <T = XBlock>(itemId?: string, initialData?: T, enabled: boolean = true) => (
|
||||
useQuery({
|
||||
export const useCourseItemData = <T extends XBlockBase>(itemId?: string, initialData?: T, enabled: boolean = true) => {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
return useQuery<T>({
|
||||
initialData,
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(itemId),
|
||||
queryFn: enabled && itemId ? () => getCourseItem<T>(itemId!) : skipToken,
|
||||
})
|
||||
);
|
||||
queryFn: enabled && itemId ? async () => {
|
||||
const data = await getCourseItem<T>(itemId!);
|
||||
// If the container has children blocks, update children react-query cache
|
||||
// data without hitting the API as each xblock call returns its children information as well.
|
||||
if ('childInfo' in data) {
|
||||
// This could mean that data is of a section or subsection
|
||||
(data.childInfo as XblockChildInfo).children.forEach(async (child) => {
|
||||
await queryClient.cancelQueries({ queryKey: courseOutlineQueryKeys.courseItemId(child.id) });
|
||||
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(child.id), child);
|
||||
if ('childInfo' in child) {
|
||||
// This means that the data is of section and so its children subsections also
|
||||
// have children i.e. units
|
||||
(child.childInfo as XblockChildInfo).children.forEach(async (grandChild) => {
|
||||
await queryClient.cancelQueries({ queryKey: courseOutlineQueryKeys.courseItemId(grandChild.id) });
|
||||
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(grandChild.id), grandChild);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
// We update redux store section list to update children list in outline.
|
||||
// Even though each block has its own hook to fetch data, new child blocks or deleted blocks
|
||||
// won't be detected as the child blocks are rendered in the outline from the top level
|
||||
// sectionList from redux store.
|
||||
if (['chapter', 'section'].includes(data.category)) {
|
||||
const payload = { [data.id]: data };
|
||||
dispatch(updateSectionList(payload));
|
||||
}
|
||||
return data;
|
||||
} : skipToken,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCourseDetails = (courseId?: string, enabled: boolean = true) => (
|
||||
useQuery({
|
||||
@@ -99,6 +174,15 @@ export const useCourseDetails = (courseId?: string, enabled: boolean = true) =>
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook to update the display name of a course block.
|
||||
*
|
||||
* This mutation updates the display name of a course item and invalidates relevant cache queries
|
||||
* to ensure the UI reflects the changes.
|
||||
*
|
||||
* @param courseId - The ID of the course containing the item
|
||||
* @returns Mutation object for updating course block names
|
||||
*/
|
||||
export const useUpdateCourseBlockName = (courseId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -107,10 +191,9 @@ export const useUpdateCourseBlockName = (courseId: string) => {
|
||||
displayName: string;
|
||||
} & ParentIds) => editItemDisplayName({ itemId: variables.itemId, displayName: variables.displayName }),
|
||||
onSuccess: async (_data, variables) => {
|
||||
await queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) });
|
||||
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(courseId) });
|
||||
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) });
|
||||
await invalidateParentQueries(queryClient, variables);
|
||||
queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) });
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(courseId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -122,9 +205,8 @@ export const usePublishCourseItem = () => {
|
||||
itemId: string;
|
||||
} & ParentIds) => publishCourseItem(variables.itemId),
|
||||
onSettled: (_data, _err, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) });
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) });
|
||||
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -141,3 +223,103 @@ export const useDeleteCourseItem = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useConfigureSection = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (variables: ConfigureSectionData & ParentIds) => configureCourseSection(variables),
|
||||
onSettled: (_data, _err, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.sectionId)),
|
||||
});
|
||||
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useConfigureSubsection = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (variables: ConfigureSubsectionData & ParentIds) => configureCourseSubsection(variables),
|
||||
onSettled: (_data, _err, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) });
|
||||
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useConfigureUnit = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (variables: ConfigureUnitData & ParentIds) => configureCourseUnit(variables),
|
||||
onSettled: (_data, _err, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.unitId)) });
|
||||
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateCourseSectionHighlights = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (variables: {
|
||||
sectionId: string;
|
||||
highlights: string[];
|
||||
} & ParentIds) => updateCourseSectionHighlights(variables.sectionId, variables.highlights),
|
||||
onSettled: (_data, _err, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.sectionId)),
|
||||
});
|
||||
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDuplicateItem = (courseKey: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { setData } = useScrollState(courseKey);
|
||||
return useMutation({
|
||||
mutationFn: (variables: {
|
||||
itemId: string;
|
||||
parentId: string;
|
||||
} & ParentIds) => duplicateCourseItem(variables.itemId, variables.parentId),
|
||||
onSuccess: async (data, variables) => {
|
||||
await invalidateParentQueries(queryClient, variables);
|
||||
// add duplicated section to store, subsection and unit are handled by invalidateParentQueries
|
||||
if (getBlockType(variables.itemId) === 'chapter') {
|
||||
const duplicatedItem = await getCourseItem(data.locator);
|
||||
dispatch(duplicateSection({ id: variables.itemId, duplicatedItem }));
|
||||
}
|
||||
// scroll to newly added block
|
||||
setData({ id: data.locator });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePasteFileNotices = createGlobalState<StaticFileNotices>(
|
||||
courseOutlineQueryKeys.pasteFileNotices,
|
||||
{
|
||||
newFiles: [],
|
||||
conflictingFiles: [],
|
||||
errorFiles: [],
|
||||
},
|
||||
);
|
||||
|
||||
export const usePasteItem = (courseId?: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { setData: setScrollState } = useScrollState(courseId);
|
||||
const { setData } = usePasteFileNotices(courseId);
|
||||
return useMutation({
|
||||
mutationFn: (variables: {
|
||||
parentLocator: string;
|
||||
} & ParentIds) => pasteBlock(variables.parentLocator),
|
||||
onSuccess: async (data, variables) => {
|
||||
await invalidateParentQueries(queryClient, variables);
|
||||
// set pasteFileNotices
|
||||
setData(data.staticFileNotices);
|
||||
// scroll to pasted block
|
||||
setScrollState({ id: data.locator });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -7,6 +7,5 @@ export const getCourseActions = (state) => state.courseOutline.actions;
|
||||
export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive;
|
||||
export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams;
|
||||
export const getTimedExamsFlag = (state) => state.courseOutline.enableTimedExams;
|
||||
export const getPasteFileNotices = (state) => state.courseOutline.pasteFileNotices;
|
||||
export const getErrors = (state) => state.courseOutline.errors;
|
||||
export const getCreatedOn = (state) => state.courseOutline.createdOn;
|
||||
|
||||
@@ -47,9 +47,8 @@ const initialState = {
|
||||
},
|
||||
enableProctoredExams: false,
|
||||
enableTimedExams: false,
|
||||
pasteFileNotices: {},
|
||||
createdOn: null,
|
||||
} satisfies CourseOutlineState as unknown as CourseOutlineState;
|
||||
} satisfies CourseOutlineState;
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'courseOutline',
|
||||
@@ -133,27 +132,6 @@ const slice = createSlice({
|
||||
payload,
|
||||
];
|
||||
},
|
||||
resetScrollField: (state) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
section.shouldScroll = false;
|
||||
section.childInfo.children.map((subsection) => {
|
||||
subsection.shouldScroll = false;
|
||||
return subsection;
|
||||
});
|
||||
return section;
|
||||
});
|
||||
},
|
||||
addSubsection: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id === payload.parentLocator) {
|
||||
section.childInfo.children = [
|
||||
...section.childInfo.children.filter(child => child.id !== payload.data.id), // Filter to avoid duplicates
|
||||
payload.data,
|
||||
];
|
||||
}
|
||||
return section;
|
||||
});
|
||||
},
|
||||
deleteSection: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.filter(
|
||||
({ id }) => id !== payload.itemId,
|
||||
@@ -170,25 +148,6 @@ const slice = createSlice({
|
||||
return section;
|
||||
});
|
||||
},
|
||||
// FIXME: This is a temporary measure to add unit using redux even while we are
|
||||
// actively trying to get rid of it.
|
||||
// To remove this and other add functions, we need to migrate course outline data
|
||||
// to a react-query and perform optimistic updates to add/remove content.
|
||||
addUnit: /* istanbul ignore next */ (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
section.childInfo.children = section.childInfo.children.map((subsection) => {
|
||||
if (subsection.id !== payload.parentLocator) {
|
||||
return subsection;
|
||||
}
|
||||
subsection.childInfo.children = [
|
||||
...subsection.childInfo.children.filter(({ id }) => id !== payload.data.id),
|
||||
payload.data,
|
||||
];
|
||||
return subsection;
|
||||
});
|
||||
return section;
|
||||
});
|
||||
},
|
||||
deleteUnit: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id !== payload.sectionId) {
|
||||
@@ -214,20 +173,11 @@ const slice = createSlice({
|
||||
return [...result, currentValue];
|
||||
}, []);
|
||||
},
|
||||
setPasteFileNotices: (state: CourseOutlineState, { payload }) => {
|
||||
state.pasteFileNotices = payload;
|
||||
},
|
||||
removePasteFileNotices: (state: CourseOutlineState, { payload }) => {
|
||||
const pasteFileNotices = { ...state.pasteFileNotices };
|
||||
payload.forEach((key: string | number) => delete pasteFileNotices[key]);
|
||||
state.pasteFileNotices = pasteFileNotices;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
addSection,
|
||||
addSubsection,
|
||||
fetchOutlineIndexSuccess,
|
||||
updateOutlineIndexLoadingStatus,
|
||||
updateReindexLoadingStatus,
|
||||
@@ -242,13 +192,9 @@ export const {
|
||||
deleteSection,
|
||||
deleteSubsection,
|
||||
deleteUnit,
|
||||
addUnit,
|
||||
duplicateSection,
|
||||
reorderSectionList,
|
||||
setPasteFileNotices,
|
||||
removePasteFileNotices,
|
||||
dismissError,
|
||||
resetScrollField,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -11,21 +11,15 @@ import {
|
||||
} from '../utils/getChecklistForStatusBar';
|
||||
import { getErrorDetails } from '../utils/getErrorDetails';
|
||||
import {
|
||||
duplicateCourseItem,
|
||||
enableCourseHighlightsEmails,
|
||||
getCourseBestPractices,
|
||||
getCourseLaunch,
|
||||
getCourseOutlineIndex,
|
||||
getCourseItem,
|
||||
configureCourseSection,
|
||||
configureCourseSubsection,
|
||||
configureCourseUnit,
|
||||
restartIndexingOnCourse,
|
||||
updateCourseSectionHighlights,
|
||||
setSectionOrderList,
|
||||
setVideoSharingOption,
|
||||
setCourseItemOrderList,
|
||||
pasteBlock,
|
||||
dismissNotification, createDiscussionsTopics,
|
||||
} from './api';
|
||||
import {
|
||||
@@ -39,9 +33,7 @@ import {
|
||||
updateSavingStatus,
|
||||
updateSectionList,
|
||||
updateFetchSectionLoadingStatus,
|
||||
duplicateSection,
|
||||
reorderSectionList,
|
||||
setPasteFileNotices,
|
||||
updateCourseLaunchQueryStatus,
|
||||
} from './slice';
|
||||
|
||||
@@ -201,32 +193,13 @@ export function fetchCourseReindexQuery(reindexLink: string) {
|
||||
/**
|
||||
* Fetches course sections and optionally scrolls to a specific subsection/unit.
|
||||
*/
|
||||
export function fetchCourseSectionQuery(sectionIds: string[], scrollToId?: {
|
||||
subsectionId: string,
|
||||
unitId?: string,
|
||||
}) {
|
||||
export function fetchCourseSectionQuery(sectionIds: string[]) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
try {
|
||||
const sections = {};
|
||||
const results = await Promise.all(sectionIds.map((sectionId) => getCourseItem(sectionId)));
|
||||
results.forEach(section => {
|
||||
if (scrollToId) {
|
||||
const targetSubsection = section?.childInfo?.children?.find(
|
||||
subsection => subsection.id === scrollToId.subsectionId,
|
||||
);
|
||||
|
||||
if (targetSubsection) {
|
||||
if (scrollToId.unitId) {
|
||||
const targetUnit = targetSubsection?.childInfo?.children?.find(unit => unit.id === scrollToId.unitId);
|
||||
if (targetUnit) {
|
||||
targetUnit.shouldScroll = true;
|
||||
}
|
||||
} else {
|
||||
targetSubsection.shouldScroll = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
sections[section.id] = section;
|
||||
});
|
||||
dispatch(updateSectionList(sections));
|
||||
@@ -240,186 +213,6 @@ export function fetchCourseSectionQuery(sectionIds: string[], scrollToId?: {
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCourseSectionHighlightsQuery(sectionId: string, highlights: string[]) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await updateCourseSectionHighlights(sectionId, highlights).then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery([sectionId]));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseItemQuery(sectionId: string, configureFn: () => Promise<any>) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await configureFn().then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery([sectionId]));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseSectionQuery(sectionId: string, isVisibleToStaffOnly: boolean, startDatetime: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
sectionId,
|
||||
async () => configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseSubsectionQuery(
|
||||
itemId: string,
|
||||
sectionId: string,
|
||||
isVisibleToStaffOnly: string,
|
||||
releaseDate: string,
|
||||
graderType: string,
|
||||
dueDate: string,
|
||||
isTimeLimited: boolean,
|
||||
isProctoredExam: boolean,
|
||||
isOnboardingExam: boolean,
|
||||
isPracticeExam: boolean,
|
||||
examReviewRules: string,
|
||||
defaultTimeLimitMin: number,
|
||||
hideAfterDue: string,
|
||||
showCorrectness: string,
|
||||
isPrereq: boolean,
|
||||
prereqUsageKey: string,
|
||||
prereqMinScore: number,
|
||||
prereqMinCompletion: number,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
sectionId,
|
||||
async () => configureCourseSubsection(
|
||||
itemId,
|
||||
isVisibleToStaffOnly,
|
||||
releaseDate,
|
||||
graderType,
|
||||
dueDate,
|
||||
isTimeLimited,
|
||||
isProctoredExam,
|
||||
isOnboardingExam,
|
||||
isPracticeExam,
|
||||
examReviewRules,
|
||||
defaultTimeLimitMin,
|
||||
hideAfterDue,
|
||||
showCorrectness,
|
||||
isPrereq,
|
||||
prereqUsageKey,
|
||||
prereqMinScore,
|
||||
prereqMinCompletion,
|
||||
),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseUnitQuery(
|
||||
itemId: string,
|
||||
sectionId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
groupAccess: object,
|
||||
discussionEnabled: boolean,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
sectionId,
|
||||
async () => configureCourseUnit(itemId, isVisibleToStaffOnly, groupAccess, discussionEnabled),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function to duplicate any course item. See wrapper functions below for specific implementations.
|
||||
* @param {string} itemId
|
||||
* @param {string} parentLocator
|
||||
* @param {(locator) => Promise<any>} duplicateFn
|
||||
*/
|
||||
function duplicateCourseItemQuery(
|
||||
itemId: string,
|
||||
parentLocator: string,
|
||||
duplicateFn: (locator: string) => Promise<any>,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating));
|
||||
|
||||
try {
|
||||
await duplicateCourseItem(itemId, parentLocator).then(async (result) => {
|
||||
if (result) {
|
||||
await duplicateFn(result.locator);
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateSectionQuery(sectionId: string, courseBlockId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
sectionId,
|
||||
courseBlockId,
|
||||
async (locator) => {
|
||||
const duplicatedItem = await getCourseItem(locator);
|
||||
// Page should scroll to newly duplicated item.
|
||||
duplicatedItem.shouldScroll = true;
|
||||
dispatch(duplicateSection({ id: sectionId, duplicatedItem }));
|
||||
},
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateSubsectionQuery(subsectionId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
subsectionId,
|
||||
sectionId,
|
||||
async (itemId: string) => dispatch(fetchCourseSectionQuery([sectionId], {
|
||||
subsectionId: itemId, // To scroll to the newly duplicated subsection
|
||||
})),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateUnitQuery(unitId: string, subsectionId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
unitId,
|
||||
subsectionId,
|
||||
async (itemId: string) => dispatch(fetchCourseSectionQuery([sectionId], {
|
||||
subsectionId,
|
||||
unitId: itemId, // To scroll to the newly duplicated unit
|
||||
})),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
function setBlockOrderListQuery(
|
||||
parentId: string,
|
||||
blockIds: string[],
|
||||
@@ -515,27 +308,6 @@ export function setUnitOrderListQuery(
|
||||
};
|
||||
}
|
||||
|
||||
export function pasteClipboardContent(parentLocator: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting));
|
||||
|
||||
try {
|
||||
await pasteBlock(parentLocator).then(async (result: any) => {
|
||||
if (result) {
|
||||
dispatch(fetchCourseSectionQuery([sectionId], { subsectionId: parentLocator, unitId: result.locator }));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(setPasteFileNotices(result?.staticFileNotices));
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function dismissNotificationQuery(url: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
|
||||
@@ -74,7 +74,6 @@ export interface CourseOutlineState {
|
||||
actions: XBlockActions;
|
||||
enableProctoredExams: boolean;
|
||||
enableTimedExams: boolean;
|
||||
pasteFileNotices: object;
|
||||
createdOn: null | Date;
|
||||
}
|
||||
|
||||
@@ -90,3 +89,42 @@ export interface CourseItemUpdateResult {
|
||||
displayName?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConfigureSectionData {
|
||||
sectionId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
startDatetime: string,
|
||||
}
|
||||
|
||||
export interface ConfigureSubsectionData {
|
||||
itemId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
releaseDate: string,
|
||||
graderType: string,
|
||||
dueDate: string,
|
||||
isTimeLimited: boolean,
|
||||
isProctoredExam: boolean,
|
||||
isOnboardingExam: boolean,
|
||||
isPracticeExam: boolean,
|
||||
examReviewRules: string,
|
||||
defaultTimeLimitMin: number,
|
||||
hideAfterDue: string,
|
||||
showCorrectness: string,
|
||||
isPrereq: boolean,
|
||||
prereqUsageKey: string,
|
||||
prereqMinScore: number,
|
||||
prereqMinCompletion: number,
|
||||
}
|
||||
|
||||
export interface ConfigureUnitData {
|
||||
unitId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
groupAccess: object,
|
||||
discussionEnabled: boolean,
|
||||
}
|
||||
|
||||
export type StaticFileNotices = {
|
||||
conflictingFiles: string[],
|
||||
errorFiles: string[],
|
||||
newFiles: string[],
|
||||
};
|
||||
|
||||
@@ -12,13 +12,21 @@ import { ContainerType, getBlockType } from '@src/generic/key-utils';
|
||||
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { useUnlinkDownstream } from '@src/generic/unlink-modal';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { courseOutlineQueryKeys, useDeleteCourseItem } from '@src/course-outline/data/apiHooks';
|
||||
import {
|
||||
courseOutlineQueryKeys,
|
||||
useConfigureSection,
|
||||
useConfigureSubsection,
|
||||
useConfigureUnit,
|
||||
useDeleteCourseItem,
|
||||
useDuplicateItem,
|
||||
usePasteItem,
|
||||
useUpdateCourseSectionHighlights,
|
||||
} from '@src/course-outline/data/apiHooks';
|
||||
import { COURSE_BLOCK_NAMES } from './constants';
|
||||
import {
|
||||
deleteSection,
|
||||
deleteSubsection,
|
||||
deleteUnit,
|
||||
resetScrollField,
|
||||
updateSavingStatus,
|
||||
} from './data/slice';
|
||||
import {
|
||||
@@ -33,23 +41,15 @@ import {
|
||||
getCreatedOn,
|
||||
} from './data/selectors';
|
||||
import {
|
||||
duplicateSectionQuery,
|
||||
duplicateSubsectionQuery,
|
||||
duplicateUnitQuery,
|
||||
enableCourseHighlightsEmailsQuery,
|
||||
fetchCourseBestPracticesQuery,
|
||||
fetchCourseLaunchQuery,
|
||||
fetchCourseOutlineIndexQuery,
|
||||
fetchCourseReindexQuery,
|
||||
updateCourseSectionHighlightsQuery,
|
||||
configureCourseSectionQuery,
|
||||
configureCourseSubsectionQuery,
|
||||
configureCourseUnitQuery,
|
||||
setSectionOrderListQuery,
|
||||
setVideoSharingOptionQuery,
|
||||
setSubsectionOrderListQuery,
|
||||
setUnitOrderListQuery,
|
||||
pasteClipboardContent,
|
||||
dismissNotificationQuery,
|
||||
syncDiscussionsTopics,
|
||||
} from './data/thunk';
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
const useCourseOutline = ({ courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
handleAddSection,
|
||||
handleAddBlock,
|
||||
setCurrentSelection,
|
||||
currentSelection,
|
||||
currentUnlinkModalData,
|
||||
@@ -99,18 +99,19 @@ const useCourseOutline = ({ courseId }) => {
|
||||
|
||||
const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED;
|
||||
|
||||
const handlePasteClipboardClick = (parentLocator, sectionId) => {
|
||||
dispatch(pasteClipboardContent(parentLocator, sectionId));
|
||||
};
|
||||
|
||||
const resetScrollState = () => {
|
||||
dispatch(resetScrollField());
|
||||
const { mutate: pasteClipboardContent, isPending: isPasting } = usePasteItem(courseId);
|
||||
const handlePasteClipboardClick = (parentLocator, subsectionId, sectionId) => {
|
||||
pasteClipboardContent({
|
||||
parentLocator,
|
||||
subsectionId,
|
||||
sectionId,
|
||||
});
|
||||
};
|
||||
|
||||
const headerNavigationsActions = {
|
||||
handleNewSection: () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleAddSection.mutateAsync({
|
||||
handleAddBlock.mutateAsync({
|
||||
type: ContainerType.Chapter,
|
||||
parentLocator: courseStructure?.id,
|
||||
displayName: COURSE_BLOCK_NAMES.chapter.name,
|
||||
@@ -147,9 +148,16 @@ const useCourseOutline = ({ courseId }) => {
|
||||
openHighlightsModal();
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: updateCourseSectionHighlights,
|
||||
isPending: isSectionHighlightsUpdatePending,
|
||||
} = useUpdateCourseSectionHighlights();
|
||||
const handleHighlightsFormSubmit = (highlights) => {
|
||||
const dataToSend = Object.values(highlights).filter(Boolean);
|
||||
dispatch(updateCourseSectionHighlightsQuery(currentSelection?.currentId, dataToSend));
|
||||
updateCourseSectionHighlights({
|
||||
sectionId: currentSelection?.currentId,
|
||||
highlights: dataToSend,
|
||||
});
|
||||
|
||||
closeHighlightsModal();
|
||||
};
|
||||
@@ -169,39 +177,52 @@ const useCourseOutline = ({ courseId }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
await unlinkDownstream(currentUnlinkModalData.value.id, {
|
||||
await unlinkDownstream({
|
||||
downstreamBlockId: currentUnlinkModalData.value.id,
|
||||
sectionId: currentUnlinkModalData.sectionId,
|
||||
subsectionId: currentUnlinkModalData.subsectionId,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
closeUnlinkModal();
|
||||
// istanbul ignore next
|
||||
// refresh child block data
|
||||
currentUnlinkModalData.value.childInfo?.children.forEach((block) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(block.id) });
|
||||
block.childInfo?.children.forEach(({ id: blockId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(blockId) });
|
||||
});
|
||||
});
|
||||
// refresh parent blocks data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(currentUnlinkModalData?.sectionId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(currentUnlinkModalData?.subsectionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [currentUnlinkModalData, unlinkDownstream, closeUnlinkModal]);
|
||||
|
||||
const handleConfigureItemSubmit = (...arg) => {
|
||||
const {
|
||||
mutate: configureCourseSection,
|
||||
isPending: isSectionConfigurePending,
|
||||
} = useConfigureSection();
|
||||
const {
|
||||
mutate: configureCourseSubsection,
|
||||
isPending: isSubsectionConfigurePending,
|
||||
} = useConfigureSubsection();
|
||||
const {
|
||||
mutate: configureCourseUnit,
|
||||
isPending: isUnitConfigurePending,
|
||||
} = useConfigureUnit();
|
||||
const isConfigureOpPending = isSectionConfigurePending || isSubsectionConfigurePending || isUnitConfigurePending;
|
||||
const handleConfigureItemSubmit = (variables) => {
|
||||
const category = getBlockType(currentSelection.currentId);
|
||||
switch (category) {
|
||||
case COURSE_BLOCK_NAMES.chapter.id:
|
||||
dispatch(configureCourseSectionQuery(currentSelection?.sectionId, ...arg));
|
||||
configureCourseSection({
|
||||
sectionId: currentSelection?.sectionId,
|
||||
...variables,
|
||||
});
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
dispatch(configureCourseSubsectionQuery(currentSelection?.currentId, currentSelection?.sectionId, ...arg));
|
||||
configureCourseSubsection({
|
||||
itemId: currentSelection?.currentId,
|
||||
sectionId: currentSelection?.sectionId,
|
||||
...variables,
|
||||
});
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
dispatch(configureCourseUnitQuery(currentSelection?.currentId, currentSelection?.sectionId, ...arg));
|
||||
configureCourseUnit({
|
||||
unitId: currentSelection?.currentId,
|
||||
sectionId: currentSelection?.sectionId,
|
||||
...variables,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// istanbul ignore next
|
||||
@@ -275,20 +296,35 @@ const useCourseOutline = ({ courseId }) => {
|
||||
deleteSubsection,
|
||||
]);
|
||||
|
||||
const {
|
||||
mutate: duplicateItem,
|
||||
isPending: isDuplicatingItem,
|
||||
} = useDuplicateItem(courseId);
|
||||
const handleDuplicateSectionSubmit = () => {
|
||||
dispatch(duplicateSectionQuery(currentSelection?.sectionId, courseStructure.id));
|
||||
duplicateItem({
|
||||
itemId: currentSelection?.currentId,
|
||||
parentId: courseStructure.id,
|
||||
sectionId: currentSelection?.sectionId,
|
||||
subsectionId: currentSelection?.subsectionId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicateSubsectionSubmit = () => {
|
||||
dispatch(duplicateSubsectionQuery(currentSelection?.subsectionId, currentSelection?.sectionId));
|
||||
duplicateItem({
|
||||
itemId: currentSelection?.currentId,
|
||||
parentId: currentSelection?.sectionId,
|
||||
sectionId: currentSelection?.sectionId,
|
||||
subsectionId: currentSelection?.subsectionId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicateUnitSubmit = () => {
|
||||
dispatch(duplicateUnitQuery(
|
||||
currentSelection?.currentId,
|
||||
currentSelection?.subsectionId,
|
||||
currentSelection?.sectionId,
|
||||
));
|
||||
duplicateItem({
|
||||
itemId: currentSelection?.currentId,
|
||||
parentId: currentSelection?.subsectionId,
|
||||
sectionId: currentSelection?.sectionId,
|
||||
subsectionId: currentSelection?.subsectionId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleVideoSharingOptionChange = (value) => {
|
||||
@@ -371,12 +407,14 @@ const useCourseOutline = ({ courseId }) => {
|
||||
isConfigureModalOpen,
|
||||
openConfigureModal,
|
||||
handleConfigureModalClose,
|
||||
isConfigureOpPending,
|
||||
headerNavigationsActions,
|
||||
handleEnableHighlightsSubmit,
|
||||
handleHighlightsFormSubmit,
|
||||
handleConfigureItemSubmit,
|
||||
statusBarData,
|
||||
isEnableHighlightsModalOpen,
|
||||
isSectionHighlightsUpdatePending,
|
||||
openEnableHighlightsModal,
|
||||
closeEnableHighlightsModal,
|
||||
isInternetConnectionAlertFailed: isSavingStatusFailed,
|
||||
@@ -391,9 +429,11 @@ const useCourseOutline = ({ courseId }) => {
|
||||
handleDeleteItemSubmit,
|
||||
handleDuplicateSectionSubmit,
|
||||
handleDuplicateSubsectionSubmit,
|
||||
isDuplicatingItem,
|
||||
handleDuplicateUnitSubmit,
|
||||
handleVideoSharingOptionChange,
|
||||
handlePasteClipboardClick,
|
||||
isPasting,
|
||||
notificationDismissUrl,
|
||||
discussionsSettings,
|
||||
discussionsIncontextLearnmoreUrl,
|
||||
@@ -407,7 +447,6 @@ const useCourseOutline = ({ courseId }) => {
|
||||
handleSubsectionDragAndDrop,
|
||||
handleUnitDragAndDrop,
|
||||
errors,
|
||||
resetScrollState,
|
||||
handleUnlinkItemSubmit,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { ContainerType } from '@src/generic/key-utils';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
|
||||
import { snakeCaseKeys } from '@src/editors/utils';
|
||||
import { getXBlockBaseApiUrl } from '@src/course-outline/data/api';
|
||||
import { getXBlockApiUrl, getXBlockBaseApiUrl } from '@src/course-outline/data/api';
|
||||
import MockAdapter from 'axios-mock-adapter/types';
|
||||
import { AddSidebar } from './AddSidebar';
|
||||
|
||||
@@ -199,10 +199,13 @@ describe('AddSidebar', () => {
|
||||
const sectionId = 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@chapter123';
|
||||
axiosMock.onPost(getXBlockBaseApiUrl())
|
||||
.reply(200, { locator: sectionId });
|
||||
axiosMock.onGet(getXBlockApiUrl(sectionId))
|
||||
.reply(200, {});
|
||||
renderComponent();
|
||||
|
||||
const subsection = await screen.findByRole('button', { name: 'Subsection' });
|
||||
await user.click(subsection);
|
||||
await waitFor(() => expect(axiosMock.history.post.length).toBeGreaterThan(1));
|
||||
// should add a section first
|
||||
expect(axiosMock.history.post[0].data).toEqual(JSON.stringify(snakeCaseKeys({
|
||||
type: 'chapter',
|
||||
@@ -250,6 +253,8 @@ describe('AddSidebar', () => {
|
||||
.reply(200, { locator: subsectionId });
|
||||
axiosMock.onPost(getXBlockBaseApiUrl(), unitBody)
|
||||
.reply(200, { locator: unitId });
|
||||
axiosMock.onGet(getXBlockApiUrl(sectionId))
|
||||
.reply(200, {});
|
||||
renderComponent();
|
||||
|
||||
const unit = await screen.findByRole('button', { name: 'Unit' });
|
||||
|
||||
@@ -50,8 +50,7 @@ type AddContentButtonProps = {
|
||||
const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
|
||||
const {
|
||||
courseUsageKey,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddBlock,
|
||||
handleAddAndOpenUnit,
|
||||
} = useCourseAuthoringContext();
|
||||
const {
|
||||
@@ -65,7 +64,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
|
||||
let subsectionParentId = lastEditableSubsection?.data?.id;
|
||||
|
||||
const addSection = (onSuccess?: (data: { locator: string; }) => void) => {
|
||||
handleAddSection.mutate({
|
||||
handleAddBlock.mutate({
|
||||
type: ContainerType.Chapter,
|
||||
parentLocator: courseUsageKey,
|
||||
displayName: COURSE_BLOCK_NAMES.chapter.name,
|
||||
@@ -82,10 +81,11 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
|
||||
};
|
||||
|
||||
const addSubsection = (sectionId: string, onSuccess?: (data: { locator: string; }) => void) => {
|
||||
handleAddSubsection.mutate({
|
||||
handleAddBlock.mutate({
|
||||
type: ContainerType.Sequential,
|
||||
parentLocator: sectionId,
|
||||
displayName: COURSE_BLOCK_NAMES.sequential.name,
|
||||
sectionId,
|
||||
}, {
|
||||
onSuccess: (data: { locator: string; }) => {
|
||||
// istanbul ignore next
|
||||
@@ -146,8 +146,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
|
||||
}, [
|
||||
blockType,
|
||||
courseUsageKey,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddBlock,
|
||||
handleAddAndOpenUnit,
|
||||
currentFlow,
|
||||
sectionParentId,
|
||||
@@ -155,7 +154,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
|
||||
lastEditableSubsection,
|
||||
]);
|
||||
|
||||
const disabled = handleAddSection.isPending || handleAddSubsection.isPending || handleAddAndOpenUnit.isPending;
|
||||
const disabled = handleAddBlock.isPending || handleAddAndOpenUnit.isPending;
|
||||
|
||||
return (
|
||||
<BlockCardButton
|
||||
@@ -213,9 +212,7 @@ const AddNewContent = () => {
|
||||
const ShowLibraryContent = () => {
|
||||
const {
|
||||
courseUsageKey,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddBlock,
|
||||
} = useCourseAuthoringContext();
|
||||
const {
|
||||
isCurrentFlowOn,
|
||||
@@ -233,7 +230,7 @@ const ShowLibraryContent = () => {
|
||||
const onComponentSelected: ComponentSelectedEvent = useCallback(async ({ usageKey, blockType }) => {
|
||||
switch (blockType) {
|
||||
case 'section':
|
||||
await handleAddSection.mutateAsync({
|
||||
await handleAddBlock.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Chapter,
|
||||
parentLocator: courseUsageKey,
|
||||
@@ -243,11 +240,12 @@ const ShowLibraryContent = () => {
|
||||
case 'subsection':
|
||||
sectionParentId = currentFlow?.parentLocator || sectionParentId;
|
||||
if (sectionParentId) {
|
||||
await handleAddSubsection.mutateAsync({
|
||||
await handleAddBlock.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Sequential,
|
||||
parentLocator: sectionParentId,
|
||||
libraryContentKey: usageKey,
|
||||
sectionId: sectionParentId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -257,7 +255,7 @@ const ShowLibraryContent = () => {
|
||||
);
|
||||
subsectionParentId = currentFlow?.parentLocator || subsectionParentId;
|
||||
if (subsectionParentId) {
|
||||
await handleAddUnit.mutateAsync({
|
||||
await handleAddBlock.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Vertical,
|
||||
parentLocator: subsectionParentId,
|
||||
@@ -273,9 +271,7 @@ const ShowLibraryContent = () => {
|
||||
stopCurrentFlow();
|
||||
}, [
|
||||
courseUsageKey,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddBlock,
|
||||
lastEditableSection,
|
||||
lastEditableSubsection,
|
||||
currentFlow,
|
||||
|
||||
@@ -9,12 +9,11 @@ import { useOutlineSidebarContext } from './OutlineSidebarContext';
|
||||
export const OutlineAlignSidebar = () => {
|
||||
const {
|
||||
courseId,
|
||||
currentSelection,
|
||||
setCurrentSelection,
|
||||
} = useCourseAuthoringContext();
|
||||
const { selectedContainerState, clearSelection } = useOutlineSidebarContext();
|
||||
|
||||
const sidebarContentId = currentSelection?.currentId || selectedContainerState?.currentId || courseId;
|
||||
const sidebarContentId = selectedContainerState?.currentId || courseId;
|
||||
|
||||
const { data: contentData } = useContentData(sidebarContentId);
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ interface OutlineSidebarContextData {
|
||||
open: () => void;
|
||||
toggle: () => void;
|
||||
selectedContainerState?: SelectionState;
|
||||
setSelectedContainerState: (selectedContainerState?: SelectionState) => void;
|
||||
openContainerInfoSidebar: (containerId: string, subsectionId?: string, sectionId?: string) => void;
|
||||
clearSelection: () => void;
|
||||
/** Stores last section that allows adding subsections inside it. */
|
||||
@@ -143,7 +144,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod
|
||||
setCurrentFlow(flow);
|
||||
}, [setCurrentFlow, setCurrentPageKey]);
|
||||
|
||||
const { data: currentItemData } = useCourseItemData(selectedContainerState?.currentId);
|
||||
const { data: currentItemData } = useCourseItemData<XBlock>(selectedContainerState?.currentId);
|
||||
const sectionsList = useSelector(getSectionsList);
|
||||
|
||||
/** Stores last section that allows adding subsections inside it. */
|
||||
@@ -188,6 +189,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod
|
||||
open,
|
||||
toggle,
|
||||
selectedContainerState,
|
||||
setSelectedContainerState,
|
||||
openContainerInfoSidebar,
|
||||
clearSelection,
|
||||
lastEditableSection,
|
||||
@@ -205,6 +207,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod
|
||||
open,
|
||||
toggle,
|
||||
selectedContainerState,
|
||||
setSelectedContainerState,
|
||||
openContainerInfoSidebar,
|
||||
clearSelection,
|
||||
lastEditableSection,
|
||||
|
||||
@@ -19,7 +19,7 @@ export type OutlineSidebarPages = {
|
||||
align?: SidebarPage;
|
||||
};
|
||||
|
||||
export const getOutlineSidebarPages = () => ({
|
||||
const getOutlineSidebarPages = () => ({
|
||||
info: {
|
||||
component: InfoSidebar,
|
||||
icon: Info,
|
||||
@@ -55,24 +55,24 @@ export const getOutlineSidebarPages = () => ({
|
||||
* export function CourseOutlineSidebarWrapper(
|
||||
* { component, pluginProps }: { component: React.ReactNode, pluginProps: CourseOutlineAspectsPageProps },
|
||||
* ) {
|
||||
* const AnalyticsPage = React.useCallback(() => <CourseOutlineAspectsPage {...pluginProps} />, [pluginProps]);
|
||||
* const sidebarPages = useOutlineSidebarPagesContext();
|
||||
*
|
||||
* const AnalyticsPage = React.useCallback(() => <CourseOutlineAspectsPage {...pluginProps} />, [pluginProps]);
|
||||
* const sidebarPages = useOutlineSidebarPagesContext();
|
||||
* const overridedPages = useMemo(() => ({
|
||||
* ...sidebarPages,
|
||||
* analytics: {
|
||||
* component: AnalyticsPage,
|
||||
* icon: AutoGraph,
|
||||
* title: messages.analyticsLabel,
|
||||
* },
|
||||
* }), [sidebarPages, AnalyticsPage]);
|
||||
*
|
||||
* const overridedPages = useMemo(() => ({
|
||||
* ...sidebarPages,
|
||||
* analytics: {
|
||||
* component: AnalyticsPage,
|
||||
* icon: AutoGraph,
|
||||
* title: messages.analyticsLabel,
|
||||
* },
|
||||
* }), [sidebarPages, AnalyticsPage]);
|
||||
*
|
||||
* return (
|
||||
* <OutlineSidebarPagesContext.Provider value={overridedPages}>
|
||||
* {component}
|
||||
* </OutlineSidebarPagesContext.Provider>
|
||||
*}
|
||||
* return (
|
||||
* <OutlineSidebarPagesContext.Provider value={overridedPages}>
|
||||
* {component}
|
||||
* </OutlineSidebarPagesContext.Provider>
|
||||
* );
|
||||
* }
|
||||
*/
|
||||
export const OutlineSidebarPagesContext = createContext<OutlineSidebarPages | undefined>(undefined);
|
||||
|
||||
@@ -94,6 +94,7 @@ export const OutlineSidebarPagesProvider = ({ children }: OutlineSidebarPagesPro
|
||||
|
||||
export const useOutlineSidebarPagesContext = (): OutlineSidebarPages => {
|
||||
const ctx = useContext(OutlineSidebarPagesContext);
|
||||
// istanbul ignore if: this should never happen
|
||||
if (ctx === undefined) { throw new Error('useOutlineSidebarPages must be used within an OutlineSidebarPagesProvider'); }
|
||||
return ctx;
|
||||
};
|
||||
|
||||
@@ -2,12 +2,18 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { SchoolOutline, Tag } from '@openedx/paragon/icons';
|
||||
import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer';
|
||||
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { LibraryReferenceCard } from '@src/course-outline/outline-sidebar/LibraryReferenceCard';
|
||||
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
|
||||
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { ComponentCountSnippet, getItemIcon } from '@src/generic/block-type-utils';
|
||||
import { normalizeContainerType } from '@src/generic/key-utils';
|
||||
import { SidebarContent, SidebarSection } from '@src/generic/sidebar';
|
||||
import { useGetBlockTypes } from '@src/search-manager';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { LibraryReferenceCard } from '@src/generic/library-reference-card/LibraryReferenceCard';
|
||||
import messages from '../messages';
|
||||
|
||||
interface Props {
|
||||
@@ -16,17 +22,46 @@ interface Props {
|
||||
|
||||
export const InfoSection = ({ itemId }: Props) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: itemData } = useCourseItemData(itemId);
|
||||
const { data: componentData } = useGetBlockTypes(
|
||||
[`breadcrumbs.usage_key = "${itemId}"`],
|
||||
);
|
||||
const category = normalizeContainerType(itemData?.category || '');
|
||||
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
|
||||
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
|
||||
|
||||
/**
|
||||
* Called after a library component sync operation completes (e.g. accepting or ignoring
|
||||
* an upstream update). Refreshes all stale data that may have been affected:
|
||||
* - Re-fetches the parent section's outline data so counts/status stay current.
|
||||
* - Invalidates the library links query so the sync-status badges update.
|
||||
* - Invalidates the full course outline query so the top-level view reflects the change.
|
||||
*/
|
||||
// istanbul ignore next
|
||||
const handleOnPostChangeSync = useCallback(() => {
|
||||
// invalidating section data will update all children blocks as well.
|
||||
if (selectedContainerState?.sectionId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(selectedContainerState?.sectionId),
|
||||
});
|
||||
}
|
||||
if (courseId) {
|
||||
invalidateLinksQuery(queryClient, courseId);
|
||||
}
|
||||
}, [dispatch, selectedContainerState, queryClient, courseId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LibraryReferenceCard itemId={itemId} />
|
||||
<LibraryReferenceCard
|
||||
itemId={itemId}
|
||||
sectionId={selectedContainerState?.sectionId}
|
||||
postChange={handleOnPostChangeSync}
|
||||
goToParent={openContainerInfoSidebar}
|
||||
/>
|
||||
<SidebarContent>
|
||||
<SidebarSection
|
||||
title={intl.formatMessage(messages[`${category}ContentSummaryText`])}
|
||||
|
||||
@@ -170,66 +170,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Settings',
|
||||
description: 'Settings tab title in container sidebar',
|
||||
},
|
||||
libraryReferenceCardText: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.text',
|
||||
defaultMessage: 'Library Reference',
|
||||
description: 'Library reference card text in sidebar',
|
||||
},
|
||||
hasTopParentText: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-text',
|
||||
defaultMessage: '{name} was reused as part of a {parentType}.',
|
||||
description: 'Text displayed in sidebar library reference card when a block was reused as part of a parent block',
|
||||
},
|
||||
hasTopParentBtn: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-btn',
|
||||
defaultMessage: 'View {parentType}',
|
||||
description: 'Text displayed in sidebar library reference card button when a block was reused as part of a parent block',
|
||||
},
|
||||
hasTopParentReadyToSyncText: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-sync-text',
|
||||
defaultMessage: '{name} was reused as part of a {parentType} which has updates available.',
|
||||
description: 'Text displayed in sidebar library reference card when a block has updates available as it was reused as part of a parent block',
|
||||
},
|
||||
hasTopParentReadyToSyncBtn: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-sync-btn',
|
||||
defaultMessage: 'Review Updates',
|
||||
description: 'Text displayed in sidebar library reference card button when a block has updates available as it was reused as part of a parent block',
|
||||
},
|
||||
hasTopParentBrokenLinkText: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-broken-link-text',
|
||||
defaultMessage: '{name} was reused as part of a {parentType} which has a broken link. To recieve library updates to this component, unlink the broken link.',
|
||||
description: 'Text displayed in sidebar library reference card when a block was reused as part of a parent block which has a broken link.',
|
||||
},
|
||||
hasTopParentBrokenLinkBtn: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-broken-link-btn',
|
||||
defaultMessage: 'Unlink {parentType}',
|
||||
description: 'Text displayed in sidebar library reference card button when a block was reused as part of a parent block which has a broken link.',
|
||||
},
|
||||
topParentBrokenLinkText: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-broken-link-text',
|
||||
defaultMessage: 'The link between {name} and the library version has been broken. To edit or make changes, unlink component.',
|
||||
description: 'Text displayed in sidebar library reference card when a block has a broken link.',
|
||||
},
|
||||
topParentBrokenLinkBtn: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-broken-link-btn',
|
||||
defaultMessage: 'Unlink from library',
|
||||
description: 'Text displayed in sidebar library reference card button when a block has a broken link.',
|
||||
},
|
||||
topParentModifiedText: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-modified-text',
|
||||
defaultMessage: '{name} has been modified in this course.',
|
||||
description: 'Text displayed in sidebar library reference card when it is modified in course.',
|
||||
},
|
||||
topParentReaadyToSyncText: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-ready-to-sync-text',
|
||||
defaultMessage: '{name} has available updates',
|
||||
description: 'Text displayed in sidebar library reference card when it is has updates available.',
|
||||
},
|
||||
topParentReaadyToSyncBtn: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-ready-to-sync-btn',
|
||||
defaultMessage: 'Review Updates',
|
||||
description: 'Text displayed in sidebar library reference card button when it is has updates available.',
|
||||
},
|
||||
cannotAddAlertMsg: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.add-sidebar.alert.text',
|
||||
defaultMessage: '{name} is a library {category}. Content cannot be added to Library referenced {category}s.',
|
||||
|
||||
@@ -11,9 +11,10 @@ import {
|
||||
} from '@openedx/paragon/icons';
|
||||
import { uniqBy } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { usePasteFileNotices } from '@src/course-outline/data/apiHooks';
|
||||
import CourseOutlinePageAlertsSlot from '../../plugin-slots/CourseOutlinePageAlertsSlot';
|
||||
import advancedSettingsMessages from '../../advanced-settings/messages';
|
||||
import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert';
|
||||
@@ -23,8 +24,7 @@ import ErrorAlert from '../../editors/sharedComponents/ErrorAlerts/ErrorAlert';
|
||||
import AlertMessage from '../../generic/alert-message';
|
||||
import AlertProctoringError from '../../generic/AlertProctoringError';
|
||||
import { API_ERROR_TYPES } from '../constants';
|
||||
import { getPasteFileNotices } from '../data/selectors';
|
||||
import { dismissError, removePasteFileNotices } from '../data/slice';
|
||||
import { dismissError } from '../data/slice';
|
||||
import messages from './messages';
|
||||
|
||||
const PageAlerts = ({
|
||||
@@ -48,7 +48,7 @@ const PageAlerts = ({
|
||||
const [showDiscussionAlert, setShowDiscussionAlert] = useState(
|
||||
localStorage.getItem(discussionAlertDismissKey) === null,
|
||||
);
|
||||
const { newFiles, conflictingFiles, errorFiles } = useSelector(getPasteFileNotices);
|
||||
const { data: pasteFileNotices, setData: setPasteFileNotices } = usePasteFileNotices(courseId);
|
||||
const [showOutOfSyncAlert, setShowOutOfSyncAlert] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -247,16 +247,16 @@ const PageAlerts = ({
|
||||
|
||||
const newFilesPasteAlert = () => {
|
||||
const onDismiss = () => {
|
||||
dispatch(removePasteFileNotices(['newFiles']));
|
||||
setPasteFileNotices({ ...pasteFileNotices, newFiles: [] });
|
||||
};
|
||||
|
||||
if (newFiles?.length) {
|
||||
if (pasteFileNotices?.newFiles?.length) {
|
||||
return (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(messages.newFileAlertTitle, { newFilesLen: newFiles.length })}
|
||||
title={intl.formatMessage(messages.newFileAlertTitle, { newFilesLen: pasteFileNotices.newFiles.length })}
|
||||
description={intl.formatMessage(
|
||||
messages.newFileAlertDesc,
|
||||
{ newFilesLen: newFiles.length, newFilesStr: newFiles.join(', ') },
|
||||
{ newFilesLen: pasteFileNotices.newFiles.length, newFilesStr: pasteFileNotices.newFiles.join(', ') },
|
||||
)}
|
||||
dismissible
|
||||
show
|
||||
@@ -279,16 +279,16 @@ const PageAlerts = ({
|
||||
|
||||
const errorFilesPasteAlert = () => {
|
||||
const onDismiss = () => {
|
||||
dispatch(removePasteFileNotices(['errorFiles']));
|
||||
setPasteFileNotices({ ...pasteFileNotices, errorFiles: [] });
|
||||
};
|
||||
|
||||
if (errorFiles?.length) {
|
||||
if (pasteFileNotices?.errorFiles?.length) {
|
||||
return (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(messages.errorFileAlertTitle)}
|
||||
description={intl.formatMessage(
|
||||
messages.errorFileAlertDesc,
|
||||
{ errorFilesLen: errorFiles.length, errorFilesStr: errorFiles.join(', ') },
|
||||
{ errorFilesLen: pasteFileNotices.errorFiles.length, errorFilesStr: pasteFileNotices.errorFiles.join(', ') },
|
||||
)}
|
||||
dismissible
|
||||
show
|
||||
@@ -303,19 +303,22 @@ const PageAlerts = ({
|
||||
|
||||
const conflictingFilesPasteAlert = () => {
|
||||
const onDismiss = () => {
|
||||
dispatch(removePasteFileNotices(['conflictingFiles']));
|
||||
setPasteFileNotices({ ...pasteFileNotices, conflictingFiles: [] });
|
||||
};
|
||||
|
||||
if (conflictingFiles?.length) {
|
||||
if (pasteFileNotices?.conflictingFiles?.length) {
|
||||
return (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(
|
||||
messages.conflictingFileAlertTitle,
|
||||
{ conflictingFilesLen: conflictingFiles.length },
|
||||
{ conflictingFilesLen: pasteFileNotices.conflictingFiles.length },
|
||||
)}
|
||||
description={intl.formatMessage(
|
||||
messages.conflictingFileAlertDesc,
|
||||
{ conflictingFilesLen: conflictingFiles.length, conflictingFilesStr: conflictingFiles.join(', ') },
|
||||
{
|
||||
conflictingFilesLen: pasteFileNotices.conflictingFiles.length,
|
||||
conflictingFilesStr: pasteFileNotices.conflictingFiles.join(', '),
|
||||
},
|
||||
)}
|
||||
dismissible
|
||||
show
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
@@ -22,9 +21,10 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn(),
|
||||
let mockNotices = {};
|
||||
jest.mock('@src/course-outline/data/apiHooks', () => ({
|
||||
...jest.requireActual('@src/course-outline/data/apiHooks'),
|
||||
usePasteFileNotices: () => ({ data: mockNotices }),
|
||||
}));
|
||||
|
||||
jest.mock('../../course-libraries/data/apiHooks', () => ({
|
||||
@@ -72,7 +72,7 @@ describe('<PageAlerts />', () => {
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
useSelector.mockReturnValue({});
|
||||
mockNotices = {};
|
||||
});
|
||||
|
||||
it('renders null when no alerts are present', async () => {
|
||||
@@ -174,11 +174,11 @@ describe('<PageAlerts />', () => {
|
||||
});
|
||||
|
||||
it('renders new & error files alert', async () => {
|
||||
useSelector.mockReturnValue({
|
||||
mockNotices = {
|
||||
newFiles: ['periodic-table.css'],
|
||||
conflictingFiles: [],
|
||||
errorFiles: ['error.css'],
|
||||
});
|
||||
};
|
||||
renderComponent();
|
||||
expect(screen.queryByText(messages.newFileAlertTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.errorFileAlertTitle.defaultMessage)).toBeInTheDocument();
|
||||
@@ -189,11 +189,11 @@ describe('<PageAlerts />', () => {
|
||||
});
|
||||
|
||||
it('renders conflicting files alert', async () => {
|
||||
useSelector.mockReturnValue({
|
||||
mockNotices = {
|
||||
newFiles: [],
|
||||
conflictingFiles: ['some.css', 'some.js'],
|
||||
errorFiles: [],
|
||||
});
|
||||
};
|
||||
renderComponent();
|
||||
expect(screen.queryByText(messages.conflictingFileAlertTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.newFileAlertAction.defaultMessage)).toHaveAttribute(
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
/* eslint-disable import/named */
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ModalDialog,
|
||||
ActionRow,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import { courseOutlineQueryKeys, usePublishCourseItem } from '@src/course-outline/data/apiHooks';
|
||||
import { usePublishCourseItem } from '@src/course-outline/data/apiHooks';
|
||||
import type { UnitXBlock, XBlock } from '@src/data/types';
|
||||
import LoadingButton from '@src/generic/loading-button';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import messages from './messages';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
|
||||
@@ -26,22 +25,6 @@ const PublishModal = () => {
|
||||
: undefined;
|
||||
const children: Array<XBlock | UnitXBlock> | undefined = childInfo?.children;
|
||||
const publishMutation = usePublishCourseItem();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const childrenIds = useMemo(() => children?.reduce((
|
||||
result: string[],
|
||||
current: XBlock | UnitXBlock,
|
||||
): string[] => {
|
||||
let temp = [...result];
|
||||
if ('childInfo' in current) {
|
||||
const grandChildren = current.childInfo.children.filter((child) => child.hasChanges);
|
||||
temp = [...temp, ...grandChildren.map((child) => child.id)];
|
||||
}
|
||||
if (current.hasChanges) {
|
||||
temp.push(current.id);
|
||||
}
|
||||
return temp;
|
||||
}, []), [children]);
|
||||
|
||||
const onPublishSubmit = async () => {
|
||||
if (id) {
|
||||
@@ -52,10 +35,6 @@ const PublishModal = () => {
|
||||
}, {
|
||||
onSettled: () => {
|
||||
closePublishModal();
|
||||
// Update query client to refresh the data of all children blocks
|
||||
childrenIds?.forEach((blockId) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(blockId) });
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
|
||||
jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
useCourseAuthoringContext: () => ({
|
||||
courseId: 5,
|
||||
handleAddSubsectionFromLibrary: jest.fn(),
|
||||
handleNewSubsectionSubmit: jest.fn(),
|
||||
setCurrentSelection,
|
||||
}),
|
||||
}));
|
||||
@@ -99,7 +97,6 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
|
||||
isSectionsExpanded
|
||||
isSelfPaced={false}
|
||||
isCustomRelativeDatesActive={false}
|
||||
resetScrollState={jest.fn()}
|
||||
{...props}
|
||||
>
|
||||
<span>children</span>
|
||||
@@ -321,6 +318,7 @@ describe('<SectionCard />', () => {
|
||||
it('should open align sidebar', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockSetCurrentPageKey = jest.fn();
|
||||
const mockSetSelectedContainerState = jest.fn();
|
||||
|
||||
const testSidebarPage = {
|
||||
component: CourseInfoSidebar,
|
||||
@@ -346,6 +344,7 @@ describe('<SectionCard />', () => {
|
||||
stopCurrentFlow: jest.fn(),
|
||||
openContainerInfoSidebar: jest.fn(),
|
||||
clearSelection: jest.fn(),
|
||||
setSelectedContainerState: mockSetSelectedContainerState,
|
||||
}));
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
@@ -369,5 +368,9 @@ describe('<SectionCard />', () => {
|
||||
currentId: section.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
expect(mockSetSelectedContainerState).toHaveBeenCalledWith({
|
||||
currentId: section.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
useContext, useEffect, useState, useRef, useCallback, ReactNode, useMemo,
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import {
|
||||
Bubble, Button, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
@@ -14,7 +13,6 @@ import SortableItem from '@src/course-outline/drag-helper/SortableItem';
|
||||
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
|
||||
import TitleButton from '@src/course-outline/card-header/TitleButton';
|
||||
import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus';
|
||||
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils';
|
||||
import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
@@ -24,8 +22,9 @@ import type { XBlock } from '@src/data/types';
|
||||
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { courseOutlineQueryKeys, useCourseItemData, useScrollState } from '@src/course-outline/data/apiHooks';
|
||||
import moment from 'moment';
|
||||
import { handleResponseErrors } from '@src/generic/saving-error-alert';
|
||||
import messages from './messages';
|
||||
|
||||
interface SectionCardProps {
|
||||
@@ -41,7 +40,6 @@ interface SectionCardProps {
|
||||
index: number,
|
||||
canMoveItem: (oldIndex: number, newIndex: number) => boolean,
|
||||
onOrderChange: (oldIndex: number, newIndex: number) => void,
|
||||
resetScrollState: () => void,
|
||||
}
|
||||
|
||||
const SectionCard = ({
|
||||
@@ -57,12 +55,10 @@ const SectionCard = ({
|
||||
onDuplicateSubmit,
|
||||
isSectionsExpanded,
|
||||
onOrderChange,
|
||||
resetScrollState,
|
||||
}: SectionCardProps) => {
|
||||
const currentRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
const { activeId, overId } = useContext(DragContext);
|
||||
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
|
||||
const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext();
|
||||
const [searchParams] = useSearchParams();
|
||||
const locatorId = searchParams.get('show');
|
||||
const {
|
||||
@@ -71,6 +67,7 @@ const SectionCard = ({
|
||||
const queryClient = useQueryClient();
|
||||
// Set initialData state from course outline and subsequently depend on its own state
|
||||
const { data: section = initialData } = useCourseItemData(initialData.id, initialData);
|
||||
const { data: scrollState, resetData: resetScrollState } = useScrollState(courseId);
|
||||
const isScrolledToElement = locatorId === section?.id;
|
||||
|
||||
// Expand the section if a search result should be shown/scrolled to
|
||||
@@ -111,6 +108,10 @@ const SectionCard = ({
|
||||
useEffect(() => {
|
||||
// istanbul ignore if
|
||||
if (moment(initialData.editedOnRaw).isAfter(moment(section.editedOnRaw))) {
|
||||
queryClient.cancelQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(initialData.id),
|
||||
// eslint-disable-next-line no-console
|
||||
}).catch((error) => console.error('Error cancelling query:', error));
|
||||
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData);
|
||||
}
|
||||
}, [initialData, section]);
|
||||
@@ -152,13 +153,13 @@ const SectionCard = ({
|
||||
}, [activeId, overId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRef.current && (section.shouldScroll || isScrolledToElement)) {
|
||||
if (currentRef.current && (scrollState?.id === section.id || isScrolledToElement)) {
|
||||
// Align element closer to the top of the screen if scrolling for search result
|
||||
const alignWithTop = !!isScrolledToElement;
|
||||
scrollToElement(currentRef.current, alignWithTop, true);
|
||||
resetScrollState();
|
||||
resetScrollState().catch((error) => handleResponseErrors(error));
|
||||
}
|
||||
}, [isScrolledToElement]);
|
||||
}, [isScrolledToElement, scrollState, resetScrollState]);
|
||||
|
||||
useEffect(() => {
|
||||
// If the locatorId is set/changed, we need to make sure that the section is expanded
|
||||
@@ -167,11 +168,13 @@ const SectionCard = ({
|
||||
}, [locatorId, setIsExpanded]);
|
||||
|
||||
const handleOnPostChangeSync = useCallback(() => {
|
||||
dispatch(fetchCourseSectionQuery([section.id]));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(section.id),
|
||||
});
|
||||
if (courseId) {
|
||||
invalidateLinksQuery(queryClient, courseId);
|
||||
}
|
||||
}, [dispatch, section, courseId, queryClient]);
|
||||
}, [section, courseId, queryClient]);
|
||||
|
||||
// re-create actions object for customizations
|
||||
const actions = { ...sectionActions };
|
||||
@@ -199,6 +202,13 @@ const SectionCard = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleClickManageTags = () => {
|
||||
setSelectedContainerState({
|
||||
currentId: section.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenHighlightsModal = () => {
|
||||
onOpenHighlightsModal(section);
|
||||
};
|
||||
@@ -284,6 +294,7 @@ const SectionCard = ({
|
||||
onClickSync={openSyncModal}
|
||||
onClickCard={(e) => onClickCard(e, true)}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
onClickManageTags={handleClickManageTags}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
actions={actions}
|
||||
|
||||
@@ -31,8 +31,7 @@ jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
useCourseAuthoringContext: () => ({
|
||||
courseId: 5,
|
||||
handleAddAndOpenUnit: handleOnAddUnitFromLibrary,
|
||||
handleAddSubsection: {},
|
||||
handleAddSection: {},
|
||||
handleAddBlock: {},
|
||||
setCurrentSelection,
|
||||
}),
|
||||
}));
|
||||
@@ -127,7 +126,6 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
onPasteClick={jest.fn()}
|
||||
resetScrollState={jest.fn()}
|
||||
isSectionsExpanded={false}
|
||||
{...props}
|
||||
>
|
||||
@@ -344,6 +342,7 @@ describe('<SubsectionCard />', () => {
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
|
||||
category: 'vertical',
|
||||
sectionId: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
|
||||
libraryContentKey: containerKey,
|
||||
});
|
||||
});
|
||||
@@ -416,6 +415,7 @@ describe('<SubsectionCard />', () => {
|
||||
it('should open align sidebar', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockSetCurrentPageKey = jest.fn();
|
||||
const mockSetSelectedContainerState = jest.fn();
|
||||
|
||||
const testSidebarPage = {
|
||||
component: CourseInfoSidebar,
|
||||
@@ -441,6 +441,7 @@ describe('<SubsectionCard />', () => {
|
||||
stopCurrentFlow: jest.fn(),
|
||||
openContainerInfoSidebar: jest.fn(),
|
||||
clearSelection: jest.fn(),
|
||||
setSelectedContainerState: mockSetSelectedContainerState,
|
||||
}));
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
@@ -465,5 +466,10 @@ describe('<SubsectionCard />', () => {
|
||||
subsectionId: subsection.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
expect(mockSetSelectedContainerState).toHaveBeenCalledWith({
|
||||
currentId: subsection.id,
|
||||
subsectionId: subsection.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
useContext, useEffect, useState, useRef, useCallback, ReactNode, useMemo,
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
@@ -15,7 +14,6 @@ import SortableItem from '@src/course-outline/drag-helper/SortableItem';
|
||||
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
|
||||
import { useClipboard, PasteComponent } from '@src/generic/clipboard';
|
||||
import TitleButton from '@src/course-outline/card-header/TitleButton';
|
||||
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
|
||||
import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
@@ -26,8 +24,9 @@ import type { XBlock } from '@src/data/types';
|
||||
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { courseOutlineQueryKeys, useCourseItemData, useScrollState } from '@src/course-outline/data/apiHooks';
|
||||
import moment from 'moment';
|
||||
import { handleResponseErrors } from '@src/generic/saving-error-alert';
|
||||
import messages from './messages';
|
||||
|
||||
interface SubsectionCardProps {
|
||||
@@ -43,8 +42,11 @@ interface SubsectionCardProps {
|
||||
getPossibleMoves: (index: number, step: number) => void,
|
||||
onOrderChange: (section: XBlock, moveDetails: any) => void,
|
||||
onOpenConfigureModal: () => void,
|
||||
onPasteClick: (parentLocator: string, sectionId: string) => void,
|
||||
resetScrollState: () => void,
|
||||
onPasteClick: (
|
||||
parentLocator: string,
|
||||
subsectionId: string,
|
||||
sectionId: string
|
||||
) => void,
|
||||
}
|
||||
|
||||
const SubsectionCard = ({
|
||||
@@ -61,13 +63,11 @@ const SubsectionCard = ({
|
||||
onOrderChange,
|
||||
onOpenConfigureModal,
|
||||
onPasteClick,
|
||||
resetScrollState,
|
||||
}: SubsectionCardProps) => {
|
||||
const currentRef = useRef(null);
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const { activeId, overId } = useContext(DragContext);
|
||||
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
|
||||
const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext();
|
||||
const [searchParams] = useSearchParams();
|
||||
const locatorId = searchParams.get('show');
|
||||
const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false);
|
||||
@@ -80,6 +80,7 @@ const SubsectionCard = ({
|
||||
// Set initialData state from course outline and subsequently depend on its own state
|
||||
const { data: section = initialSectionData } = useCourseItemData(initialSectionData.id, initialSectionData);
|
||||
const { data: subsection = initialData } = useCourseItemData(initialData.id, initialData);
|
||||
const { data: scrollState, resetData: resetScrollState } = useScrollState(courseId);
|
||||
const isScrolledToElement = locatorId === subsection.id;
|
||||
|
||||
const {
|
||||
@@ -146,6 +147,10 @@ const SubsectionCard = ({
|
||||
useEffect(() => {
|
||||
// istanbul ignore if
|
||||
if (moment(initialData.editedOnRaw).isAfter(moment(subsection.editedOnRaw))) {
|
||||
queryClient.cancelQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(initialData.id),
|
||||
// eslint-disable-next-line no-console
|
||||
}).catch((error) => console.error('Error cancelling query:', error));
|
||||
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData);
|
||||
}
|
||||
}, [initialData, subsection]);
|
||||
@@ -162,12 +167,22 @@ const SubsectionCard = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleClickManageTags = () => {
|
||||
setSelectedContainerState({
|
||||
currentId: subsection.id,
|
||||
subsectionId: subsection.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOnPostChangeSync = useCallback(() => {
|
||||
dispatch(fetchCourseSectionQuery([section.id]));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(section.id),
|
||||
});
|
||||
if (courseId) {
|
||||
invalidateLinksQuery(queryClient, courseId);
|
||||
}
|
||||
}, [dispatch, section, queryClient, courseId]);
|
||||
}, [section, queryClient, courseId]);
|
||||
|
||||
const handleSubsectionMoveUp = () => {
|
||||
onOrderChange(section, moveUpDetails);
|
||||
@@ -177,7 +192,7 @@ const SubsectionCard = ({
|
||||
onOrderChange(section, moveDownDetails);
|
||||
};
|
||||
|
||||
const handlePasteButtonClick = () => onPasteClick(id, section.id);
|
||||
const handlePasteButtonClick = () => onPasteClick(id, id, section.id);
|
||||
|
||||
const titleComponent = (
|
||||
<TitleButton
|
||||
@@ -212,13 +227,13 @@ const SubsectionCard = ({
|
||||
|
||||
useEffect(() => {
|
||||
// if this items has been newly added, scroll to it.
|
||||
if (currentRef.current && (subsection.shouldScroll || isScrolledToElement)) {
|
||||
if (currentRef.current && (scrollState?.id === subsection.id || isScrolledToElement)) {
|
||||
// Align element closer to the top of the screen if scrolling for search result
|
||||
const alignWithTop = !!isScrolledToElement;
|
||||
scrollToElement(currentRef.current, alignWithTop, true);
|
||||
resetScrollState();
|
||||
resetScrollState().catch((error) => handleResponseErrors(error));
|
||||
}
|
||||
}, [isScrolledToElement]);
|
||||
}, [isScrolledToElement, scrollState, resetScrollState]);
|
||||
|
||||
useEffect(() => {
|
||||
// If the locatorId is set/changed, we need to make sure that the subsection is expanded
|
||||
@@ -290,6 +305,7 @@ const SubsectionCard = ({
|
||||
onClickSync={openSyncModal}
|
||||
onClickCard={(e) => onClickCard(e, true)}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
onClickManageTags={handleClickManageTags}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
actions={actions}
|
||||
|
||||
@@ -307,6 +307,7 @@ describe('<UnitCard />', () => {
|
||||
it('should open align sidebar', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockSetCurrentPageKey = jest.fn();
|
||||
const mockSetSelectedContainerState = jest.fn();
|
||||
|
||||
const testSidebarPage = {
|
||||
component: CourseInfoSidebar,
|
||||
@@ -332,6 +333,7 @@ describe('<UnitCard />', () => {
|
||||
stopCurrentFlow: jest.fn(),
|
||||
openContainerInfoSidebar: jest.fn(),
|
||||
clearSelection: jest.fn(),
|
||||
setSelectedContainerState: mockSetSelectedContainerState,
|
||||
}));
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
@@ -356,5 +358,10 @@ describe('<UnitCard />', () => {
|
||||
subsectionId: subsection.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
expect(mockSetSelectedContainerState).toHaveBeenCalledWith({
|
||||
currentId: unit.id,
|
||||
subsectionId: subsection.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,14 +5,12 @@ import {
|
||||
useRef,
|
||||
} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import CourseOutlineUnitCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineUnitCardExtraActionsSlot';
|
||||
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
|
||||
import CardHeader from '@src/course-outline/card-header/CardHeader';
|
||||
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
|
||||
import TitleLink from '@src/course-outline/card-header/TitleLink';
|
||||
@@ -24,8 +22,9 @@ import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes';
|
||||
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
|
||||
import type { UnitXBlock, XBlock } from '@src/data/types';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { courseOutlineQueryKeys, useCourseItemData, useScrollState } from '@src/course-outline/data/apiHooks';
|
||||
import moment from 'moment';
|
||||
import { handleResponseErrors } from '@src/generic/saving-error-alert';
|
||||
import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext';
|
||||
|
||||
interface UnitCardProps {
|
||||
@@ -61,9 +60,8 @@ const UnitCard = ({
|
||||
discussionsSettings,
|
||||
}: UnitCardProps) => {
|
||||
const currentRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
|
||||
const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext();
|
||||
const locatorId = searchParams.get('show');
|
||||
const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false);
|
||||
const namePrefix = 'unit';
|
||||
@@ -79,6 +77,7 @@ const UnitCard = ({
|
||||
initialSubsectionData,
|
||||
);
|
||||
const { data: unit = initialData } = useCourseItemData<UnitXBlock>(initialData.id, initialData);
|
||||
const { data: scrollState, resetData: resetScrollState } = useScrollState(courseId);
|
||||
const isScrolledToElement = locatorId === unit.id;
|
||||
|
||||
const {
|
||||
@@ -140,6 +139,14 @@ const UnitCard = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleClickManageTags = () => {
|
||||
setSelectedContainerState({
|
||||
currentId: unit.id,
|
||||
subsectionId: subsection.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnitMoveUp = () => {
|
||||
onOrderChange(section, moveUpDetails);
|
||||
};
|
||||
@@ -154,11 +161,13 @@ const UnitCard = ({
|
||||
};
|
||||
|
||||
const handleOnPostChangeSync = useCallback(() => {
|
||||
dispatch(fetchCourseSectionQuery([section.id]));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(section.id),
|
||||
});
|
||||
if (courseId) {
|
||||
invalidateLinksQuery(queryClient, courseId);
|
||||
}
|
||||
}, [dispatch, section, queryClient, courseId]);
|
||||
}, [section, queryClient, courseId]);
|
||||
|
||||
const onClickCard = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
@@ -194,18 +203,23 @@ const UnitCard = ({
|
||||
useEffect(() => {
|
||||
// istanbul ignore if
|
||||
if (moment(initialData.editedOnRaw).isAfter(moment(unit.editedOnRaw))) {
|
||||
queryClient.cancelQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(initialData.id),
|
||||
// eslint-disable-next-line no-console
|
||||
}).catch((error) => console.error('Error cancelling query:', error));
|
||||
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData);
|
||||
}
|
||||
}, [initialData, unit]);
|
||||
|
||||
useEffect(() => {
|
||||
// if this items has been newly added, scroll to it.
|
||||
if (currentRef.current && (unit.shouldScroll || isScrolledToElement)) {
|
||||
if (currentRef.current && (scrollState?.id === unit.id || isScrolledToElement)) {
|
||||
// Align element closer to the top of the screen if scrolling for search result
|
||||
const alignWithTop = !!isScrolledToElement;
|
||||
scrollToElement(currentRef.current, alignWithTop, true);
|
||||
resetScrollState().catch((error) => handleResponseErrors(error));
|
||||
}
|
||||
}, [isScrolledToElement]);
|
||||
}, [isScrolledToElement, scrollState, resetScrollState]);
|
||||
|
||||
if (!isHeaderVisible) {
|
||||
return null;
|
||||
@@ -269,6 +283,7 @@ const UnitCard = ({
|
||||
onClickSync={openSyncModal}
|
||||
onClickCard={onClickCard}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
onClickManageTags={handleClickManageTags}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
actions={actions}
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
// @ts-check
|
||||
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
|
||||
import { courseTeamMock, courseTeamWithOneUser, courseTeamWithoutUsers } from './__mocks__';
|
||||
import { getCourseTeamApiUrl, updateCourseTeamUserApiUrl } from './data/api';
|
||||
import CourseTeam from './CourseTeam';
|
||||
import messages from './messages';
|
||||
import { USER_ROLES } from '../constants';
|
||||
import { executeThunk } from '../utils';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { changeRoleTeamUserQuery, deleteCourseTeamQuery } from './data/thunk';
|
||||
import {
|
||||
fireEvent,
|
||||
initializeMocks,
|
||||
render as baseRender,
|
||||
waitFor,
|
||||
} from '../testUtils';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
|
||||
const render = () => baseRender(
|
||||
<CourseAuthoringProvider courseId={courseId}>
|
||||
<CourseTeam />
|
||||
</CourseAuthoringProvider>,
|
||||
{ path: mockPathname },
|
||||
);
|
||||
|
||||
describe('<CourseTeam />', () => {
|
||||
beforeEach(() => {
|
||||
const mocks = initializeMocks();
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
});
|
||||
|
||||
it('render CourseTeam component with 3 team members correctly', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamMock);
|
||||
|
||||
const {
|
||||
getByText, getByRole, getByTestId, queryAllByTestId,
|
||||
} = render();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByTestId('course-team-sidebar')).toBeInTheDocument();
|
||||
expect(queryAllByTestId('course-team-member')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
it('render CourseTeam component with 1 team member correctly', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithOneUser);
|
||||
|
||||
const {
|
||||
getByText, getByRole, getByTestId, getAllByTestId,
|
||||
} = render();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByTestId('course-team-sidebar')).toBeInTheDocument();
|
||||
expect(getAllByTestId('course-team-member')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('render CourseTeam component without team member correctly', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithoutUsers);
|
||||
|
||||
const {
|
||||
getByText, getByRole, getByTestId, queryAllByTestId,
|
||||
} = render();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByTestId('course-team-sidebar__initial')).toBeInTheDocument();
|
||||
expect(queryAllByTestId('course-team-member')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('render CourseTeam component with initial sidebar correctly', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithoutUsers);
|
||||
|
||||
const { getByTestId, queryByTestId } = render();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('course-team-sidebar__initial')).toBeInTheDocument();
|
||||
expect(queryByTestId('course-team-sidebar')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('render CourseTeam component without initial sidebar correctly', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamMock);
|
||||
|
||||
const { getByTestId, queryByTestId } = render();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('course-team-sidebar__initial')).not.toBeInTheDocument();
|
||||
expect(getByTestId('course-team-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays AddUserForm when clicking the "Add New Member" button', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithOneUser);
|
||||
|
||||
const { getByRole, queryByTestId } = render();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('add-user-form')).not.toBeInTheDocument();
|
||||
const addButton = getByRole('button', { name: messages.addNewMemberButton.defaultMessage });
|
||||
fireEvent.click(addButton);
|
||||
expect(queryByTestId('add-user-form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays AddUserForm when clicking the "Add a New Team member" button', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithOneUser);
|
||||
|
||||
const { getByRole, queryByTestId } = render();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('add-user-form')).not.toBeInTheDocument();
|
||||
const addButton = getByRole('button', { name: 'Add a new team member' });
|
||||
fireEvent.click(addButton);
|
||||
expect(queryByTestId('add-user-form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('not displays "Add New Member" and AddTeamMember component when isAllowActions is false', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...courseTeamWithOneUser,
|
||||
allowActions: false,
|
||||
});
|
||||
|
||||
const { queryByRole, queryByTestId } = render();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByRole('button', { name: messages.addNewMemberButton.defaultMessage })).not.toBeInTheDocument();
|
||||
expect(queryByTestId('add-team-member')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete user', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamMock);
|
||||
|
||||
const { queryByText } = render();
|
||||
|
||||
axiosMock
|
||||
.onDelete(updateCourseTeamUserApiUrl(courseId, 'staff@example.com'))
|
||||
.reply(200);
|
||||
|
||||
await executeThunk(deleteCourseTeamQuery(courseId, 'staff@example.com'), store.dispatch);
|
||||
expect(queryByText('staff@example.com')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should change role user', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamMock);
|
||||
|
||||
const { getAllByText } = render();
|
||||
|
||||
axiosMock
|
||||
.onPut(updateCourseTeamUserApiUrl(courseId, 'staff@example.com'))
|
||||
.reply(200, { role: USER_ROLES.admin });
|
||||
|
||||
await executeThunk(changeRoleTeamUserQuery(courseId, 'staff@example.com', { role: USER_ROLES.admin }), store.dispatch);
|
||||
expect(getAllByText('Admin')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('displays an alert and sets status to DENIED when API responds with 403', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(403);
|
||||
|
||||
const { getByRole } = render();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByRole('alert')).toBeInTheDocument();
|
||||
const { loadingCourseTeamStatus } = store.getState().courseTeam;
|
||||
expect(loadingCourseTeamStatus).toEqual(RequestStatus.DENIED);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets loading status to FAILED upon receiving a 404 response from the API', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(404);
|
||||
|
||||
render();
|
||||
|
||||
await waitFor(() => {
|
||||
const { loadingCourseTeamStatus } = store.getState().courseTeam;
|
||||
expect(loadingCourseTeamStatus).toEqual(RequestStatus.FAILED);
|
||||
});
|
||||
});
|
||||
});
|
||||
242
src/course-team/CourseTeam.test.tsx
Normal file
242
src/course-team/CourseTeam.test.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
screen,
|
||||
initializeMocks,
|
||||
render as baseRender,
|
||||
waitFor,
|
||||
} from '@src/testUtils';
|
||||
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
|
||||
import { USER_ROLES } from '@src/constants';
|
||||
|
||||
import { courseTeamMock, courseTeamWithOneUser, courseTeamWithoutUsers } from './__mocks__';
|
||||
import { getCourseTeamApiUrl, updateCourseTeamUserApiUrl } from './data/api';
|
||||
import CourseTeam from './CourseTeam';
|
||||
import messages from './messages';
|
||||
import addUserFormMessages from './add-user-form/messages';
|
||||
|
||||
let axiosMock;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
|
||||
const render = () => baseRender(
|
||||
<CourseAuthoringProvider courseId={courseId}>
|
||||
<CourseTeam />
|
||||
</CourseAuthoringProvider>,
|
||||
{ path: mockPathname },
|
||||
);
|
||||
|
||||
describe('<CourseTeam />', () => {
|
||||
beforeEach(() => {
|
||||
const mocks = initializeMocks();
|
||||
axiosMock = mocks.axiosMock;
|
||||
});
|
||||
|
||||
it('render CourseTeam component with 3 team members correctly', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamMock);
|
||||
|
||||
render();
|
||||
|
||||
expect(await screen.findByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('course-team-sidebar')).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('course-team-member')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('render CourseTeam component with 1 team member correctly', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithOneUser);
|
||||
|
||||
render();
|
||||
|
||||
expect(await screen.findByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('course-team-sidebar')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('course-team-member')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('render CourseTeam component without team member correctly', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithoutUsers);
|
||||
|
||||
render();
|
||||
|
||||
expect(await screen.findByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('course-team-sidebar__initial')).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('course-team-member')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('render CourseTeam component with initial sidebar correctly', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithoutUsers);
|
||||
|
||||
render();
|
||||
|
||||
expect(await screen.findByTestId('course-team-sidebar__initial')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('course-team-sidebar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render CourseTeam component without initial sidebar correctly', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamMock);
|
||||
|
||||
render();
|
||||
|
||||
expect(await screen.findByTestId('course-team-sidebar')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('course-team-sidebar__initial')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays AddUserForm when clicking the "Add New Member" button', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithOneUser);
|
||||
|
||||
render();
|
||||
|
||||
expect(screen.queryByTestId('add-user-form')).not.toBeInTheDocument();
|
||||
|
||||
const addButton = await screen.findByRole('button', { name: messages.addNewMemberButton.defaultMessage });
|
||||
expect(addButton).toBeInTheDocument();
|
||||
await user.click(addButton);
|
||||
expect(screen.queryByTestId('add-user-form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays AddUserForm when clicking the "Add a New Team member" button', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithOneUser);
|
||||
|
||||
render();
|
||||
|
||||
expect(screen.queryByTestId('add-user-form')).not.toBeInTheDocument();
|
||||
|
||||
const addButton = await screen.findByRole('button', { name: 'Add a new team member' });
|
||||
expect(addButton).toBeInTheDocument();
|
||||
await user.click(addButton);
|
||||
expect(screen.queryByTestId('add-user-form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('not displays "Add New Member" and AddTeamMember component when isAllowActions is false', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...courseTeamWithOneUser,
|
||||
allowActions: false,
|
||||
});
|
||||
|
||||
render();
|
||||
|
||||
await screen.findByText(messages.headingTitle.defaultMessage);
|
||||
expect(screen.queryByRole('button', { name: messages.addNewMemberButton.defaultMessage })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('add-team-member')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should delete user', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamMock);
|
||||
const deleteUrl = updateCourseTeamUserApiUrl(courseId, 'staff@example.com');
|
||||
|
||||
axiosMock
|
||||
.onDelete(deleteUrl)
|
||||
.reply(200);
|
||||
|
||||
render();
|
||||
|
||||
const deleteButton = (await screen.findAllByRole('button', { name: /delete user/i }))[0];
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
await user.click(deleteButton);
|
||||
|
||||
expect(await screen.findByText('Delete course team member'));
|
||||
const confirmDelete = screen.getByRole('button', { name: /delete/i });
|
||||
expect(confirmDelete).toBeInTheDocument();
|
||||
await user.click(confirmDelete);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.delete.length).toBe(1);
|
||||
});
|
||||
expect(axiosMock.history.delete[0].url).toEqual(deleteUrl);
|
||||
});
|
||||
|
||||
it('should change role user', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamMock);
|
||||
|
||||
const updateUrl = updateCourseTeamUserApiUrl(courseId, 'staff@example.com');
|
||||
|
||||
axiosMock
|
||||
.onPut(updateUrl)
|
||||
.reply(200, { role: USER_ROLES.admin });
|
||||
|
||||
render();
|
||||
|
||||
const updateButton = (await screen.findAllByRole('button', { name: /add admin access/i }))[0];
|
||||
expect(updateButton).toBeInTheDocument();
|
||||
await user.click(updateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.put.length).toBe(1);
|
||||
});
|
||||
expect(axiosMock.history.put[0].url).toEqual(updateUrl);
|
||||
});
|
||||
|
||||
it('should show warning modal when submitting an already existing user email', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithOneUser);
|
||||
|
||||
render();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: messages.addNewMemberButton.defaultMessage }));
|
||||
await user.type(screen.getByRole('textbox'), 'staff@example.com');
|
||||
await user.click(screen.getByRole('button', { name: addUserFormMessages.addUserButton.defaultMessage }));
|
||||
|
||||
expect(await screen.findByText('Already a course team member')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide the form after successfully adding a new user', async () => {
|
||||
const user = userEvent.setup();
|
||||
const newEmail = 'newuser@example.com';
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithOneUser);
|
||||
axiosMock
|
||||
.onPost(updateCourseTeamUserApiUrl(courseId, newEmail))
|
||||
.reply(200);
|
||||
|
||||
render();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: messages.addNewMemberButton.defaultMessage }));
|
||||
await user.type(screen.getByRole('textbox'), newEmail);
|
||||
await user.click(screen.getByRole('button', { name: addUserFormMessages.addUserButton.defaultMessage }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('add-user-form')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays an alert and sets status to DENIED when API responds with 403', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(403);
|
||||
|
||||
render();
|
||||
|
||||
expect(await screen.findByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,14 @@ import {
|
||||
Layout,
|
||||
} from '@openedx/paragon';
|
||||
import { Add as IconAdd } from '@openedx/paragon/icons';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import { USER_ROLES } from '../constants';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
|
||||
import SubHeader from '@src/generic/sub-header/SubHeader';
|
||||
import { USER_ROLES } from '@src/constants';
|
||||
import getPageHeadTitle from '@src/generic/utils';
|
||||
import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert';
|
||||
|
||||
import messages from './messages';
|
||||
import CourseTeamSideBar from './course-team-sidebar/CourseTeamSidebar';
|
||||
import AddUserForm from './add-user-form/AddUserForm';
|
||||
@@ -17,12 +20,9 @@ import AddTeamMember from './add-team-member/AddTeamMember';
|
||||
import CourseTeamMember from './course-team-member/CourseTeamMember';
|
||||
import InfoModal from './info-modal/InfoModal';
|
||||
import { useCourseTeam } from './hooks';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
|
||||
|
||||
const CourseTeam = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
|
||||
const {
|
||||
@@ -43,7 +43,6 @@ const CourseTeam = () => {
|
||||
isShowAddTeamMember,
|
||||
isShowInitialSidebar,
|
||||
isShowUserFilledSidebar,
|
||||
isInternetConnectionAlertFailed,
|
||||
openForm,
|
||||
hideForm,
|
||||
closeInfoModal,
|
||||
@@ -51,8 +50,7 @@ const CourseTeam = () => {
|
||||
handleOpenDeleteModal,
|
||||
handleDeleteUserSubmit,
|
||||
handleChangeRoleUserSubmit,
|
||||
handleInternetConnectionFailed,
|
||||
} = useCourseTeam({ intl, courseId });
|
||||
} = useCourseTeam();
|
||||
|
||||
document.title = getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle));
|
||||
|
||||
@@ -86,7 +84,7 @@ const CourseTeam = () => {
|
||||
<SubHeader
|
||||
title={intl.formatMessage(messages.headingTitle)}
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
headerActions={isAllowActions && (
|
||||
headerActions={isAllowActions ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
iconBefore={IconAdd}
|
||||
@@ -96,7 +94,7 @@ const CourseTeam = () => {
|
||||
>
|
||||
{intl.formatMessage(messages.addNewMemberButton)}
|
||||
</Button>
|
||||
)}
|
||||
) : undefined}
|
||||
/>
|
||||
<section className="course-team-section">
|
||||
<div className="members-container">
|
||||
@@ -139,7 +137,7 @@ const CourseTeam = () => {
|
||||
isOpen={isInfoModalOpen}
|
||||
close={closeInfoModal}
|
||||
currentEmail={currentEmail}
|
||||
errorMessage={errorMessage}
|
||||
errorMessage={errorMessage ?? ''}
|
||||
courseName={courseName}
|
||||
modalType={modalType}
|
||||
onDeleteSubmit={handleDeleteUserSubmit}
|
||||
@@ -161,9 +159,9 @@ const CourseTeam = () => {
|
||||
</Container>
|
||||
<div className="alert-toast">
|
||||
<InternetConnectionAlert
|
||||
isFailed={isInternetConnectionAlertFailed}
|
||||
isFailed={errorMessage !== undefined}
|
||||
isQueryPending={isQueryPending}
|
||||
onInternetConnectionFailed={handleInternetConnectionFailed}
|
||||
onInternetConnectionFailed={/* istanbul ignore next */ () => {}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -1,128 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
act,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { EXAMPLE_USER_EMAIL } from '../constants';
|
||||
import initializeStore from '../../store';
|
||||
import { USER_ROLES } from '../../constants';
|
||||
import { updateCourseTeamUserApiUrl } from '../data/api';
|
||||
import { createCourseTeamQuery } from '../data/thunk';
|
||||
import { executeThunk } from '../../utils';
|
||||
import AddUserForm from './AddUserForm';
|
||||
import messages from './messages';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
pathname: mockPathname,
|
||||
}),
|
||||
}));
|
||||
|
||||
const onSubmitMock = jest.fn();
|
||||
const onCancelMock = jest.fn();
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<AddUserForm
|
||||
onSubmit={onSubmitMock}
|
||||
onCancel={onCancelMock}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<AddUserForm />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('render AddUserForm component correctly', () => {
|
||||
const { getByText, getByPlaceholderText } = render(<RootWrapper />);
|
||||
|
||||
expect(getByText(messages.formTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.formLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByPlaceholderText(messages.formPlaceholder.defaultMessage
|
||||
.replace('{email}', EXAMPLE_USER_EMAIL))).toBeInTheDocument();
|
||||
expect(getByText(messages.cancelButton.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.addUserButton.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSubmit when the "Add User" button is clicked with a valid email', async () => {
|
||||
const { getByPlaceholderText, getByRole } = render(<RootWrapper />);
|
||||
|
||||
const emailInput = getByPlaceholderText(messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL));
|
||||
const addUserButton = getByRole('button', { name: messages.addUserButton.defaultMessage });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: EXAMPLE_USER_EMAIL } });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(addUserButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmitMock).toHaveBeenCalledWith(
|
||||
{ email: EXAMPLE_USER_EMAIL },
|
||||
expect.objectContaining({ submitForm: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onPost(updateCourseTeamUserApiUrl(courseId, EXAMPLE_USER_EMAIL), { role: USER_ROLES.staff })
|
||||
.reply(200, { role: USER_ROLES.staff });
|
||||
|
||||
await executeThunk(createCourseTeamQuery(courseId, EXAMPLE_USER_EMAIL), store.dispatch);
|
||||
});
|
||||
|
||||
it('calls onCancel when the "Cancel" button is clicked', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
|
||||
const cancelButton = getByText(messages.cancelButton.defaultMessage);
|
||||
fireEvent.click(cancelButton);
|
||||
expect(onCancelMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('"Add User" button is disabled when the email input field is empty', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
|
||||
const addUserButton = getByText(messages.addUserButton.defaultMessage);
|
||||
expect(addUserButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('"Add User" button is not disabled when the email input field is not empty', () => {
|
||||
const { getByPlaceholderText, getByText } = render(<RootWrapper />);
|
||||
|
||||
const emailInput = getByPlaceholderText(
|
||||
messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL),
|
||||
);
|
||||
const addUserButton = getByText(messages.addUserButton.defaultMessage);
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'user@example.com' } });
|
||||
expect(addUserButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
96
src/course-team/add-user-form/AddUserForm.test.tsx
Normal file
96
src/course-team/add-user-form/AddUserForm.test.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
initializeMocks,
|
||||
} from '@src/testUtils';
|
||||
|
||||
import { EXAMPLE_USER_EMAIL } from '../constants';
|
||||
import AddUserForm from './AddUserForm';
|
||||
import messages from './messages';
|
||||
|
||||
const mockPathname = '/foo-bar';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
pathname: mockPathname,
|
||||
}),
|
||||
}));
|
||||
|
||||
const onSubmitMock = jest.fn();
|
||||
const onCancelMock = jest.fn();
|
||||
|
||||
const renderComponent = () => render(
|
||||
<AddUserForm
|
||||
onSubmit={onSubmitMock}
|
||||
onCancel={onCancelMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe('<AddUserForm />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('render AddUserForm component correctly', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByText(messages.formTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.formLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(messages.formPlaceholder.defaultMessage
|
||||
.replace('{email}', EXAMPLE_USER_EMAIL))).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.cancelButton.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.addUserButton.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSubmit when the "Add User" button is clicked with a valid email', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL));
|
||||
const addUserButton = screen.getByRole('button', { name: messages.addUserButton.defaultMessage });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: EXAMPLE_USER_EMAIL } });
|
||||
|
||||
await user.click(addUserButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onSubmitMock).toHaveBeenCalledWith(
|
||||
{ email: EXAMPLE_USER_EMAIL },
|
||||
expect.objectContaining({ submitForm: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onCancel when the "Cancel" button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
const cancelButton = screen.getByText(messages.cancelButton.defaultMessage);
|
||||
await user.click(cancelButton);
|
||||
expect(onCancelMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('"Add User" button is disabled when the email input field is empty', () => {
|
||||
renderComponent();
|
||||
|
||||
const addUserButton = screen.getByText(messages.addUserButton.defaultMessage);
|
||||
expect(addUserButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('"Add User" button is not disabled when the email input field is not empty', () => {
|
||||
renderComponent();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(
|
||||
messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL),
|
||||
);
|
||||
const addUserButton = screen.getByText(messages.addUserButton.defaultMessage);
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'user@example.com' } });
|
||||
expect(addUserButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ export const MODAL_TYPES = {
|
||||
warning: 'warning',
|
||||
} as const;
|
||||
|
||||
export type ModalType = typeof MODAL_TYPES[keyof typeof MODAL_TYPES];
|
||||
|
||||
export const BADGE_STATES = {
|
||||
admin: 'primary-700',
|
||||
staff: 'gray-500',
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { USER_ROLES } from '../../constants';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getCourseTeamApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_team/${courseId}`;
|
||||
export const updateCourseTeamUserApiUrl = (courseId, email) => `${getApiBaseUrl()}/course_team/${courseId}/${email}`;
|
||||
|
||||
/**
|
||||
* Get course team.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getCourseTeam(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseTeamApiUrl(courseId));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create course team user.
|
||||
* @param {string} courseId
|
||||
* @param {string} email
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function createTeamUser(courseId, email) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.post(updateCourseTeamUserApiUrl(courseId, email), { role: USER_ROLES.staff });
|
||||
}
|
||||
|
||||
/**
|
||||
* Change role course team user.
|
||||
* @param {string} courseId
|
||||
* @param {string} email
|
||||
* @param {string} role
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function changeRoleTeamUser(courseId, email, role) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.put(updateCourseTeamUserApiUrl(courseId, email), { role });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete course team user.
|
||||
* @param {string} courseId
|
||||
* @param {string} email
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function deleteTeamUser(courseId, email) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(updateCourseTeamUserApiUrl(courseId, email));
|
||||
}
|
||||
54
src/course-team/data/api.ts
Normal file
54
src/course-team/data/api.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { USER_ROLES } from '@src/constants';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getCourseTeamApiUrl = (courseId: string) => `${getApiBaseUrl()}/api/contentstore/v1/course_team/${courseId}`;
|
||||
export const updateCourseTeamUserApiUrl = (courseId: string, email: string) => `${getApiBaseUrl()}/course_team/${courseId}/${email}`;
|
||||
|
||||
export interface CourseTeamUser {
|
||||
id: number;
|
||||
email: string;
|
||||
role: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface CourseTeam {
|
||||
users: CourseTeamUser[];
|
||||
allowActions: boolean;
|
||||
showTransferOwnershipHint: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get course team.
|
||||
*/
|
||||
export async function getCourseTeam(courseId: string): Promise<CourseTeam> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseTeamApiUrl(courseId));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create course team user.
|
||||
*/
|
||||
export async function createTeamUser(courseId: string, email: string) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.post(updateCourseTeamUserApiUrl(courseId, email), { role: USER_ROLES.staff });
|
||||
}
|
||||
|
||||
/**
|
||||
* Change role course team user.
|
||||
*/
|
||||
export async function changeRoleTeamUser(courseId: string, email: string, role: string) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.put(updateCourseTeamUserApiUrl(courseId, email), { role });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete course team user.
|
||||
*/
|
||||
export async function deleteTeamUser(courseId: string, email: string) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(updateCourseTeamUserApiUrl(courseId, email));
|
||||
}
|
||||
64
src/course-team/data/apiHooks.ts
Normal file
64
src/course-team/data/apiHooks.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AxiosError } from 'axios';
|
||||
import * as api from './api';
|
||||
|
||||
export const courseTeamQueryKeys = {
|
||||
all: ['courseTeam'],
|
||||
/** Base key for course team data specific to a courseId */
|
||||
courseTeam: (courseId: string) => [...courseTeamQueryKeys.all, courseId],
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch the course team for the given courseId
|
||||
*/
|
||||
export const useCourseTeamData = (courseId: string) => (
|
||||
useQuery<api.CourseTeam, AxiosError>({
|
||||
queryKey: courseTeamQueryKeys.courseTeam(courseId),
|
||||
queryFn: () => api.getCourseTeam(courseId),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook to create a new course team user
|
||||
*/
|
||||
export const useCreateTeamUser = (courseId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, AxiosError, string>({
|
||||
mutationFn: (email: string) => api.createTeamUser(courseId, email),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: courseTeamQueryKeys.courseTeam(courseId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export type ChangeRoleRequest = {
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to change the role of a course team user
|
||||
*/
|
||||
export const useChangeRoleTeamUser = (courseId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, AxiosError, ChangeRoleRequest>({
|
||||
mutationFn: ({ email, role }: ChangeRoleRequest) => api.changeRoleTeamUser(courseId, email, role),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: courseTeamQueryKeys.courseTeam(courseId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete a course team user
|
||||
*/
|
||||
export const useDeleteTeamUser = (courseId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, AxiosError, string>({
|
||||
mutationFn: (email: string) => api.deleteTeamUser(courseId, email),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: courseTeamQueryKeys.courseTeam(courseId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
export const getCourseTeamUsers = (state) => state.courseTeam.users;
|
||||
export const getCourseTeamLoadingStatus = (state) => state.courseTeam.loadingCourseTeamStatus;
|
||||
export const getErrorMessage = (state) => state.courseTeam.errorMessage;
|
||||
export const getIsAllowActions = (state) => state.courseTeam.allowActions;
|
||||
export const getIsOwnershipHint = (state) => state.courseTeam.showTransferOwnershipHint;
|
||||
export const getSavingStatus = (state) => state.courseTeam.savingStatus;
|
||||
@@ -1,46 +0,0 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'courseTeam',
|
||||
initialState: {
|
||||
loadingCourseTeamStatus: RequestStatus.IN_PROGRESS,
|
||||
savingStatus: '',
|
||||
users: [],
|
||||
showTransferOwnershipHint: false,
|
||||
allowActions: false,
|
||||
errorMessage: '',
|
||||
},
|
||||
reducers: {
|
||||
fetchCourseTeamSuccess: (state, { payload }) => {
|
||||
state.users = payload.users;
|
||||
state.showTransferOwnershipHint = payload.showTransferOwnershipHint;
|
||||
state.allowActions = payload.allowActions;
|
||||
},
|
||||
updateLoadingCourseTeamStatus: (state, { payload }) => {
|
||||
state.loadingCourseTeamStatus = payload.status;
|
||||
},
|
||||
deleteCourseTeamUser: (state, { payload }) => {
|
||||
state.users = state.users.filter((user) => user.email !== payload);
|
||||
},
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
setErrorMessage: (state, { payload }) => {
|
||||
state.errorMessage = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchCourseTeamSuccess,
|
||||
updateLoadingCourseTeamStatus,
|
||||
deleteCourseTeamUser,
|
||||
updateSavingStatus,
|
||||
setErrorMessage,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
@@ -1,91 +0,0 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import {
|
||||
getCourseTeam,
|
||||
deleteTeamUser,
|
||||
createTeamUser,
|
||||
changeRoleTeamUser,
|
||||
} from './api';
|
||||
import {
|
||||
fetchCourseTeamSuccess,
|
||||
updateLoadingCourseTeamStatus,
|
||||
deleteCourseTeamUser,
|
||||
updateSavingStatus,
|
||||
setErrorMessage,
|
||||
} from './slice';
|
||||
|
||||
export function fetchCourseTeamQuery(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const courseTeam = await getCourseTeam(courseId);
|
||||
dispatch(fetchCourseTeamSuccess(courseTeam));
|
||||
|
||||
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 403) {
|
||||
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.DENIED }));
|
||||
} else {
|
||||
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createCourseTeamQuery(courseId, email) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await createTeamUser(courseId, email);
|
||||
const courseTeam = await getCourseTeam(courseId);
|
||||
dispatch(fetchCourseTeamSuccess(courseTeam));
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.error || '';
|
||||
dispatch(setErrorMessage(message));
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function changeRoleTeamUserQuery(courseId, email, role) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await changeRoleTeamUser(courseId, email, role);
|
||||
const courseTeam = await getCourseTeam(courseId);
|
||||
dispatch(fetchCourseTeamSuccess(courseTeam));
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseTeamQuery(courseId, email) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await deleteTeamUser(courseId, email);
|
||||
dispatch(deleteCourseTeamUser(email));
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { USER_ROLES } from '../constants';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import {
|
||||
changeRoleTeamUserQuery,
|
||||
createCourseTeamQuery,
|
||||
deleteCourseTeamQuery,
|
||||
fetchCourseTeamQuery,
|
||||
} from './data/thunk';
|
||||
import {
|
||||
getCourseTeamLoadingStatus,
|
||||
getCourseTeamUsers,
|
||||
getErrorMessage,
|
||||
getIsAllowActions,
|
||||
getIsOwnershipHint, getSavingStatus,
|
||||
} from './data/selectors';
|
||||
import { setErrorMessage } from './data/slice';
|
||||
import { MODAL_TYPES } from './constants';
|
||||
|
||||
const useCourseTeam = ({ courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { email: currentUserEmail } = getAuthenticatedUser();
|
||||
const { courseDetails } = useCourseAuthoringContext();
|
||||
|
||||
const [modalType, setModalType] = useState(MODAL_TYPES.delete);
|
||||
const [isInfoModalOpen, openInfoModal, closeInfoModal] = useToggle(false);
|
||||
const [isFormVisible, openForm, hideForm] = useToggle(false);
|
||||
const [currentEmail, setCurrentEmail] = useState('');
|
||||
const [isQueryPending, setIsQueryPending] = useState(false);
|
||||
const courseTeamUsers = useSelector(getCourseTeamUsers);
|
||||
const errorMessage = useSelector(getErrorMessage);
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
const isAllowActions = useSelector(getIsAllowActions);
|
||||
const isOwnershipHint = useSelector(getIsOwnershipHint);
|
||||
const loadingCourseTeamStatus = useSelector(getCourseTeamLoadingStatus);
|
||||
|
||||
const isSingleAdmin = courseTeamUsers.filter((user) => user.role === USER_ROLES.admin).length === 1;
|
||||
|
||||
const handleOpenInfoModal = (type, email) => {
|
||||
setCurrentEmail(email);
|
||||
setModalType(type);
|
||||
openInfoModal();
|
||||
};
|
||||
|
||||
const handleCloseInfoModal = () => {
|
||||
dispatch(setErrorMessage(''));
|
||||
closeInfoModal();
|
||||
};
|
||||
|
||||
const handleAddUserSubmit = (data) => {
|
||||
setIsQueryPending(true);
|
||||
|
||||
const { email } = data;
|
||||
const isUserContains = courseTeamUsers.some((user) => user.email === email);
|
||||
|
||||
if (isUserContains) {
|
||||
handleOpenInfoModal(MODAL_TYPES.warning, email);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(createCourseTeamQuery(courseId, email)).then((result) => {
|
||||
if (result) {
|
||||
hideForm();
|
||||
dispatch(setErrorMessage(''));
|
||||
return;
|
||||
}
|
||||
|
||||
handleOpenInfoModal(MODAL_TYPES.error, email);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteUserSubmit = () => {
|
||||
setIsQueryPending(true);
|
||||
dispatch(deleteCourseTeamQuery(courseId, currentEmail));
|
||||
handleCloseInfoModal();
|
||||
};
|
||||
|
||||
const handleChangeRoleUserSubmit = (email, role) => {
|
||||
setIsQueryPending(true);
|
||||
dispatch(changeRoleTeamUserQuery(courseId, email, role));
|
||||
};
|
||||
|
||||
const handleInternetConnectionFailed = () => {
|
||||
setIsQueryPending(false);
|
||||
};
|
||||
|
||||
const handleOpenDeleteModal = (email) => {
|
||||
handleOpenInfoModal(MODAL_TYPES.delete, email);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseTeamQuery(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
setIsQueryPending(false);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
return {
|
||||
modalType,
|
||||
errorMessage,
|
||||
courseName: courseDetails?.name || '',
|
||||
currentEmail,
|
||||
courseTeamUsers,
|
||||
currentUserEmail,
|
||||
isLoading: loadingCourseTeamStatus === RequestStatus.IN_PROGRESS,
|
||||
isLoadingDenied: loadingCourseTeamStatus === RequestStatus.DENIED,
|
||||
isSingleAdmin,
|
||||
isFormVisible,
|
||||
isAllowActions,
|
||||
isInfoModalOpen,
|
||||
isOwnershipHint,
|
||||
isQueryPending,
|
||||
isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED,
|
||||
isShowAddTeamMember: courseTeamUsers.length === 1 && isAllowActions,
|
||||
isShowInitialSidebar: !courseTeamUsers.length && !isFormVisible,
|
||||
isShowUserFilledSidebar: Boolean(courseTeamUsers.length) || isFormVisible,
|
||||
openForm,
|
||||
hideForm,
|
||||
closeInfoModal,
|
||||
handleAddUserSubmit,
|
||||
handleOpenInfoModal,
|
||||
handleOpenDeleteModal,
|
||||
handleDeleteUserSubmit,
|
||||
handleChangeRoleUserSubmit,
|
||||
handleInternetConnectionFailed,
|
||||
};
|
||||
};
|
||||
|
||||
export { useCourseTeam };
|
||||
118
src/course-team/hooks.tsx
Normal file
118
src/course-team/hooks.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useState } from 'react';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { USER_ROLES } from '../constants';
|
||||
import messages from './messages';
|
||||
import { MODAL_TYPES, type ModalType } from './constants';
|
||||
import {
|
||||
useChangeRoleTeamUser,
|
||||
useCourseTeamData,
|
||||
useCreateTeamUser,
|
||||
useDeleteTeamUser,
|
||||
} from './data/apiHooks';
|
||||
|
||||
const useCourseTeam = () => {
|
||||
const intl = useIntl();
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
|
||||
const { email: currentUserEmail } = getAuthenticatedUser();
|
||||
const { courseDetails } = useCourseAuthoringContext();
|
||||
const {
|
||||
data,
|
||||
isPending: isLoadingCourseTeamStatus,
|
||||
failureReason: courseTeamQueryError,
|
||||
} = useCourseTeamData(courseId);
|
||||
|
||||
const {
|
||||
users: courseTeamUsers = [],
|
||||
allowActions: isAllowActions = false,
|
||||
showTransferOwnershipHint: isOwnershipHint = false,
|
||||
} = data ?? {};
|
||||
|
||||
const addUserMutation = useCreateTeamUser(courseId);
|
||||
const editUserRoleMutation = useChangeRoleTeamUser(courseId);
|
||||
const deleteUserMutation = useDeleteTeamUser(courseId);
|
||||
|
||||
const [modalType, setModalType] = useState<ModalType>(MODAL_TYPES.delete);
|
||||
const [isInfoModalOpen, openInfoModal, closeInfoModal] = useToggle(false);
|
||||
const [isFormVisible, openForm, hideForm] = useToggle(false);
|
||||
const [currentEmail, setCurrentEmail] = useState('');
|
||||
|
||||
const courseTeamStatusIsDenied = courseTeamQueryError?.response?.status === 403;
|
||||
|
||||
const isSingleAdmin = courseTeamUsers.filter((user) => user.role === USER_ROLES.admin).length === 1;
|
||||
|
||||
const handleOpenInfoModal = (type: ModalType, email: string) => {
|
||||
setCurrentEmail(email);
|
||||
setModalType(type);
|
||||
openInfoModal();
|
||||
};
|
||||
|
||||
const handleAddUserSubmit = (body: { email: string }) => {
|
||||
const { email } = body;
|
||||
const isUserContains = courseTeamUsers.some((user) => user.email === email);
|
||||
|
||||
if (isUserContains) {
|
||||
handleOpenInfoModal(MODAL_TYPES.warning, email);
|
||||
return;
|
||||
}
|
||||
|
||||
addUserMutation.mutateAsync(email).then(() => {
|
||||
hideForm();
|
||||
}).catch(() => {
|
||||
handleOpenInfoModal(MODAL_TYPES.error, email);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteUserSubmit = () => {
|
||||
deleteUserMutation.mutate(currentEmail);
|
||||
closeInfoModal();
|
||||
};
|
||||
|
||||
const handleChangeRoleUserSubmit = (email: string, role: string) => {
|
||||
editUserRoleMutation.mutate({ email, role });
|
||||
};
|
||||
|
||||
const handleOpenDeleteModal = (email: string) => {
|
||||
handleOpenInfoModal(MODAL_TYPES.delete, email);
|
||||
};
|
||||
|
||||
const getErrorMessage = () => {
|
||||
const errorObject = addUserMutation.error ?? editUserRoleMutation.error ?? deleteUserMutation.error;
|
||||
// @ts-ignore
|
||||
return errorObject?.response?.data?.error ?? intl.formatMessage(messages.unknownError);
|
||||
};
|
||||
|
||||
return {
|
||||
modalType,
|
||||
courseName: courseDetails?.name ?? '',
|
||||
currentEmail,
|
||||
courseTeamUsers,
|
||||
currentUserEmail,
|
||||
errorMessage: getErrorMessage(),
|
||||
isLoading: isLoadingCourseTeamStatus,
|
||||
isLoadingDenied: courseTeamStatusIsDenied,
|
||||
isSingleAdmin,
|
||||
isFormVisible,
|
||||
isAllowActions,
|
||||
isInfoModalOpen,
|
||||
isOwnershipHint,
|
||||
isQueryPending: addUserMutation.isPending || deleteUserMutation.isPending || editUserRoleMutation.isPending,
|
||||
isShowAddTeamMember: courseTeamUsers.length === 1 && isAllowActions,
|
||||
isShowInitialSidebar: !courseTeamUsers.length && !isFormVisible,
|
||||
isShowUserFilledSidebar: Boolean(courseTeamUsers?.length) || isFormVisible,
|
||||
openForm,
|
||||
hideForm,
|
||||
closeInfoModal,
|
||||
handleAddUserSubmit,
|
||||
handleOpenInfoModal,
|
||||
handleOpenDeleteModal,
|
||||
handleDeleteUserSubmit,
|
||||
handleChangeRoleUserSubmit,
|
||||
};
|
||||
};
|
||||
|
||||
export { useCourseTeam };
|
||||
@@ -13,6 +13,11 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-team.button.new-team-member',
|
||||
defaultMessage: 'New team member',
|
||||
},
|
||||
unknownError: {
|
||||
id: 'course-authoring.course-team.error.unknown',
|
||||
defaultMessage: 'An unexpected error occurred. Please try again.',
|
||||
description: 'Fallback error message shown when the API returns an error in an unexpected format.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -25,6 +25,7 @@ import { getClipboardUrl } from '@src/generic/data/api';
|
||||
import { IframeProvider } from '@src/generic/hooks/context/iFrameContext';
|
||||
import { getDownstreamApiUrl } from '@src/generic/unlink-modal/data/api';
|
||||
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
|
||||
import { mockContentData } from '@src/content-tags-drawer/data/api.mocks';
|
||||
import {
|
||||
mockContentLibrary,
|
||||
mockGetContentLibraryV2List,
|
||||
@@ -89,6 +90,7 @@ mockContentSearchConfig.applyMock();
|
||||
mockContentLibrary.applyMock();
|
||||
mockGetContentLibraryV2List.applyMock();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
mockContentData.applyMock();
|
||||
|
||||
const {
|
||||
block_id: id,
|
||||
@@ -120,10 +122,10 @@ jest.mock('@src/studio-home/hooks', () => ({
|
||||
* This can be used to mimic events like deletion or other actions
|
||||
* sent from Backbone or other sources via postMessage.
|
||||
*
|
||||
* @param {string} type - The type of the message event (e.g., 'deleteXBlock').
|
||||
* @param {Object} payload - The payload data for the message event.
|
||||
* @param type - The type of the message event (e.g., 'deleteXBlock').
|
||||
* @param payload - The payload data for the message event.
|
||||
*/
|
||||
function simulatePostMessageEvent(type, payload) {
|
||||
function simulatePostMessageEvent(type: string, payload?: object) {
|
||||
const messageEvent = new MessageEvent('message', {
|
||||
data: { type, payload },
|
||||
});
|
||||
@@ -329,7 +331,7 @@ describe('<CourseUnit />', () => {
|
||||
expect(iframe).toHaveAttribute(
|
||||
'aria-label',
|
||||
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
|
||||
.replace('{xblockCount}', courseVerticalChildrenMock.children.length),
|
||||
.replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()),
|
||||
);
|
||||
|
||||
simulatePostMessageEvent(messageTypes.deleteXBlock, {
|
||||
@@ -420,7 +422,7 @@ describe('<CourseUnit />', () => {
|
||||
)).toHaveAttribute(
|
||||
'aria-label',
|
||||
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
|
||||
.replace('{xblockCount}', updatedCourseVerticalChildren.length),
|
||||
.replace('{xblockCount}', updatedCourseVerticalChildren.length.toString()),
|
||||
);
|
||||
// after removing the xblock, the sidebar status changes to Draft (unpublished changes)
|
||||
expect(await screen.findByText(
|
||||
@@ -483,10 +485,7 @@ describe('<CourseUnit />', () => {
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl({
|
||||
parent_locator: blockId,
|
||||
duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id,
|
||||
}))
|
||||
.onPost(postXBlockBaseApiUrl())
|
||||
.replyOnce(200, { locator: '1234567890' });
|
||||
|
||||
const updatedCourseVerticalChildren = [
|
||||
@@ -518,7 +517,7 @@ describe('<CourseUnit />', () => {
|
||||
expect(iframe).toHaveAttribute(
|
||||
'aria-label',
|
||||
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
|
||||
.replace('{xblockCount}', courseVerticalChildrenMock.children.length),
|
||||
.replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()),
|
||||
);
|
||||
|
||||
simulatePostMessageEvent(messageTypes.duplicateXBlock, {
|
||||
@@ -564,7 +563,7 @@ describe('<CourseUnit />', () => {
|
||||
expect(xblockIframe).toHaveAttribute(
|
||||
'aria-label',
|
||||
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
|
||||
.replace('{xblockCount}', updatedCourseVerticalChildren.length),
|
||||
.replace('{xblockCount}', updatedCourseVerticalChildren.length.toString()),
|
||||
);
|
||||
|
||||
// after duplicate the xblock, the sidebar status changes to Draft (unpublished changes)
|
||||
@@ -614,16 +613,14 @@ describe('<CourseUnit />', () => {
|
||||
it('checks courseUnit title changing when edit query is successfully', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RootWrapper />);
|
||||
let editTitleButton = null;
|
||||
let titleEditField = null;
|
||||
const newDisplayName = `${unitDisplayName} new`;
|
||||
|
||||
axiosMock
|
||||
.onPost(getXBlockBaseApiUrl(blockId, {
|
||||
.onPost(getXBlockBaseApiUrl(blockId), {
|
||||
metadata: {
|
||||
display_name: newDisplayName,
|
||||
},
|
||||
}))
|
||||
})
|
||||
.reply(200, { dummy: 'value' });
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
@@ -632,7 +629,6 @@ describe('<CourseUnit />', () => {
|
||||
xblock_info: {
|
||||
...courseSectionVerticalMock.xblock_info,
|
||||
metadata: {
|
||||
...courseSectionVerticalMock.xblock_info.metadata,
|
||||
display_name: newDisplayName,
|
||||
},
|
||||
},
|
||||
@@ -651,15 +647,14 @@ describe('<CourseUnit />', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const unitHeaderTitle = screen.getByTestId('unit-header-title');
|
||||
editTitleButton = within(unitHeaderTitle)
|
||||
.getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage });
|
||||
titleEditField = within(unitHeaderTitle)
|
||||
.queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
|
||||
});
|
||||
const unitHeaderTitle = await screen.findByTestId('unit-header-title');
|
||||
const editTitleButton = within(unitHeaderTitle)
|
||||
.getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage });
|
||||
let titleEditField = within(unitHeaderTitle)
|
||||
.queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
|
||||
expect(titleEditField).not.toBeInTheDocument();
|
||||
await user.click(editTitleButton);
|
||||
|
||||
titleEditField = screen.getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
|
||||
|
||||
await user.clear(titleEditField);
|
||||
@@ -678,7 +673,7 @@ describe('<CourseUnit />', () => {
|
||||
const user = userEvent.setup();
|
||||
const { courseKey, locator } = courseCreateXblockMock;
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId }))
|
||||
.onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId })
|
||||
.reply(500, {});
|
||||
render(<RootWrapper />);
|
||||
|
||||
@@ -693,7 +688,7 @@ describe('<CourseUnit />', () => {
|
||||
it('handle creating Problem xblock and showing editor modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl({ type: 'problem', category: 'problem', parentLocator: blockId }))
|
||||
.onPost(postXBlockBaseApiUrl(), { type: 'problem', category: 'problem', parent_locator: blockId })
|
||||
.reply(200, courseCreateXblockMock);
|
||||
render(<RootWrapper />);
|
||||
|
||||
@@ -757,17 +752,17 @@ describe('<CourseUnit />', () => {
|
||||
it('correct addition of a new course unit after click on the "Add new unit" button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RootWrapper />);
|
||||
let units = null;
|
||||
let units: HTMLElement[] | null = null;
|
||||
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
|
||||
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
|
||||
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
|
||||
...updatedAncestorsChild.child_info.children,
|
||||
...updatedAncestorsChild.child_info!.children,
|
||||
courseUnitMock,
|
||||
]);
|
||||
|
||||
await waitFor(async () => {
|
||||
units = screen.getAllByTestId('course-unit-btn');
|
||||
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
|
||||
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info!.children;
|
||||
expect(units).toHaveLength(courseUnits.length);
|
||||
});
|
||||
|
||||
@@ -786,7 +781,7 @@ describe('<CourseUnit />', () => {
|
||||
const addNewUnitBtn = screen.getByRole('button', { name: courseSequenceMessages.newUnitBtnText.defaultMessage });
|
||||
units = screen.getAllByTestId('course-unit-btn');
|
||||
const updatedCourseUnits = updatedCourseSectionVerticalData
|
||||
.xblock_info.ancestor_info.ancestors[0].child_info.children;
|
||||
.xblock_info.ancestor_info.ancestors[0].child_info!.children;
|
||||
|
||||
await user.click(addNewUnitBtn);
|
||||
expect(units.length).toEqual(updatedCourseUnits.length);
|
||||
@@ -824,18 +819,18 @@ describe('<CourseUnit />', () => {
|
||||
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
|
||||
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
|
||||
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
|
||||
...updatedAncestorsChild.child_info.children,
|
||||
...updatedAncestorsChild.child_info!.children,
|
||||
courseUnitMock,
|
||||
]);
|
||||
|
||||
const newDisplayName = `${unitDisplayName} new`;
|
||||
|
||||
axiosMock
|
||||
.onPost(getXBlockBaseApiUrl(blockId, {
|
||||
.onPost(getXBlockBaseApiUrl(blockId), {
|
||||
metadata: {
|
||||
display_name: newDisplayName,
|
||||
},
|
||||
}))
|
||||
})
|
||||
.reply(200, { dummy: 'value' })
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
@@ -843,7 +838,6 @@ describe('<CourseUnit />', () => {
|
||||
xblock_info: {
|
||||
...courseSectionVerticalMock.xblock_info,
|
||||
metadata: {
|
||||
...courseSectionVerticalMock.xblock_info.metadata,
|
||||
display_name: newDisplayName,
|
||||
},
|
||||
},
|
||||
@@ -877,7 +871,7 @@ describe('<CourseUnit />', () => {
|
||||
const waffleSpy = mockWaffleFlags({ useVideoGalleryFlow: true });
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId }))
|
||||
.onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId })
|
||||
.reply(200, courseCreateXblockMock);
|
||||
render(<RootWrapper />);
|
||||
|
||||
@@ -948,12 +942,13 @@ describe('<CourseUnit />', () => {
|
||||
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
|
||||
)).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /add video to your course/i, hidden: true })).toBeInTheDocument();
|
||||
|
||||
waffleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles creating Video xblock and showing editor modal', async () => {
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId }))
|
||||
.onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId })
|
||||
.reply(200, courseCreateXblockMock);
|
||||
const user = userEvent.setup();
|
||||
render(<RootWrapper />);
|
||||
@@ -1158,11 +1153,12 @@ describe('<CourseUnit />', () => {
|
||||
const modalNotification = screen.getByRole('dialog');
|
||||
const makeVisibilityBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalMakeVisibilityActionButtonText.defaultMessage });
|
||||
const cancelBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalMakeVisibilityCancelButtonText.defaultMessage });
|
||||
const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalMakeVisibilityTitle.defaultMessage, class: 'pgn__modal-title' });
|
||||
const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalMakeVisibilityTitle.defaultMessage });
|
||||
|
||||
expect(makeVisibilityBtn).toBeInTheDocument();
|
||||
expect(cancelBtn).toBeInTheDocument();
|
||||
expect(headingElement).toBeInTheDocument();
|
||||
expect(headingElement).toHaveClass('pgn__modal-title');
|
||||
expect(within(modalNotification)
|
||||
.getByText(unitInfoMessages.modalMakeVisibilityDescription.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
@@ -1250,8 +1246,9 @@ describe('<CourseUnit />', () => {
|
||||
.getByText(unitInfoMessages.modalDiscardUnitChangesDescription.defaultMessage)).toBeInTheDocument();
|
||||
expect(within(modalNotification)
|
||||
.getByText(unitInfoMessages.modalDiscardUnitChangesCancelButtonText.defaultMessage)).toBeInTheDocument();
|
||||
const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalDiscardUnitChangesTitle.defaultMessage, class: 'pgn__modal-title' });
|
||||
const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalDiscardUnitChangesTitle.defaultMessage });
|
||||
expect(headingElement).toBeInTheDocument();
|
||||
expect(headingElement).toHaveClass('pgn__modal-title');
|
||||
const actionBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalDiscardUnitChangesActionButtonText.defaultMessage });
|
||||
expect(actionBtn).toBeInTheDocument();
|
||||
|
||||
@@ -1396,17 +1393,17 @@ describe('<CourseUnit />', () => {
|
||||
await user.click(screen.getByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
|
||||
await user.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
|
||||
|
||||
let units = null;
|
||||
let units: HTMLElement[] | null = null;
|
||||
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
|
||||
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
|
||||
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
|
||||
...updatedAncestorsChild.child_info.children,
|
||||
...updatedAncestorsChild.child_info!.children,
|
||||
courseUnitMock,
|
||||
]);
|
||||
|
||||
await waitFor(() => {
|
||||
units = screen.getAllByTestId('course-unit-btn');
|
||||
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
|
||||
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info!.children;
|
||||
expect(units).toHaveLength(courseUnits.length);
|
||||
});
|
||||
|
||||
@@ -1423,7 +1420,7 @@ describe('<CourseUnit />', () => {
|
||||
|
||||
units = screen.getAllByTestId('course-unit-btn');
|
||||
const updatedCourseUnits = updatedCourseSectionVerticalData
|
||||
.xblock_info.ancestor_info.ancestors[0].child_info.children;
|
||||
.xblock_info.ancestor_info.ancestors[0].child_info!.children;
|
||||
|
||||
expect(units.length).toEqual(updatedCourseUnits.length);
|
||||
expect(mockedUsedNavigate).toHaveBeenCalled();
|
||||
@@ -1457,7 +1454,7 @@ describe('<CourseUnit />', () => {
|
||||
expect(iframe).toHaveAttribute(
|
||||
'aria-label',
|
||||
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
|
||||
.replace('{xblockCount}', courseVerticalChildrenMock.children.length),
|
||||
.replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()),
|
||||
);
|
||||
|
||||
simulatePostMessageEvent(messageTypes.copyXBlock, {
|
||||
@@ -1493,7 +1490,7 @@ describe('<CourseUnit />', () => {
|
||||
expect(iframe).toHaveAttribute(
|
||||
'aria-label',
|
||||
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
|
||||
.replace('{xblockCount}', updatedCourseVerticalChildren.length),
|
||||
.replace('{xblockCount}', updatedCourseVerticalChildren.length.toString()),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1520,12 +1517,12 @@ describe('<CourseUnit />', () => {
|
||||
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
|
||||
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
|
||||
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
|
||||
...updatedAncestorsChild.child_info.children,
|
||||
...updatedAncestorsChild.child_info!.children,
|
||||
courseUnitMock,
|
||||
]);
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl(postXBlockBody))
|
||||
.onPost(postXBlockBaseApiUrl(), postXBlockBody)
|
||||
.reply(200, clipboardMockResponse);
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
@@ -1573,12 +1570,12 @@ describe('<CourseUnit />', () => {
|
||||
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
|
||||
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
|
||||
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
|
||||
...updatedAncestorsChild.child_info.children,
|
||||
...updatedAncestorsChild.child_info!.children,
|
||||
courseUnitMock,
|
||||
]);
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl(postXBlockBody))
|
||||
.onPost(postXBlockBaseApiUrl(), postXBlockBody)
|
||||
.reply(200, clipboardMockResponse);
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
@@ -1628,12 +1625,12 @@ describe('<CourseUnit />', () => {
|
||||
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
|
||||
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
|
||||
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
|
||||
...updatedAncestorsChild.child_info.children,
|
||||
...updatedAncestorsChild.child_info!.children,
|
||||
courseUnitMock,
|
||||
]);
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl(postXBlockBody))
|
||||
.onPost(postXBlockBaseApiUrl(), postXBlockBody)
|
||||
.reply(200, clipboardMockResponse);
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
@@ -1806,7 +1803,7 @@ describe('<CourseUnit />', () => {
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
const currentUnit = currentSubsection.child_info.children[0];
|
||||
const currentUnit = currentSubsection.child_info!.children[0];
|
||||
const currentUnitItemBtn = screen.getByRole('button', {
|
||||
name: `${currentUnit.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`,
|
||||
});
|
||||
@@ -1846,13 +1843,13 @@ describe('<CourseUnit />', () => {
|
||||
|
||||
simulatePostMessageEvent(messageTypes.rollbackMovedXBlock, { locator: requestData.sourceLocator });
|
||||
|
||||
const dismissButton = screen.queryByRole('button', {
|
||||
const dismissButton = screen.getByRole('button', {
|
||||
name: /dismiss/i, hidden: true,
|
||||
});
|
||||
const undoButton = screen.queryByRole('button', {
|
||||
const undoButton = screen.getByRole('button', {
|
||||
name: messages.undoMoveButton.defaultMessage, hidden: true,
|
||||
});
|
||||
const newLocationButton = screen.queryByRole('button', {
|
||||
const newLocationButton = screen.getByRole('button', {
|
||||
name: messages.newLocationButton.defaultMessage, hidden: true,
|
||||
});
|
||||
|
||||
@@ -1892,7 +1889,7 @@ describe('<CourseUnit />', () => {
|
||||
callbackFn: requestData.callbackFn,
|
||||
}), store.dispatch);
|
||||
|
||||
const newLocationButton = screen.queryByRole('button', {
|
||||
const newLocationButton = screen.getByRole('button', {
|
||||
name: messages.newLocationButton.defaultMessage, hidden: true,
|
||||
});
|
||||
await user.click(newLocationButton);
|
||||
@@ -2246,6 +2243,7 @@ describe('<CourseUnit />', () => {
|
||||
];
|
||||
|
||||
sidebarContent.forEach(({ query, type, name }) => {
|
||||
// @ts-ignore
|
||||
expect(type ? query(type, { name }) : query(name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -2271,10 +2269,10 @@ describe('<CourseUnit />', () => {
|
||||
targetChild.block_id = 'block-v1:OpenedX+L153+3T2023+type@html+block@test123original';
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl({
|
||||
.onPost(postXBlockBaseApiUrl(), {
|
||||
parent_locator: blockId,
|
||||
duplicate_source_locator: targetChild.block_id,
|
||||
}))
|
||||
})
|
||||
.replyOnce(200, { locator: '1234567890' });
|
||||
|
||||
axiosMock
|
||||
@@ -2939,9 +2937,10 @@ describe('<CourseUnit />', () => {
|
||||
|
||||
await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
|
||||
|
||||
simulatePostMessageEvent(messageTypes.openManageTags, { contentId: blockId });
|
||||
simulatePostMessageEvent(messageTypes.openManageTags, { contentId: mockContentData.textXBlock });
|
||||
|
||||
await screen.findByText('Align');
|
||||
await screen.findByText(mockContentData.textXBlockData.displayName);
|
||||
});
|
||||
|
||||
describe('Add sidebar', () => {
|
||||
@@ -2970,7 +2969,7 @@ describe('<CourseUnit />', () => {
|
||||
// The Meilisearch client-side API uses fetch, not Axios.
|
||||
fetchMock.mockReset();
|
||||
fetchMock.post(searchEndpoint, (_url, req) => {
|
||||
const requestData = JSON.parse((req.body ?? ''));
|
||||
const requestData = JSON.parse((req.body ?? '') as string);
|
||||
const query = requestData?.queries[0]?.q ?? '';
|
||||
// We have to replace the query (search keywords) in the mock results with the actual query,
|
||||
// because otherwise Instantsearch will update the UI and change the query,
|
||||
@@ -3244,4 +3243,21 @@ describe('<CourseUnit />', () => {
|
||||
expect(sidebarToggle).toBeInTheDocument();
|
||||
expect(within(sidebarToggle).queryByRole('button', { name: 'Add' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the component info sidebar on postMessage event', async () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_UNIT_PAGE_NEW_DESIGN: 'true',
|
||||
});
|
||||
|
||||
render(<RootWrapper />);
|
||||
|
||||
await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
|
||||
|
||||
simulatePostMessageEvent(messageTypes.xblockSelected, {
|
||||
contentId: mockContentData.textXBlock,
|
||||
});
|
||||
|
||||
await screen.findByText(mockContentData.textXBlockData.displayName);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ import MoveModal from './move-modal';
|
||||
import IframePreviewLibraryXBlockChanges from './preview-changes';
|
||||
import CourseUnitHeaderActionsSlot from '../plugin-slots/CourseUnitHeaderActionsSlot';
|
||||
import { UnitSidebarProvider } from './unit-sidebar/UnitSidebarContext';
|
||||
import { UnitSidebarPagesProvider } from './unit-sidebar/UnitSidebarPagesContext';
|
||||
import { UNIT_VISIBILITY_STATES } from './constants';
|
||||
import { isUnitPageNewDesignEnabled } from './utils';
|
||||
|
||||
@@ -242,178 +243,180 @@ const CourseUnit = () => {
|
||||
|
||||
return (
|
||||
<UnitSidebarProvider readOnly={readOnly}>
|
||||
<Container fluid className="course-unit px-4">
|
||||
<section className="course-unit-container mb-4 mt-5">
|
||||
<TransitionReplace>
|
||||
{movedXBlockParams.isSuccess ? (
|
||||
<AlertMessage
|
||||
key="xblock-moved-alert"
|
||||
data-testid="xblock-moved-alert"
|
||||
show={movedXBlockParams.isSuccess}
|
||||
variant="success"
|
||||
icon={CheckCircleIcon}
|
||||
title={movedXBlockParams.isUndo
|
||||
? intl.formatMessage(messages.alertMoveCancelTitle)
|
||||
: intl.formatMessage(messages.alertMoveSuccessTitle)}
|
||||
description={movedXBlockParams.isUndo
|
||||
? intl.formatMessage(messages.alertMoveCancelDescription, { title: movedXBlockParams.title })
|
||||
: intl.formatMessage(messages.alertMoveSuccessDescription, { title: movedXBlockParams.title })}
|
||||
aria-hidden={movedXBlockParams.isSuccess}
|
||||
dismissible
|
||||
actions={movedXBlockParams.isUndo ? undefined : [
|
||||
<Button
|
||||
onClick={handleRollbackMovedXBlock}
|
||||
key="xblock-moved-alert-undo-move-button"
|
||||
>
|
||||
{intl.formatMessage(messages.undoMoveButton)}
|
||||
</Button>,
|
||||
<Button
|
||||
onClick={handleNavigateToTargetUnit}
|
||||
key="xblock-moved-alert-new-location-button"
|
||||
>
|
||||
{intl.formatMessage(messages.newLocationButton)}
|
||||
</Button>,
|
||||
]}
|
||||
onClose={handleCloseXBlockMovedAlert}
|
||||
/>
|
||||
) : null}
|
||||
</TransitionReplace>
|
||||
{courseUnit.upstreamInfo?.upstreamLink && (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(
|
||||
messages.alertLibraryUnitReadOnlyText,
|
||||
{
|
||||
link: (
|
||||
<Alert.Link
|
||||
href={courseUnit.upstreamInfo.upstreamLink}
|
||||
>
|
||||
{intl.formatMessage(messages.alertLibraryUnitReadOnlyLinkText)}
|
||||
</Alert.Link>
|
||||
),
|
||||
},
|
||||
)}
|
||||
variant="info"
|
||||
/>
|
||||
)}
|
||||
<SubHeader
|
||||
hideBorder
|
||||
title={(
|
||||
<HeaderTitle
|
||||
unitTitle={unitTitle}
|
||||
isTitleEditFormOpen={isTitleEditFormOpen}
|
||||
handleTitleEdit={handleTitleEdit}
|
||||
handleTitleEditSubmit={handleTitleEditSubmit}
|
||||
handleConfigureSubmit={handleConfigureSubmit}
|
||||
/>
|
||||
)}
|
||||
breadcrumbs={(
|
||||
<Breadcrumbs
|
||||
courseId={courseId}
|
||||
parentUnitId={sequenceId}
|
||||
/>
|
||||
)}
|
||||
headerActions={(
|
||||
<CourseUnitHeaderActionsSlot
|
||||
category={unitCategory}
|
||||
headerNavigationsActions={headerNavigationsActions}
|
||||
unitTitle={unitTitle}
|
||||
verticalBlocks={courseVerticalChildren.children}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="unit-header-status-bar h5 mt-2 mb-4 font-weight-normal">
|
||||
{isUnitPageNewDesignEnabled() && isUnitVerticalType && (
|
||||
<StatusBar courseUnit={courseUnit} />
|
||||
)}
|
||||
</div>
|
||||
{isUnitVerticalType && (
|
||||
<Sequence
|
||||
courseId={courseId}
|
||||
sequenceId={sequenceId}
|
||||
unitId={blockId}
|
||||
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
|
||||
showPasteUnit={showPasteUnit}
|
||||
/>
|
||||
)}
|
||||
<div className="d-flex align-items-baseline">
|
||||
<div className="flex-fill">
|
||||
{currentlyVisibleToStudents && (
|
||||
<UnitSidebarPagesProvider>
|
||||
<Container fluid className="course-unit px-4">
|
||||
<section className="course-unit-container mb-4 mt-5">
|
||||
<TransitionReplace>
|
||||
{movedXBlockParams.isSuccess ? (
|
||||
<AlertMessage
|
||||
className="course-unit__alert"
|
||||
title={intl.formatMessage(messages.alertUnpublishedVersion)}
|
||||
variant="warning"
|
||||
icon={WarningIcon}
|
||||
key="xblock-moved-alert"
|
||||
data-testid="xblock-moved-alert"
|
||||
show={movedXBlockParams.isSuccess}
|
||||
variant="success"
|
||||
icon={CheckCircleIcon}
|
||||
title={movedXBlockParams.isUndo
|
||||
? intl.formatMessage(messages.alertMoveCancelTitle)
|
||||
: intl.formatMessage(messages.alertMoveSuccessTitle)}
|
||||
description={movedXBlockParams.isUndo
|
||||
? intl.formatMessage(messages.alertMoveCancelDescription, { title: movedXBlockParams.title })
|
||||
: intl.formatMessage(messages.alertMoveSuccessDescription, { title: movedXBlockParams.title })}
|
||||
aria-hidden={movedXBlockParams.isSuccess}
|
||||
dismissible
|
||||
actions={movedXBlockParams.isUndo ? undefined : [
|
||||
<Button
|
||||
onClick={handleRollbackMovedXBlock}
|
||||
key="xblock-moved-alert-undo-move-button"
|
||||
>
|
||||
{intl.formatMessage(messages.undoMoveButton)}
|
||||
</Button>,
|
||||
<Button
|
||||
onClick={handleNavigateToTargetUnit}
|
||||
key="xblock-moved-alert-new-location-button"
|
||||
>
|
||||
{intl.formatMessage(messages.newLocationButton)}
|
||||
</Button>,
|
||||
]}
|
||||
onClose={handleCloseXBlockMovedAlert}
|
||||
/>
|
||||
)}
|
||||
{staticFileNotices && (
|
||||
<PasteNotificationAlert
|
||||
staticFileNotices={staticFileNotices}
|
||||
courseId={courseId}
|
||||
/>
|
||||
)}
|
||||
{blockId && (
|
||||
<XBlockContainerIframe
|
||||
courseId={courseId}
|
||||
blockId={blockId}
|
||||
isUnitVerticalType={isUnitVerticalType}
|
||||
unitXBlockActions={unitXBlockActions}
|
||||
courseVerticalChildren={courseVerticalChildren.children}
|
||||
) : null}
|
||||
</TransitionReplace>
|
||||
{courseUnit.upstreamInfo?.upstreamLink && (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(
|
||||
messages.alertLibraryUnitReadOnlyText,
|
||||
{
|
||||
link: (
|
||||
<Alert.Link
|
||||
href={courseUnit.upstreamInfo.upstreamLink}
|
||||
>
|
||||
{intl.formatMessage(messages.alertLibraryUnitReadOnlyLinkText)}
|
||||
</Alert.Link>
|
||||
),
|
||||
},
|
||||
)}
|
||||
variant="info"
|
||||
/>
|
||||
)}
|
||||
<SubHeader
|
||||
hideBorder
|
||||
title={(
|
||||
<HeaderTitle
|
||||
unitTitle={unitTitle}
|
||||
isTitleEditFormOpen={isTitleEditFormOpen}
|
||||
handleTitleEdit={handleTitleEdit}
|
||||
handleTitleEditSubmit={handleTitleEditSubmit}
|
||||
handleConfigureSubmit={handleConfigureSubmit}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && sharedClipboardData
|
||||
&& /* istanbul ignore next */ (
|
||||
<PasteComponent
|
||||
clipboardData={sharedClipboardData}
|
||||
onClick={
|
||||
/* istanbul ignore next */
|
||||
() => handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId })
|
||||
}
|
||||
text={intl.formatMessage(messages.pasteButtonText)}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && blockId && (
|
||||
<AddComponent
|
||||
parentLocator={blockId}
|
||||
isSplitTestType={isSplitTestType}
|
||||
isUnitVerticalType={isUnitVerticalType}
|
||||
isProblemBankType={isProblemBankType}
|
||||
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
|
||||
addComponentTemplateData={addComponentTemplateData}
|
||||
breadcrumbs={(
|
||||
<Breadcrumbs
|
||||
courseId={courseId}
|
||||
parentUnitId={sequenceId}
|
||||
/>
|
||||
)}
|
||||
<MoveModal
|
||||
isOpenModal={isMoveModalOpen}
|
||||
openModal={openMoveModal}
|
||||
closeModal={closeMoveModal}
|
||||
courseId={courseId}
|
||||
/>
|
||||
<IframePreviewLibraryXBlockChanges />
|
||||
headerActions={(
|
||||
<CourseUnitHeaderActionsSlot
|
||||
category={unitCategory}
|
||||
headerNavigationsActions={headerNavigationsActions}
|
||||
unitTitle={unitTitle}
|
||||
verticalBlocks={courseVerticalChildren.children}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="unit-header-status-bar h5 mt-2 mb-4 font-weight-normal">
|
||||
{isUnitPageNewDesignEnabled() && isUnitVerticalType && (
|
||||
<StatusBar courseUnit={courseUnit} />
|
||||
)}
|
||||
</div>
|
||||
{!isUnitLegacyLibraryType && (
|
||||
<CourseAuthoringUnitSidebarSlot
|
||||
{isUnitVerticalType && (
|
||||
<Sequence
|
||||
courseId={courseId}
|
||||
blockId={blockId}
|
||||
unitTitle={unitTitle}
|
||||
xBlocks={courseVerticalChildren.children}
|
||||
readOnly={readOnly}
|
||||
isUnitVerticalType={isUnitVerticalType}
|
||||
isSplitTestType={isSplitTestType}
|
||||
sequenceId={sequenceId}
|
||||
unitId={blockId}
|
||||
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
|
||||
showPasteUnit={showPasteUnit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</Container>
|
||||
<div className="alert-toast">
|
||||
<ProcessingNotification
|
||||
isShow={isShowProcessingNotification}
|
||||
title={processingNotificationTitle}
|
||||
/>
|
||||
<SavingErrorAlert
|
||||
savingStatus={savingStatus}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex align-items-baseline">
|
||||
<div className="flex-fill">
|
||||
{currentlyVisibleToStudents && (
|
||||
<AlertMessage
|
||||
className="course-unit__alert"
|
||||
title={intl.formatMessage(messages.alertUnpublishedVersion)}
|
||||
variant="warning"
|
||||
icon={WarningIcon}
|
||||
/>
|
||||
)}
|
||||
{staticFileNotices && (
|
||||
<PasteNotificationAlert
|
||||
staticFileNotices={staticFileNotices}
|
||||
courseId={courseId}
|
||||
/>
|
||||
)}
|
||||
{blockId && (
|
||||
<XBlockContainerIframe
|
||||
courseId={courseId}
|
||||
blockId={blockId}
|
||||
isUnitVerticalType={isUnitVerticalType}
|
||||
unitXBlockActions={unitXBlockActions}
|
||||
courseVerticalChildren={courseVerticalChildren.children}
|
||||
handleConfigureSubmit={handleConfigureSubmit}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && sharedClipboardData
|
||||
&& /* istanbul ignore next */ (
|
||||
<PasteComponent
|
||||
clipboardData={sharedClipboardData}
|
||||
onClick={
|
||||
/* istanbul ignore next */
|
||||
() => handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId })
|
||||
}
|
||||
text={intl.formatMessage(messages.pasteButtonText)}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && blockId && (
|
||||
<AddComponent
|
||||
parentLocator={blockId}
|
||||
isSplitTestType={isSplitTestType}
|
||||
isUnitVerticalType={isUnitVerticalType}
|
||||
isProblemBankType={isProblemBankType}
|
||||
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
|
||||
addComponentTemplateData={addComponentTemplateData}
|
||||
/>
|
||||
)}
|
||||
<MoveModal
|
||||
isOpenModal={isMoveModalOpen}
|
||||
openModal={openMoveModal}
|
||||
closeModal={closeMoveModal}
|
||||
courseId={courseId}
|
||||
/>
|
||||
<IframePreviewLibraryXBlockChanges />
|
||||
</div>
|
||||
{!isUnitLegacyLibraryType && (
|
||||
<CourseAuthoringUnitSidebarSlot
|
||||
courseId={courseId}
|
||||
blockId={blockId}
|
||||
unitTitle={unitTitle}
|
||||
xBlocks={courseVerticalChildren.children}
|
||||
readOnly={readOnly}
|
||||
isUnitVerticalType={isUnitVerticalType}
|
||||
isSplitTestType={isSplitTestType}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</Container>
|
||||
<div className="alert-toast">
|
||||
<ProcessingNotification
|
||||
isShow={isShowProcessingNotification}
|
||||
title={processingNotificationTitle}
|
||||
/>
|
||||
<SavingErrorAlert
|
||||
savingStatus={savingStatus}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</div>
|
||||
</UnitSidebarPagesProvider>
|
||||
</UnitSidebarProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ const expectedCourseItemDataWithUnit = {
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
id: 'unitId',
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@1',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -64,7 +64,12 @@ describe('SubsectionUnitRedirect', () => {
|
||||
// Confirm redirection by checking the final URL
|
||||
const mockNavigate = screen.getByTestId('mock-navigate');
|
||||
expect(mockNavigate).toBeInTheDocument();
|
||||
expect(mockNavigate).toHaveAttribute('data-to', `/course/${courseId}/container/unitId`);
|
||||
expect(mockNavigate).toHaveAttribute(
|
||||
'data-to',
|
||||
`/course/${courseId}/container/${encodeURIComponent(
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@1',
|
||||
)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,14 +2,15 @@ import { LoadingSpinner } from '@src/generic/Loading';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
|
||||
import { useParams, Navigate } from 'react-router-dom';
|
||||
import { useCourseItemData } from '../course-outline/data/apiHooks';
|
||||
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { XBlock } from '@src/data/types';
|
||||
|
||||
const SubsectionUnitRedirect = () => {
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
let { subsectionId } = useParams();
|
||||
// if the call is made via the click on breadcrumbs the re won't be courseId available
|
||||
// in such cases the page should redirect to the 1st unit of he subsection
|
||||
const { data: courseItemData, isLoading } = useCourseItemData(subsectionId);
|
||||
const { data: courseItemData, isLoading } = useCourseItemData<XBlock>(subsectionId);
|
||||
let firstUnitId = courseItemData?.childInfo?.children?.[0]?.id;
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -79,4 +79,7 @@ export const messageTypes = {
|
||||
copyXBlockLegacy: 'copyXBlockLegacy',
|
||||
hideProcessingNotification: 'hideProcessingNotification',
|
||||
handleRedirectToXBlockEditPage: 'handleRedirectToXBlockEditPage',
|
||||
xblockSelected: 'xblockSelected',
|
||||
clearSelection: 'clearSelection',
|
||||
selectXblock: 'selectXBlock',
|
||||
};
|
||||
|
||||
@@ -4,7 +4,8 @@ import classNames from 'classnames';
|
||||
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import Loading from '../../generic/Loading';
|
||||
import Loading from '@src/generic/Loading';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import SequenceNavigation from './sequence-navigation/SequenceNavigation';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('<HeaderNavigations />', () => {
|
||||
expect(infoButton).toBeInTheDocument();
|
||||
await user.click(infoButton);
|
||||
|
||||
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('info');
|
||||
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('info', null);
|
||||
});
|
||||
|
||||
it('click Add button should open add sidebar', async () => {
|
||||
@@ -107,6 +107,6 @@ describe('<HeaderNavigations />', () => {
|
||||
expect(addButton).toBeInTheDocument();
|
||||
await user.click(addButton);
|
||||
|
||||
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('add');
|
||||
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('add', null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ const HeaderNavigations = ({ headerNavigationsActions, category }: HeaderNavigat
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={InfoOutline}
|
||||
onClick={() => setCurrentPageKey('info')}
|
||||
onClick={() => setCurrentPageKey('info', null)}
|
||||
>
|
||||
{intl.formatMessage(messages.infoButton)}
|
||||
</Button>
|
||||
@@ -60,7 +60,7 @@ const HeaderNavigations = ({ headerNavigationsActions, category }: HeaderNavigat
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={Add}
|
||||
onClick={() => setCurrentPageKey('add')}
|
||||
onClick={() => setCurrentPageKey('add', null)}
|
||||
>
|
||||
{intl.formatMessage(messages.addButton)}
|
||||
</Button>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import ConfigureModal from '@src/generic/configure-modal/ConfigureModal';
|
||||
import { COURSE_BLOCK_NAMES } from '@src/constants';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { ConfigureUnitData } from '@src/course-outline/data/types';
|
||||
import { getCourseUnitData } from '../data/selectors';
|
||||
import { updateQueryPendingStatus } from '../data/slice';
|
||||
import messages from './messages';
|
||||
@@ -21,13 +22,7 @@ type HeaderTitleProps = {
|
||||
isTitleEditFormOpen: boolean;
|
||||
handleTitleEdit: () => void;
|
||||
handleTitleEditSubmit: (title: string) => void;
|
||||
handleConfigureSubmit: (
|
||||
id: string,
|
||||
isVisible: boolean,
|
||||
groupAccess: boolean,
|
||||
isDiscussionEnabled: boolean,
|
||||
closeModalFn: (value: boolean) => void
|
||||
) => void;
|
||||
handleConfigureSubmit: (variables: ConfigureUnitData & { closeModalFn?: () => void }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -56,14 +51,12 @@ const HeaderTitle = ({
|
||||
COURSE_BLOCK_NAMES.component.id,
|
||||
].includes(currentItemData.category);
|
||||
|
||||
const onConfigureSubmit = (...arg) => {
|
||||
handleConfigureSubmit(
|
||||
currentItemData.id,
|
||||
arg[0],
|
||||
arg[1],
|
||||
arg[2],
|
||||
closeConfigureModal,
|
||||
);
|
||||
const onConfigureSubmit = (variables: Omit<ConfigureUnitData, 'unitId'>) => {
|
||||
handleConfigureSubmit({
|
||||
...variables,
|
||||
unitId: currentItemData.id,
|
||||
closeModalFn: closeConfigureModal,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useEventListener } from '@src/generic/hooks';
|
||||
import { useIframe } from '@src/generic/hooks/context/hooks';
|
||||
import { COURSE_BLOCK_NAMES, iframeMessageTypes } from '@src/constants';
|
||||
|
||||
import { ConfigureUnitData } from '@src/course-outline/data/types';
|
||||
import { messageTypes, PUBLISH_TYPES } from './constants';
|
||||
import {
|
||||
createNewCourseXBlock,
|
||||
@@ -73,7 +74,8 @@ export const useCourseUnit = ({
|
||||
const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useClipboard(canEdit);
|
||||
const { canPasteComponent } = courseVerticalChildren;
|
||||
const { displayName: unitTitle, category: unitCategory } = xblockInfo;
|
||||
const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id;
|
||||
const sequenceId = courseUnit.ancestorInfo?.ancestors[0]?.id;
|
||||
const sectionId = courseUnit.ancestorInfo?.ancestors[1]?.id;
|
||||
const isUnitVerticalType = unitCategory === COURSE_BLOCK_NAMES.vertical.id;
|
||||
const isUnitLegacyLibraryType = unitCategory === COURSE_BLOCK_NAMES.libraryContent.id;
|
||||
const isSplitTestType = unitCategory === COURSE_BLOCK_NAMES.splitTest.id;
|
||||
@@ -98,19 +100,17 @@ export const useCourseUnit = ({
|
||||
dispatch(changeEditTitleFormOpen(!isTitleEditFormOpen));
|
||||
};
|
||||
|
||||
const handleConfigureSubmit = (id, isVisible, groupAccess, isDiscussionEnabled, closeModalFn) => {
|
||||
const handleConfigureSubmit = (variables: ConfigureUnitData & { closeModalFn?: () => void }) => {
|
||||
dispatch(editCourseUnitVisibilityAndData(
|
||||
id,
|
||||
variables.unitId,
|
||||
PUBLISH_TYPES.republish,
|
||||
isVisible,
|
||||
groupAccess,
|
||||
isDiscussionEnabled,
|
||||
() => sendMessageToIframe(messageTypes.completeManageXBlockAccess, { locator: id }),
|
||||
variables.isVisibleToStaffOnly,
|
||||
variables.groupAccess,
|
||||
variables.discussionEnabled,
|
||||
() => sendMessageToIframe(messageTypes.completeManageXBlockAccess, { locator: variables.unitId }),
|
||||
blockId,
|
||||
));
|
||||
if (typeof closeModalFn === 'function') {
|
||||
closeModalFn();
|
||||
}
|
||||
variables.closeModalFn?.();
|
||||
};
|
||||
|
||||
const handleTitleEditSubmit = (displayName) => {
|
||||
@@ -139,18 +139,26 @@ export const useCourseUnit = ({
|
||||
const { mutateAsync: unlinkDownstream } = useUnlinkDownstream();
|
||||
|
||||
const unitXBlockActions = {
|
||||
handleDelete: (XBlockId) => {
|
||||
dispatch(deleteUnitItemQuery(blockId, XBlockId, sendMessageToIframe));
|
||||
handleDelete: async (XBlockId: string) => {
|
||||
// oxlint-disable-next-line typescript-eslint(await-thenable)
|
||||
await dispatch(deleteUnitItemQuery(blockId, XBlockId, sendMessageToIframe));
|
||||
},
|
||||
handleDuplicate: (XBlockId) => {
|
||||
handleDuplicate: (XBlockId: string) => {
|
||||
dispatch(duplicateUnitItemQuery(
|
||||
blockId,
|
||||
XBlockId,
|
||||
(courseKey, locator) => sendMessageToIframe(messageTypes.completeXBlockDuplicating, { courseKey, locator }),
|
||||
(courseKey: string, locator: string) => sendMessageToIframe(
|
||||
messageTypes.completeXBlockDuplicating,
|
||||
{ courseKey, locator },
|
||||
),
|
||||
));
|
||||
},
|
||||
handleUnlink: async (XBlockId) => {
|
||||
await unlinkDownstream(XBlockId);
|
||||
handleUnlink: async (XBlockId: string) => {
|
||||
await unlinkDownstream({
|
||||
downstreamBlockId: XBlockId,
|
||||
subsectionId: sequenceId,
|
||||
sectionId,
|
||||
});
|
||||
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen, initializeMocks } from '@src/testUtils';
|
||||
import { IframeProvider } from '@src/generic/hooks/context/iFrameContext';
|
||||
import { UnitAlignSidebar } from './UnitAlignSidebar';
|
||||
import { UnitSidebarProvider } from './UnitSidebarContext';
|
||||
|
||||
@@ -16,9 +17,11 @@ jest.mock('react-router-dom', () => ({
|
||||
}));
|
||||
|
||||
const renderComponent = () => render(
|
||||
<UnitSidebarProvider readOnly={false}>
|
||||
<UnitAlignSidebar />
|
||||
</UnitSidebarProvider>,
|
||||
<IframeProvider>
|
||||
<UnitSidebarProvider readOnly={false}>
|
||||
<UnitAlignSidebar />
|
||||
</UnitSidebarProvider>
|
||||
</IframeProvider>,
|
||||
);
|
||||
|
||||
describe('OutlineAlignSidebar', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useContentData } from '@src/content-tags-drawer/data/apiHooks';
|
||||
import { AlignSidebar } from '@src/generic/sidebar/AlignSidebar';
|
||||
import { useCallback } from 'react';
|
||||
import { useUnitSidebarContext } from './UnitSidebarContext';
|
||||
|
||||
/**
|
||||
@@ -9,18 +9,19 @@ import { useUnitSidebarContext } from './UnitSidebarContext';
|
||||
*/
|
||||
export const UnitAlignSidebar = () => {
|
||||
const { blockId } = useParams();
|
||||
const { currentComponentId, setCurrentPageKey } = useUnitSidebarContext();
|
||||
const { selectedComponentId, setCurrentPageKey } = useUnitSidebarContext();
|
||||
|
||||
const sidebarContentId = currentComponentId || blockId;
|
||||
const sidebarContentId = selectedComponentId || blockId;
|
||||
|
||||
const {
|
||||
data: contentData,
|
||||
} = useContentData(sidebarContentId);
|
||||
|
||||
// istanbul ignore next
|
||||
const handleBack = useCallback(() => {
|
||||
// Set the align sidebar without current component to back
|
||||
// to unit align sidebar.
|
||||
setCurrentPageKey('align');
|
||||
setCurrentPageKey('align', null);
|
||||
}, [setCurrentPageKey]);
|
||||
|
||||
return (
|
||||
@@ -30,7 +31,7 @@ export const UnitAlignSidebar = () => {
|
||||
? contentData.displayName : ''
|
||||
}
|
||||
contentId={sidebarContentId || ''}
|
||||
onBackBtnClick={currentComponentId ? handleBack : undefined}
|
||||
onBackBtnClick={selectedComponentId ? handleBack : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Sidebar } from '@src/generic/sidebar';
|
||||
|
||||
import LegacySidebar, { LegacySidebarProps } from '../legacy-sidebar';
|
||||
import { UnitSidebarPageKeys, useUnitSidebarContext } from './UnitSidebarContext';
|
||||
import { isUnitPageNewDesignEnabled } from '../utils';
|
||||
import { useUnitSidebarPages } from './sidebarPages';
|
||||
import { UnitSidebarPageKeys, useUnitSidebarContext } from './UnitSidebarContext';
|
||||
import { useUnitSidebarPagesContext } from './UnitSidebarPagesContext';
|
||||
|
||||
export type UnitSidebarProps = {
|
||||
legacySidebarProps: LegacySidebarProps,
|
||||
@@ -22,7 +23,7 @@ export const UnitSidebar = ({
|
||||
toggle,
|
||||
} = useUnitSidebarContext();
|
||||
|
||||
const sidebarPages = useUnitSidebarPages();
|
||||
const sidebarPages = useUnitSidebarPagesContext();
|
||||
|
||||
if (!isUnitPageNewDesignEnabled()) {
|
||||
return (
|
||||
|
||||
@@ -4,19 +4,19 @@ import {
|
||||
import { SidebarPage } from '@src/generic/sidebar';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { useStateWithUrlSearchParam } from '@src/hooks';
|
||||
import { useIframe } from '@src/generic/hooks/context/hooks';
|
||||
import { messageTypes } from '../constants';
|
||||
|
||||
export type UnitSidebarPageKeys = 'info' | 'add' | 'align';
|
||||
export type UnitSidebarPages = Record<UnitSidebarPageKeys, SidebarPage>;
|
||||
|
||||
interface UnitSidebarContextData {
|
||||
currentPageKey: UnitSidebarPageKeys;
|
||||
setCurrentPageKey: (pageKey: UnitSidebarPageKeys, componentId?: string) => void;
|
||||
setCurrentPageKey: (pageKey: UnitSidebarPageKeys, componentId?: string | null) => void;
|
||||
currentTabKey?: string;
|
||||
setCurrentTabKey: (tabKey: string | undefined) => void;
|
||||
// The Id of the component used in the current sidebar page
|
||||
// The component is not necessarily selected to open a selected sidebar.
|
||||
// Example: Align sidebar
|
||||
currentComponentId?: string;
|
||||
selectedComponentId?: string;
|
||||
setSelectedComponentId: (componentId?: string) => void;
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
toggle: () => void;
|
||||
@@ -32,6 +32,7 @@ export const UnitSidebarProvider = ({
|
||||
children?: React.ReactNode,
|
||||
readOnly: boolean,
|
||||
}) => {
|
||||
const { sendMessageToIframe } = useIframe();
|
||||
const [currentPageKey, setCurrentPageKeyState] = useStateWithUrlSearchParam<UnitSidebarPageKeys>(
|
||||
'info',
|
||||
'sidebar',
|
||||
@@ -39,16 +40,23 @@ export const UnitSidebarProvider = ({
|
||||
(value: UnitSidebarPageKeys) => value,
|
||||
);
|
||||
const [currentTabKey, setCurrentTabKey] = useState<string>();
|
||||
const [currentComponentId, setCurrentComponentId] = useState<string>();
|
||||
const [selectedComponentId, setSelectedComponentId] = useState<string>();
|
||||
const [isOpen, open,, toggle] = useToggle(true);
|
||||
|
||||
const setCurrentPageKey = useCallback(/* istanbul ignore next */ (
|
||||
pageKey: UnitSidebarPageKeys,
|
||||
componentId?: string,
|
||||
componentId?: string | null,
|
||||
) => {
|
||||
// Reset tab
|
||||
setCurrentTabKey(undefined);
|
||||
setCurrentPageKeyState(pageKey);
|
||||
setCurrentComponentId(componentId);
|
||||
if (componentId !== undefined) {
|
||||
setSelectedComponentId(componentId === null ? undefined : componentId);
|
||||
}
|
||||
if (componentId === null) {
|
||||
// Deselect the component
|
||||
sendMessageToIframe(messageTypes.clearSelection, null);
|
||||
}
|
||||
open();
|
||||
}, [open]);
|
||||
|
||||
@@ -58,7 +66,8 @@ export const UnitSidebarProvider = ({
|
||||
setCurrentPageKey,
|
||||
currentTabKey,
|
||||
setCurrentTabKey,
|
||||
currentComponentId,
|
||||
selectedComponentId,
|
||||
setSelectedComponentId,
|
||||
isOpen,
|
||||
open,
|
||||
toggle,
|
||||
@@ -69,7 +78,8 @@ export const UnitSidebarProvider = ({
|
||||
setCurrentPageKey,
|
||||
currentTabKey,
|
||||
setCurrentTabKey,
|
||||
currentComponentId,
|
||||
selectedComponentId,
|
||||
setSelectedComponentId,
|
||||
isOpen,
|
||||
open,
|
||||
toggle,
|
||||
@@ -84,9 +94,11 @@ export const UnitSidebarProvider = ({
|
||||
);
|
||||
};
|
||||
|
||||
export function useUnitSidebarContext(): UnitSidebarContextData {
|
||||
export function useUnitSidebarContext(raiseError?: true): UnitSidebarContextData;
|
||||
export function useUnitSidebarContext(raiseError?: boolean): UnitSidebarContextData | undefined;
|
||||
export function useUnitSidebarContext(raiseError: boolean = true): UnitSidebarContextData | undefined {
|
||||
const ctx = useContext(UnitSidebarContext);
|
||||
if (ctx === undefined) {
|
||||
if (ctx === undefined && raiseError) {
|
||||
/* istanbul ignore next */
|
||||
throw new Error('useUnitSidebarContext() was used in a component without a <UnitSidebarProvider> ancestor.');
|
||||
}
|
||||
|
||||
104
src/course-unit/unit-sidebar/UnitSidebarPagesContext.tsx
Normal file
104
src/course-unit/unit-sidebar/UnitSidebarPagesContext.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { createContext, useContext, useMemo } from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Info, Plus, Tag } from '@openedx/paragon/icons';
|
||||
|
||||
import type { SidebarPage } from '@src/generic/sidebar';
|
||||
|
||||
import { InfoSidebar } from './unit-info/InfoSidebar';
|
||||
import { AddSidebar } from './AddSidebar';
|
||||
import { UnitAlignSidebar } from './UnitAlignSidebar';
|
||||
import { useUnitSidebarContext } from './UnitSidebarContext';
|
||||
import messages from './messages';
|
||||
|
||||
export type UnitSidebarPages = {
|
||||
info: SidebarPage;
|
||||
add?: SidebarPage;
|
||||
align?: SidebarPage;
|
||||
};
|
||||
|
||||
const getUnitSidebarPages = (readOnly: boolean, hasComponentSelected: boolean) => {
|
||||
const showAlignSidebar = getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true';
|
||||
|
||||
return {
|
||||
info: {
|
||||
component: InfoSidebar,
|
||||
icon: Info,
|
||||
title: messages.sidebarButtonInfo,
|
||||
},
|
||||
...(!readOnly && {
|
||||
add: {
|
||||
component: AddSidebar,
|
||||
icon: Plus,
|
||||
title: messages.sidebarButtonAdd,
|
||||
disabled: hasComponentSelected,
|
||||
tooltip: hasComponentSelected ? messages.sidebarDisabledAddTooltip : undefined,
|
||||
},
|
||||
}),
|
||||
...(showAlignSidebar && {
|
||||
align: {
|
||||
component: UnitAlignSidebar,
|
||||
icon: Tag,
|
||||
title: messages.sidebarButtonAlign,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Context for the Unit Sidebar Pages.
|
||||
*
|
||||
* This could be used in plugins to add new pages to the sidebar.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```tsx
|
||||
* export function UnitOutlineSidebarWrapper(
|
||||
* { component, pluginProps }: { component: React.ReactNode, pluginProps: UnitOutlineAspectsPageProps},
|
||||
* ) {
|
||||
* const sidebarPages = useUnitSidebarPagesContext();
|
||||
* const AnalyticsPage = useCallback(() => <UnitOutlineAspectsPage {...pluginProps} />, [pluginProps]);
|
||||
*
|
||||
* const overridedPages = useMemo(() => ({
|
||||
* ...sidebarPages,
|
||||
* analytics: {
|
||||
* component: AnalyticsPage,
|
||||
* icon: AutoGraph,
|
||||
* title: messages.analyticsLabel,
|
||||
* },
|
||||
* }), [sidebarPages, AnalyticsPage]);
|
||||
*
|
||||
* return (
|
||||
* <UnitSidebarPagesContext.Provider value={overridedPages}>
|
||||
* {component}
|
||||
* </UnitSidebarPagesContext.Provider>
|
||||
* );
|
||||
* }
|
||||
*/
|
||||
export const UnitSidebarPagesContext = createContext<UnitSidebarPages | undefined>(undefined);
|
||||
|
||||
type UnitSidebarPagesProviderProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const UnitSidebarPagesProvider = ({ children }: UnitSidebarPagesProviderProps) => {
|
||||
const { readOnly, selectedComponentId } = useUnitSidebarContext();
|
||||
|
||||
const hasComponentSelected = selectedComponentId !== undefined;
|
||||
|
||||
const sidebarPages = useMemo(
|
||||
() => getUnitSidebarPages(readOnly, hasComponentSelected),
|
||||
[readOnly, hasComponentSelected],
|
||||
);
|
||||
|
||||
return (
|
||||
<UnitSidebarPagesContext.Provider value={sidebarPages}>
|
||||
{children}
|
||||
</UnitSidebarPagesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUnitSidebarPagesContext = (): UnitSidebarPages => {
|
||||
const ctx = useContext(UnitSidebarPagesContext);
|
||||
if (ctx === undefined) { throw new Error('useUnitSidebarPages must be used within an UnitSidebarPagesProvider'); }
|
||||
return ctx;
|
||||
};
|
||||
@@ -71,6 +71,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Advanced Blocks',
|
||||
description: 'Title for the add advanced blocks page in the unit sidebar',
|
||||
},
|
||||
sidebarDisabledAddTooltip: {
|
||||
id: 'course-authoring.course-unit.sidebar.add.disabled.tooltip',
|
||||
defaultMessage: 'Cannot add content to components',
|
||||
description: 'Tooltip for the Add sidebar when is disabled.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Info, Tag, Plus } from '@openedx/paragon/icons';
|
||||
import { SidebarPage } from '@src/generic/sidebar';
|
||||
import messages from './messages';
|
||||
import { UnitInfoSidebar } from './unit-info/UnitInfoSidebar';
|
||||
import { UnitAlignSidebar } from './UnitAlignSidebar';
|
||||
import { AddSidebar } from './AddSidebar';
|
||||
import { useUnitSidebarContext } from './UnitSidebarContext';
|
||||
|
||||
export type UnitSidebarPages = {
|
||||
info: SidebarPage;
|
||||
align?: SidebarPage;
|
||||
add?: SidebarPage;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sidebar pages for the unit sidebar
|
||||
*
|
||||
* This has been separated from the context to avoid a cyclical import
|
||||
* if you want to use the context in the sidebar pages.
|
||||
*/
|
||||
export const useUnitSidebarPages = (): UnitSidebarPages => {
|
||||
const showAlignSidebar = getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true';
|
||||
const { readOnly } = useUnitSidebarContext();
|
||||
return {
|
||||
info: {
|
||||
component: UnitInfoSidebar,
|
||||
icon: Info,
|
||||
title: messages.sidebarButtonInfo,
|
||||
},
|
||||
...(!readOnly && {
|
||||
add: {
|
||||
component: AddSidebar,
|
||||
icon: Plus,
|
||||
title: messages.sidebarButtonAdd,
|
||||
},
|
||||
}),
|
||||
...(showAlignSidebar && {
|
||||
align: {
|
||||
component: UnitAlignSidebar,
|
||||
icon: Tag,
|
||||
title: messages.sidebarButtonAlign,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Tag } from '@openedx/paragon/icons';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar';
|
||||
import { ContentTagsSnippet } from '@src/content-tags-drawer';
|
||||
import { useContentData } from '@src/content-tags-drawer/data/apiHooks';
|
||||
import type { XBlockData } from '@src/content-tags-drawer/data/types';
|
||||
import { getItemIcon } from '@src/generic/block-type-utils';
|
||||
import { useIframe } from '@src/generic/hooks/context/hooks';
|
||||
import { messageTypes } from '@src/course-unit/constants';
|
||||
import { LibraryReferenceCard } from '@src/generic/library-reference-card/LibraryReferenceCard';
|
||||
import { getCourseUnitData } from '@src/course-unit/data/selectors';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks';
|
||||
|
||||
import { useUnitSidebarContext } from '../UnitSidebarContext';
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* Sidebar info for components
|
||||
*/
|
||||
export const ComponentInfoSidebar = () => {
|
||||
const intl = useIntl();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { sendMessageToIframe } = useIframe();
|
||||
const unitData = useSelector(getCourseUnitData);
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
const sectionId = unitData?.ancestorInfo?.ancestors?.find(
|
||||
(ancestor) => ancestor.category === 'chapter',
|
||||
)?.id;
|
||||
|
||||
const {
|
||||
selectedComponentId,
|
||||
setCurrentPageKey,
|
||||
} = useUnitSidebarContext();
|
||||
|
||||
const { data: contentData } = useContentData(selectedComponentId) as { data: XBlockData | undefined };
|
||||
|
||||
// istanbul ignore next
|
||||
const handleBack = () => {
|
||||
setCurrentPageKey('info', null);
|
||||
};
|
||||
|
||||
const handleGoToParent = (containerId: string) => {
|
||||
navigate(`/course/${courseId}?show=${encodeURIComponent(containerId)}`);
|
||||
};
|
||||
|
||||
// istanbul ignore next
|
||||
const handlePostChange = () => {
|
||||
sendMessageToIframe(messageTypes.refreshXBlock, null);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(sectionId),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarTitle
|
||||
title={contentData?.displayName || ''}
|
||||
icon={getItemIcon(contentData?.category || '')}
|
||||
onBackBtnClick={handleBack}
|
||||
/>
|
||||
<LibraryReferenceCard
|
||||
itemId={selectedComponentId}
|
||||
sectionId={sectionId}
|
||||
goToParent={handleGoToParent}
|
||||
postChange={handlePostChange}
|
||||
/>
|
||||
<SidebarContent>
|
||||
<SidebarSection
|
||||
title={intl.formatMessage(messages.sidebarSectionTaxonomies)}
|
||||
icon={Tag}
|
||||
>
|
||||
<ContentTagsSnippet contentId={selectedComponentId || ''} />
|
||||
</SidebarSection>
|
||||
</SidebarContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
19
src/course-unit/unit-sidebar/unit-info/InfoSidebar.tsx
Normal file
19
src/course-unit/unit-sidebar/unit-info/InfoSidebar.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useUnitSidebarContext } from '../UnitSidebarContext';
|
||||
import { ComponentInfoSidebar } from './ComponentInfoSidebar';
|
||||
import { UnitInfoSidebar } from './UnitInfoSidebar';
|
||||
|
||||
/**
|
||||
* Main component to render the Info Sidebar in the unit page
|
||||
*
|
||||
* Depending of the selected component, this can render
|
||||
* the unit infor sidebar or the component info sidebar
|
||||
*/
|
||||
export const InfoSidebar = () => {
|
||||
const { selectedComponentId } = useUnitSidebarContext();
|
||||
|
||||
if (selectedComponentId) {
|
||||
return <ComponentInfoSidebar />;
|
||||
}
|
||||
|
||||
return <UnitInfoSidebar />;
|
||||
};
|
||||
@@ -189,7 +189,7 @@ const UnitInfoSettings = () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Main component that renders the tabs of the info sidebar.
|
||||
* Component that renders the tabs of the info sidebar for units.
|
||||
*/
|
||||
export const UnitInfoSidebar = () => {
|
||||
const intl = useIntl();
|
||||
@@ -205,7 +205,7 @@ export const UnitInfoSidebar = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<SidebarTitle
|
||||
title={currentItemData.displayName}
|
||||
icon={getItemIcon('unit')}
|
||||
@@ -233,6 +233,6 @@ export const UnitInfoSidebar = () => {
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ export type UseMessageHandlersTypes = {
|
||||
handleShowProcessingNotification: (variant: string) => void;
|
||||
handleHideProcessingNotification: () => void;
|
||||
handleRefreshIframe: () => void;
|
||||
handleXBlockSelected: (id: string) => void;
|
||||
};
|
||||
|
||||
export type MessageHandlersTypes = Record<string, (payload: any) => void>;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { useClipboard } from '../../../generic/clipboard';
|
||||
import { handleResponseErrors } from '../../../generic/saving-error-alert';
|
||||
import { NOTIFICATION_MESSAGES } from '../../../constants';
|
||||
import { updateSavingStatus } from '../../data/slice';
|
||||
import { messageTypes } from '../../constants';
|
||||
import { useClipboard } from '@src/generic/clipboard';
|
||||
import { messageTypes } from '@src/course-unit/constants';
|
||||
import { handleResponseErrors } from '@src/generic/saving-error-alert';
|
||||
import { updateSavingStatus } from '@src/course-unit/data/slice';
|
||||
import { NOTIFICATION_MESSAGES } from '@src/constants';
|
||||
|
||||
import { MessageHandlersTypes, UseMessageHandlersTypes } from './types';
|
||||
|
||||
/**
|
||||
@@ -32,6 +33,7 @@ export const useMessageHandlers = ({
|
||||
handleHideProcessingNotification,
|
||||
handleEditXBlock,
|
||||
handleRefreshIframe,
|
||||
handleXBlockSelected,
|
||||
}: UseMessageHandlersTypes): MessageHandlersTypes => {
|
||||
const { copyToClipboard } = useClipboard();
|
||||
|
||||
@@ -45,7 +47,7 @@ export const useMessageHandlers = ({
|
||||
[messageTypes.scrollToXBlock]: debounce(({ scrollOffset }) => handleScrollToXBlock(scrollOffset), 1000),
|
||||
[messageTypes.toggleCourseXBlockDropdown]: ({
|
||||
courseXBlockDropdownHeight,
|
||||
}: { courseXBlockDropdownHeight: number }) => setIframeOffset(courseXBlockDropdownHeight),
|
||||
}) => setIframeOffset(courseXBlockDropdownHeight),
|
||||
[messageTypes.editXBlock]: ({ id }) => handleShowLegacyEditXBlockModal(id),
|
||||
[messageTypes.closeXBlockEditorModal]: handleCloseLegacyEditorXBlockModal,
|
||||
[messageTypes.saveEditedXBlockData]: handleSaveEditedXBlockData,
|
||||
@@ -63,6 +65,7 @@ export const useMessageHandlers = ({
|
||||
payload.type,
|
||||
payload.locator,
|
||||
),
|
||||
[messageTypes.xblockSelected]: ({ contentId }) => handleXBlockSelected(contentId),
|
||||
}), [
|
||||
courseId,
|
||||
handleDeleteXBlock,
|
||||
@@ -71,5 +74,6 @@ export const useMessageHandlers = ({
|
||||
handleManageXBlockAccess,
|
||||
handleScrollToXBlock,
|
||||
copyToClipboard,
|
||||
handleXBlockSelected,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,9 @@ import { UnlinkModal } from '@src/generic/unlink-modal';
|
||||
import VideoSelectorPage from '@src/editors/VideoSelectorPage';
|
||||
import EditorPage from '@src/editors/EditorPage';
|
||||
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { ConfigureUnitData } from '@src/course-outline/data/types';
|
||||
import { AccessManagedXBlockDataTypes } from '@src/data/types';
|
||||
import { messageTypes } from '../constants';
|
||||
import {
|
||||
fetchCourseSectionVerticalData,
|
||||
@@ -36,7 +39,6 @@ import {
|
||||
import messages from './messages';
|
||||
import {
|
||||
XBlockContainerIframeProps,
|
||||
AccessManagedXBlockDataTypes,
|
||||
} from './types';
|
||||
import { formatAccessManagedXBlockData, getIframeUrl, getLegacyEditModalUrl } from './utils';
|
||||
import { useUnitSidebarContext } from '../unit-sidebar/UnitSidebarContext';
|
||||
@@ -53,19 +55,25 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const { setCurrentPageKey } = useUnitSidebarContext();
|
||||
const {
|
||||
setCurrentPageKey,
|
||||
setSelectedComponentId,
|
||||
} = useUnitSidebarContext(!readonly) || {};
|
||||
|
||||
// Useful to reload iframe
|
||||
const [iframeKey, setIframeKey] = useState(0);
|
||||
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
|
||||
const [isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal] = useToggle(false);
|
||||
const { isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal } = useCourseAuthoringContext();
|
||||
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
|
||||
const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle();
|
||||
const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle();
|
||||
const [blockType, setBlockType] = useState<string>('');
|
||||
const { useVideoGalleryFlow } = useWaffleFlags(courseId);
|
||||
const [newBlockId, setNewBlockId] = useState<string>('');
|
||||
const [accessManagedXBlockData, setAccessManagedXBlockData] = useState<AccessManagedXBlockDataTypes | {}>({});
|
||||
const [
|
||||
accessManagedXBlockData,
|
||||
setAccessManagedXBlockData,
|
||||
] = useState<AccessManagedXBlockDataTypes | undefined>(undefined);
|
||||
const [iframeOffset, setIframeOffset] = useState(0);
|
||||
const [deleteXBlockId, setDeleteXBlockId] = useState<string | null>(null);
|
||||
const [unlinkXBlockId, setUnlinkXBlockId] = useState<string | null>(null);
|
||||
@@ -115,7 +123,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
|
||||
const handleUnlinkXBlock = (usageId: string) => {
|
||||
setUnlinkXBlockId(usageId);
|
||||
openUnlinkModal();
|
||||
openUnlinkModal({});
|
||||
};
|
||||
|
||||
const handleManageXBlockAccess = (usageId: string) => {
|
||||
@@ -127,9 +135,10 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteSubmit = () => {
|
||||
const onDeleteSubmit = async () => {
|
||||
if (deleteXBlockId) {
|
||||
unitXBlockActions.handleDelete(deleteXBlockId);
|
||||
await unitXBlockActions.handleDelete(deleteXBlockId);
|
||||
setSelectedComponentId?.(undefined);
|
||||
closeDeleteModal();
|
||||
}
|
||||
};
|
||||
@@ -141,10 +150,14 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onManageXBlockAccessSubmit = (...args: any[]) => {
|
||||
const onManageXBlockAccessSubmit = (variables: Omit<ConfigureUnitData, 'unitId'>) => {
|
||||
if (configureXBlockId) {
|
||||
handleConfigureSubmit(configureXBlockId, ...args, closeConfigureModal);
|
||||
setAccessManagedXBlockData({});
|
||||
handleConfigureSubmit({
|
||||
unitId: configureXBlockId,
|
||||
...variables,
|
||||
closeModalFn: closeConfigureModal,
|
||||
});
|
||||
setAccessManagedXBlockData(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,7 +192,8 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
|
||||
const handleOpenManageTagsModal = (id: string) => {
|
||||
if (isUnitPageNewDesignEnabled()) {
|
||||
setCurrentPageKey('align', id);
|
||||
setCurrentPageKey?.('align', id);
|
||||
sendMessageToIframe(messageTypes.selectXblock, { locator: id });
|
||||
} else {
|
||||
// Legacy manage tags modal
|
||||
setConfigureXBlockId(id);
|
||||
@@ -204,6 +218,10 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
setIframeKey((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleXBlockSelected = (id) => {
|
||||
setCurrentPageKey?.('info', id);
|
||||
};
|
||||
|
||||
const messageHandlers = useMessageHandlers({
|
||||
courseId,
|
||||
dispatch,
|
||||
@@ -222,6 +240,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
handleHideProcessingNotification,
|
||||
handleEditXBlock,
|
||||
handleRefreshIframe,
|
||||
handleXBlockSelected,
|
||||
});
|
||||
|
||||
useIframeMessages(readonly ? {} : messageHandlers);
|
||||
@@ -277,19 +296,17 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(accessManagedXBlockData).length ? (
|
||||
<ConfigureModal
|
||||
isXBlockComponent
|
||||
isOpen={isConfigureModalOpen}
|
||||
onClose={() => {
|
||||
closeConfigureModal();
|
||||
setAccessManagedXBlockData({});
|
||||
}}
|
||||
onConfigureSubmit={onManageXBlockAccessSubmit}
|
||||
currentItemData={accessManagedXBlockData as AccessManagedXBlockDataTypes}
|
||||
isSelfPaced={false}
|
||||
/>
|
||||
) : null}
|
||||
<ConfigureModal
|
||||
isXBlockComponent
|
||||
isOpen={isConfigureModalOpen}
|
||||
onClose={() => {
|
||||
closeConfigureModal();
|
||||
setAccessManagedXBlockData(undefined);
|
||||
}}
|
||||
onConfigureSubmit={onManageXBlockAccessSubmit}
|
||||
currentItemData={accessManagedXBlockData}
|
||||
isSelfPaced={false}
|
||||
/>
|
||||
<iframe
|
||||
key={iframeKey}
|
||||
ref={iframeRef}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { UserPartitionInfoTypes, UserPartitionTypes, XBlockPrereqs } from '@src/data/types';
|
||||
import { ConfigureUnitData } from '@src/course-outline/data/types';
|
||||
import { UserPartitionTypes } from '@src/data/types';
|
||||
|
||||
export interface XBlockActionsTypes {
|
||||
canCopy: boolean;
|
||||
@@ -31,46 +32,11 @@ export interface XBlockContainerIframeProps {
|
||||
blockId: string;
|
||||
isUnitVerticalType: boolean,
|
||||
unitXBlockActions: {
|
||||
handleDelete: (XBlockId: string | null) => void;
|
||||
handleDelete: (XBlockId: string | null) => Promise<void> | void;
|
||||
handleDuplicate: (XBlockId: string | null) => void;
|
||||
handleUnlink: (XBlockId: string | null) => void;
|
||||
};
|
||||
courseVerticalChildren: Array<XBlockTypes>;
|
||||
handleConfigureSubmit: (XBlockId: string, ...args: any[]) => void;
|
||||
handleConfigureSubmit: (variables: ConfigureUnitData & { closeModalFn?: () => void }) => void;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export type AccessManagedXBlockDataTypes = {
|
||||
id: string;
|
||||
displayName?: string;
|
||||
start?: string;
|
||||
visibilityState?: string | boolean;
|
||||
blockType: string;
|
||||
due?: string;
|
||||
isTimeLimited?: boolean;
|
||||
defaultTimeLimitMinutes?: number;
|
||||
hideAfterDue?: boolean;
|
||||
showCorrectness?: string | boolean;
|
||||
courseGraders?: string[];
|
||||
category?: string;
|
||||
format?: string;
|
||||
userPartitionInfo?: UserPartitionInfoTypes;
|
||||
ancestorHasStaffLock?: boolean;
|
||||
isPrereq?: boolean;
|
||||
prereqs?: XBlockPrereqs[];
|
||||
prereq?: string;
|
||||
prereqMinScore?: number;
|
||||
prereqMinCompletion?: number;
|
||||
releasedToStudents?: boolean;
|
||||
wasExamEverLinkedWithExternal?: boolean;
|
||||
isProctoredExam?: boolean;
|
||||
isOnboardingExam?: boolean;
|
||||
isPracticeExam?: boolean;
|
||||
examReviewRules?: string;
|
||||
supportsOnboarding?: boolean;
|
||||
showReviewRules?: boolean;
|
||||
onlineProctoringRules?: string;
|
||||
discussionEnabled: boolean;
|
||||
};
|
||||
|
||||
export type FormattedAccessManagedXBlockDataTypes = Omit<AccessManagedXBlockDataTypes, 'discussionEnabled'>;
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { AccessManagedXBlockDataTypes } from '@src/data/types';
|
||||
|
||||
import { COURSE_BLOCK_NAMES } from '../../constants';
|
||||
import { FormattedAccessManagedXBlockDataTypes, XBlockTypes } from './types';
|
||||
import { XBlockTypes } from './types';
|
||||
|
||||
/**
|
||||
* Formats the XBlock data into a standardized structure for access management.
|
||||
*
|
||||
* @param {XBlockTypes} xblock - The XBlock object containing the original data.
|
||||
* @param {string} usageId - The unique identifier for the XBlock.
|
||||
*
|
||||
* @returns {FormattedAccessManagedXBlockDataTypes} - The formatted XBlock data, ready for access management operations.
|
||||
*/
|
||||
export const formatAccessManagedXBlockData = (
|
||||
xblock: XBlockTypes,
|
||||
usageId: string,
|
||||
): FormattedAccessManagedXBlockDataTypes => ({
|
||||
): AccessManagedXBlockDataTypes => ({
|
||||
category: COURSE_BLOCK_NAMES.component.id,
|
||||
displayName: xblock.name,
|
||||
userPartitionInfo: xblock.userPartitionInfo,
|
||||
showCorrectness: 'always',
|
||||
blockType: xblock.blockType,
|
||||
id: usageId,
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ const messages = defineMessages({
|
||||
},
|
||||
customPagesExplanationBody: {
|
||||
id: 'course-authoring.custom-pages.customPagesExplanation.body',
|
||||
defaultMessage: `You can create and edit custom pages to probide students with additional course content. For example, you can create
|
||||
defaultMessage: `You can create and edit custom pages to provide students with additional course content. For example, you can create
|
||||
pages for the grading policy, course slide, and a course calendar.`,
|
||||
},
|
||||
studentViewExplanationHeader: {
|
||||
|
||||
@@ -92,6 +92,7 @@ export const waffleFlagDefaults = {
|
||||
useNewGroupConfigurationsPage: true,
|
||||
useReactMarkdownEditor: true,
|
||||
useVideoGalleryFlow: false,
|
||||
enableAuthzCourseAuthoring: false,
|
||||
} as const;
|
||||
|
||||
export type WaffleFlagName = keyof typeof waffleFlagDefaults;
|
||||
|
||||
@@ -29,20 +29,20 @@ describe('useWaffleFlags', () => {
|
||||
axiosMock.onGet(getApiWaffleFlagsUrl()).reply(() => promise);
|
||||
|
||||
render(<FlagComponent />);
|
||||
expect(screen.getByLabelText('isLoading')).toHaveTextContent('loading');
|
||||
expect(screen.getByLabelText('isError')).toHaveTextContent('false');
|
||||
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('loading');
|
||||
expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
|
||||
// The default should be enabled, even before we hear back from the server:
|
||||
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
|
||||
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
|
||||
|
||||
// Then, the server responds with a new value:
|
||||
resolveResponse([200, { useNewCourseOutlinePage: false }]);
|
||||
|
||||
// Now, we're no longer loading and we have the new value:
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('isLoading')).toHaveTextContent('false');
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('false');
|
||||
});
|
||||
expect(screen.getByLabelText('isError')).toHaveTextContent('false');
|
||||
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
|
||||
expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
|
||||
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
|
||||
});
|
||||
|
||||
it('uses the default values if there\'s an error', async () => {
|
||||
@@ -53,20 +53,20 @@ describe('useWaffleFlags', () => {
|
||||
axiosMock.onGet(getApiWaffleFlagsUrl()).reply(() => promise);
|
||||
|
||||
render(<FlagComponent />);
|
||||
expect(screen.getByLabelText('isLoading')).toHaveTextContent('loading');
|
||||
expect(screen.getByLabelText('isError')).toHaveTextContent('false');
|
||||
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('loading');
|
||||
expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
|
||||
// The default should be enabled, even before we hear back from the server:
|
||||
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
|
||||
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
|
||||
|
||||
// Then, the server responds with an error
|
||||
resolveResponse([500, {}]);
|
||||
|
||||
// Now, we're no longer loading, we have an error state, and we still have the default value:
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('isLoading')).toHaveTextContent('false');
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('false');
|
||||
});
|
||||
expect(screen.getByLabelText('isError')).toHaveTextContent('error');
|
||||
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
|
||||
expect(await screen.findByLabelText('isError')).toHaveTextContent('error');
|
||||
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
|
||||
});
|
||||
|
||||
it('uses the global flag values while loading the course-specific flags', async () => {
|
||||
@@ -81,9 +81,9 @@ describe('useWaffleFlags', () => {
|
||||
|
||||
// Check the global flag:
|
||||
render(<FlagComponent />);
|
||||
await waitFor(() => {
|
||||
await waitFor(async () => {
|
||||
// Once it loads the flags from the server, the global 'false' value will override the default 'true':
|
||||
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
|
||||
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
|
||||
});
|
||||
|
||||
// Now check the course-specific flag:
|
||||
@@ -91,16 +91,16 @@ describe('useWaffleFlags', () => {
|
||||
render(<FlagComponent courseId={courseId} />);
|
||||
|
||||
// Now, the course-specific value is loading but in the meantime we use the global default:
|
||||
expect(screen.getByLabelText('isLoading')).toHaveTextContent('loading');
|
||||
expect(screen.getByLabelText('isError')).toHaveTextContent('false');
|
||||
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
|
||||
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('loading');
|
||||
expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
|
||||
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
|
||||
|
||||
// Now the server responds: the course-specific flag is ON:
|
||||
resolveResponse([200, { useNewCourseOutlinePage: true }]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('isLoading')).toHaveTextContent('false');
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('false');
|
||||
});
|
||||
expect(screen.getByLabelText('isError')).toHaveTextContent('false');
|
||||
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
|
||||
expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
|
||||
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,3 +130,38 @@ export const useCourseDetails = (courseId: string) => {
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a global state function for a query.
|
||||
*/
|
||||
export function createGlobalState<T>(
|
||||
queryKeyFn: (queryKeyArgs?: any) => unknown[],
|
||||
initialData: T | null = null,
|
||||
) {
|
||||
return (queryKeyArgs?: any) => {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = queryKeyFn(queryKeyArgs);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => Promise.resolve(initialData),
|
||||
refetchInterval: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
function setData(x: Partial<T>) {
|
||||
queryClient.setQueryData(queryKey, x);
|
||||
}
|
||||
|
||||
async function resetData() {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey,
|
||||
});
|
||||
}
|
||||
|
||||
return { data, setData, resetData };
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user