Compare commits
11 Commits
kshitij/ag
...
renovate/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31d690f03a | ||
|
|
1efd559786 | ||
|
|
df79861685 | ||
|
|
24e1c73f6b | ||
|
|
449af65d01 | ||
|
|
fce65c0215 | ||
|
|
abcef4a502 | ||
|
|
5c1cdcf01c | ||
|
|
4dccc12883 | ||
|
|
f0e735b3a1 | ||
|
|
091d9a1c3e |
4
.github/workflows/validate.yml
vendored
4
.github/workflows/validate.yml
vendored
@@ -17,7 +17,7 @@ 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/*.*
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
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
|
||||
|
||||
94
package-lock.json
generated
94
package-lock.json
generated
@@ -58,7 +58,7 @@
|
||||
"moment-shortformat": "^2.1.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-datepicker": "^8.10.0",
|
||||
"react-datepicker": "^9.0.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-helmet": "^6.1.0",
|
||||
@@ -95,7 +95,7 @@
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"oxlint": "^1.42.0",
|
||||
"oxlint-tsgolint": "^0.14.0",
|
||||
"oxlint-tsgolint": "^0.16.0",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"redux-mock-store": "^1.5.4"
|
||||
}
|
||||
@@ -2305,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",
|
||||
@@ -2336,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",
|
||||
@@ -5574,9 +5574,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/darwin-arm64": {
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.14.2.tgz",
|
||||
"integrity": "sha512-03WxIXguCXf1pTmoG2C6vqRcbrU9GaJCW6uTIiQdIQq4BrJnVWZv99KEUQQRkuHK78lOLa9g7B4K58NcVcB54g==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.16.0.tgz",
|
||||
"integrity": "sha512-WQt5lGwRPJBw7q2KNR0mSPDAaMmZmVvDlEEti96xLO7ONhyomQc6fBZxxwZ4qTFedjJnrHX94sFelZ4OKzS7UQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5588,9 +5588,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/darwin-x64": {
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.14.2.tgz",
|
||||
"integrity": "sha512-ksMLl1cIWz3Jw+U79BhyCPdvohZcJ/xAKri5bpT6oeEM2GVnQCHBk/KZKlYrd7hZUTxz0sLnnKHE11XFnLASNQ==",
|
||||
"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"
|
||||
],
|
||||
@@ -5602,9 +5602,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/linux-arm64": {
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.14.2.tgz",
|
||||
"integrity": "sha512-2BgR535w7GLxBCyQD5DR3dBzbAgiBbG5QX1kAEVzOmWxJhhGxt5lsHdHebRo7ilukYLpBDkerz0mbMErblghCQ==",
|
||||
"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"
|
||||
],
|
||||
@@ -5616,9 +5616,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/linux-x64": {
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.14.2.tgz",
|
||||
"integrity": "sha512-TUHFyVHfbbGtnTQZbUFgwvv3NzXBgzNLKdMUJw06thpiC7u5OW5qdk4yVXIC/xeVvdl3NAqTfcT4sA32aiMubg==",
|
||||
"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"
|
||||
],
|
||||
@@ -5630,9 +5630,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/win32-arm64": {
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.14.2.tgz",
|
||||
"integrity": "sha512-OfYHa/irfVggIFEC4TbawsI7Hwrttppv//sO/e00tu4b2QRga7+VHAwtCkSFWSr0+BsO4InRYVA0+pun5BinpQ==",
|
||||
"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"
|
||||
],
|
||||
@@ -5644,9 +5644,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/win32-x64": {
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.14.2.tgz",
|
||||
"integrity": "sha512-5gxwbWYE2pP+pzrO4SEeYvLk4N609eAe18rVXUx+en3qtHBkU8VM2jBmMcZdIHn+G05leu4pYvwAvw6tvT9VbA==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.16.0.tgz",
|
||||
"integrity": "sha512-1ufk8cgktXJuJZHKF63zCHAkaLMwZrEXnZ89H2y6NO85PtOXqu4zbdNl0VBpPP3fCUuUBu9RvNqMFiv0VsbXWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8978,9 +8978,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
||||
"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.cjs"
|
||||
@@ -9361,9 +9361,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001775",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz",
|
||||
"integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==",
|
||||
"version": "1.0.30001779",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz",
|
||||
"integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -17891,21 +17891,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oxlint-tsgolint": {
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.14.2.tgz",
|
||||
"integrity": "sha512-XJsFIQwnYJgXFlNDz2MncQMWYxwnfy4BCy73mdiFN/P13gEZrAfBU4Jmz2XXFf9UG0wPILdi7hYa6t0KmKQLhw==",
|
||||
"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.14.2",
|
||||
"@oxlint-tsgolint/darwin-x64": "0.14.2",
|
||||
"@oxlint-tsgolint/linux-arm64": "0.14.2",
|
||||
"@oxlint-tsgolint/linux-x64": "0.14.2",
|
||||
"@oxlint-tsgolint/win32-arm64": "0.14.2",
|
||||
"@oxlint-tsgolint/win32-x64": "0.14.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": {
|
||||
@@ -19710,9 +19710,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-datepicker": {
|
||||
"version": "8.10.0",
|
||||
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.10.0.tgz",
|
||||
"integrity": "sha512-JIXuA+g+qP3c4MVJpx24o7n1gnv3WV/8A/D6964HucY1FlSEc30+ITPNUfbKZXYHl5rruCtxYCwi2lzn7gaz7g==",
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-9.1.0.tgz",
|
||||
"integrity": "sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.15",
|
||||
@@ -19720,8 +19720,14 @@
|
||||
"date-fns": "^4.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"date-fns-tz": "^3.0.0",
|
||||
"react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"date-fns-tz": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-dev-utils": {
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
"moment-shortformat": "^2.1.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-datepicker": "^8.10.0",
|
||||
"react-datepicker": "^9.0.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-helmet": "^6.1.0",
|
||||
@@ -119,7 +119,7 @@
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"oxlint": "^1.42.0",
|
||||
"oxlint-tsgolint": "^0.14.0",
|
||||
"oxlint-tsgolint": "^0.16.0",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"redux-mock-store": "^1.5.4"
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ 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:
|
||||
@@ -152,7 +153,13 @@ const CourseAuthoringRoutes = () => {
|
||||
/>
|
||||
<Route
|
||||
path="export"
|
||||
element={<PageWrap><CourseExportPage /></PageWrap>}
|
||||
element={(
|
||||
<PageWrap>
|
||||
<CourseExportProvider>
|
||||
<CourseExportPage />
|
||||
</CourseExportProvider>
|
||||
</PageWrap>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="optimizer"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -122,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 },
|
||||
});
|
||||
@@ -331,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, {
|
||||
@@ -422,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(
|
||||
@@ -485,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 = [
|
||||
@@ -520,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, {
|
||||
@@ -566,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)
|
||||
@@ -616,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))
|
||||
@@ -634,7 +629,6 @@ describe('<CourseUnit />', () => {
|
||||
xblock_info: {
|
||||
...courseSectionVerticalMock.xblock_info,
|
||||
metadata: {
|
||||
...courseSectionVerticalMock.xblock_info.metadata,
|
||||
display_name: newDisplayName,
|
||||
},
|
||||
},
|
||||
@@ -653,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);
|
||||
@@ -680,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 />);
|
||||
|
||||
@@ -695,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 />);
|
||||
|
||||
@@ -759,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);
|
||||
});
|
||||
|
||||
@@ -788,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);
|
||||
@@ -826,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, {
|
||||
@@ -845,7 +838,6 @@ describe('<CourseUnit />', () => {
|
||||
xblock_info: {
|
||||
...courseSectionVerticalMock.xblock_info,
|
||||
metadata: {
|
||||
...courseSectionVerticalMock.xblock_info.metadata,
|
||||
display_name: newDisplayName,
|
||||
},
|
||||
},
|
||||
@@ -879,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 />);
|
||||
|
||||
@@ -950,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 />);
|
||||
@@ -1160,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();
|
||||
|
||||
@@ -1252,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();
|
||||
|
||||
@@ -1398,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);
|
||||
});
|
||||
|
||||
@@ -1425,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();
|
||||
@@ -1459,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, {
|
||||
@@ -1495,7 +1490,7 @@ describe('<CourseUnit />', () => {
|
||||
expect(iframe).toHaveAttribute(
|
||||
'aria-label',
|
||||
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
|
||||
.replace('{xblockCount}', updatedCourseVerticalChildren.length),
|
||||
.replace('{xblockCount}', updatedCourseVerticalChildren.length.toString()),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1522,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))
|
||||
@@ -1575,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))
|
||||
@@ -1630,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))
|
||||
@@ -1808,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}`,
|
||||
});
|
||||
@@ -1848,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,
|
||||
});
|
||||
|
||||
@@ -1894,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);
|
||||
@@ -2248,6 +2243,7 @@ describe('<CourseUnit />', () => {
|
||||
];
|
||||
|
||||
sidebarContent.forEach(({ query, type, name }) => {
|
||||
// @ts-ignore
|
||||
expect(type ? query(type, { name }) : query(name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -2273,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
|
||||
@@ -2973,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,
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -94,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;
|
||||
};
|
||||
@@ -1,49 +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 { UnitAlignSidebar } from './UnitAlignSidebar';
|
||||
import { AddSidebar } from './AddSidebar';
|
||||
import { useUnitSidebarContext } from './UnitSidebarContext';
|
||||
import { InfoSidebar } from './unit-info/InfoSidebar';
|
||||
|
||||
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, selectedComponentId } = useUnitSidebarContext();
|
||||
const hasComponentSelected = selectedComponentId !== undefined;
|
||||
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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -58,7 +58,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
const {
|
||||
setCurrentPageKey,
|
||||
setSelectedComponentId,
|
||||
} = useUnitSidebarContext();
|
||||
} = useUnitSidebarContext(!readonly) || {};
|
||||
|
||||
// Useful to reload iframe
|
||||
const [iframeKey, setIframeKey] = useState(0);
|
||||
@@ -138,7 +138,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
const onDeleteSubmit = async () => {
|
||||
if (deleteXBlockId) {
|
||||
await unitXBlockActions.handleDelete(deleteXBlockId);
|
||||
setSelectedComponentId(undefined);
|
||||
setSelectedComponentId?.(undefined);
|
||||
closeDeleteModal();
|
||||
}
|
||||
};
|
||||
@@ -192,7 +192,7 @@ 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
|
||||
@@ -219,7 +219,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
};
|
||||
|
||||
const handleXBlockSelected = (id) => {
|
||||
setCurrentPageKey('info', id);
|
||||
setCurrentPageKey?.('info', id);
|
||||
};
|
||||
|
||||
const messageHandlers = useMessageHandlers({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
155
src/export-page/CourseExportContext.tsx
Normal file
155
src/export-page/CourseExportContext.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import moment from 'moment';
|
||||
import Cookies from 'universal-cookie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useExportStatus, useInvalidateExportStatus, useStartCourseExporting } from './data/apiHooks';
|
||||
import { EXPORT_STAGES, LAST_EXPORT_COOKIE_NAME } from './data/constants';
|
||||
import messages from './messages';
|
||||
import { setExportCookie } from './utils';
|
||||
|
||||
export type CourseExportContextData = {
|
||||
currentStage: number;
|
||||
exportTriggered: boolean;
|
||||
fetchExportErrorMessage?: string;
|
||||
errorUnitUrl?: string;
|
||||
anyRequestInProgress: boolean;
|
||||
anyRequestFailed: boolean;
|
||||
isLoadingDenied: boolean;
|
||||
successDate?: number;
|
||||
handleStartExportingCourse: () => void;
|
||||
downloadPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Course Export Context.
|
||||
* Always available when we're in the context of the Course Export Page.
|
||||
*
|
||||
* Get this using `useCourseExportContext()`
|
||||
*/
|
||||
const CourseExportContext = createContext<CourseExportContextData | undefined>(undefined);
|
||||
|
||||
type CourseExportProviderProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const CourseExportProvider = ({ children }: CourseExportProviderProps) => {
|
||||
const intl = useIntl();
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
const cookies = new Cookies();
|
||||
|
||||
const [isStopFetching, setStopFetching] = useState(false);
|
||||
const [exportTriggered, setExportTriggered] = useState(false);
|
||||
const [successDate, setSuccessDate] = useState<number>();
|
||||
|
||||
const reset = () => {
|
||||
setStopFetching(false);
|
||||
setExportTriggered(false);
|
||||
setSuccessDate(undefined);
|
||||
};
|
||||
|
||||
const {
|
||||
data: exportStatus,
|
||||
isPending: isPendingExportStatus,
|
||||
isError: isErrorExportStatus,
|
||||
failureReason: exportStatusError,
|
||||
} = useExportStatus(courseId, isStopFetching, exportTriggered);
|
||||
const exportMutation = useStartCourseExporting(courseId);
|
||||
const invalidateExportStatus = useInvalidateExportStatus(courseId);
|
||||
|
||||
const currentStage = exportStatus?.exportStatus ?? 0;
|
||||
const anyRequestInProgress = exportMutation.isPending || isPendingExportStatus;
|
||||
const anyRequestFailed = exportMutation.isError || isErrorExportStatus;
|
||||
const isLoadingDenied = exportStatusError?.response?.status === 403;
|
||||
|
||||
let fetchExportErrorMessage: string | undefined;
|
||||
let errorUnitUrl;
|
||||
if (exportStatus?.exportError) {
|
||||
fetchExportErrorMessage = exportStatus.exportError.rawErrorMsg ?? intl.formatMessage(messages.unknownError);
|
||||
errorUnitUrl = exportStatus.exportError.editUnitUrl;
|
||||
}
|
||||
|
||||
let downloadPath;
|
||||
if (exportStatus?.exportOutput) {
|
||||
downloadPath = exportStatus.exportOutput;
|
||||
if (downloadPath.startsWith('/')) {
|
||||
downloadPath = `${getConfig().STUDIO_BASE_URL}${downloadPath}`;
|
||||
}
|
||||
}
|
||||
|
||||
// On mount, restore export state from the cookie set by a previous session,
|
||||
// so the stepper remains visible if the user navigates away and comes back.
|
||||
useEffect(() => {
|
||||
const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME);
|
||||
if (cookieData) {
|
||||
setExportTriggered(true);
|
||||
setSuccessDate(cookieData.date);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Stop fetching the export status once the process has reached a terminal state:
|
||||
// successful completion, a network/request failure, or an application-level export error.
|
||||
useEffect(() => {
|
||||
if (currentStage === EXPORT_STAGES.SUCCESS || anyRequestFailed || fetchExportErrorMessage) {
|
||||
setStopFetching(true);
|
||||
}
|
||||
}, [currentStage, anyRequestFailed, fetchExportErrorMessage]);
|
||||
|
||||
const handleStartExportingCourse = async () => {
|
||||
reset();
|
||||
invalidateExportStatus();
|
||||
setExportTriggered(true);
|
||||
await exportMutation.mutateAsync();
|
||||
const momentDate = moment().valueOf();
|
||||
setExportCookie(momentDate);
|
||||
setSuccessDate(momentDate);
|
||||
};
|
||||
|
||||
const context = useMemo<CourseExportContextData>(() => ({
|
||||
currentStage,
|
||||
exportTriggered,
|
||||
fetchExportErrorMessage,
|
||||
errorUnitUrl,
|
||||
anyRequestFailed,
|
||||
isLoadingDenied,
|
||||
anyRequestInProgress,
|
||||
successDate,
|
||||
handleStartExportingCourse,
|
||||
downloadPath,
|
||||
}), [
|
||||
currentStage,
|
||||
exportTriggered,
|
||||
fetchExportErrorMessage,
|
||||
errorUnitUrl,
|
||||
anyRequestFailed,
|
||||
isLoadingDenied,
|
||||
anyRequestInProgress,
|
||||
successDate,
|
||||
handleStartExportingCourse,
|
||||
downloadPath,
|
||||
]);
|
||||
|
||||
return (
|
||||
<CourseExportContext.Provider value={context}>
|
||||
{children}
|
||||
</CourseExportContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useCourseExportContext(): CourseExportContextData {
|
||||
const ctx = useContext(CourseExportContext);
|
||||
if (ctx === undefined) {
|
||||
/* istanbul ignore next */
|
||||
throw new Error('useCourseExportContext() was used in a component without a <CourseExportProvider> ancestor.');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Cookies from 'universal-cookie';
|
||||
@@ -6,11 +7,10 @@ import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
|
||||
import { getCourseDetailsUrl } from '@src/data/api';
|
||||
import {
|
||||
initializeMocks,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '../testUtils';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
} from '@src/testUtils';
|
||||
import stepperMessages from './export-stepper/messages';
|
||||
import modalErrorMessages from './export-modal-error/messages';
|
||||
import { getExportStatusApiUrl, postExportCourseApiUrl } from './data/api';
|
||||
@@ -18,8 +18,8 @@ import { EXPORT_STAGES } from './data/constants';
|
||||
import { exportPageMock } from './__mocks__';
|
||||
import messages from './messages';
|
||||
import CourseExportPage from './CourseExportPage';
|
||||
import { CourseExportProvider } from './CourseExportContext';
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
let cookies;
|
||||
const courseId = '123';
|
||||
@@ -35,7 +35,9 @@ jest.mock('universal-cookie', () => {
|
||||
|
||||
const renderComponent = () => render(
|
||||
<CourseAuthoringProvider courseId={courseId}>
|
||||
<CourseExportPage />
|
||||
<CourseExportProvider>
|
||||
<CourseExportPage />
|
||||
</CourseExportProvider>
|
||||
</CourseAuthoringProvider>,
|
||||
);
|
||||
|
||||
@@ -46,17 +48,20 @@ describe('<CourseExportPage />', () => {
|
||||
username: 'username',
|
||||
};
|
||||
const mocks = initializeMocks({ user });
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
axiosMock
|
||||
.onGet(postExportCourseApiUrl(courseId))
|
||||
.onPost(postExportCourseApiUrl(courseId))
|
||||
.reply(200, exportPageMock);
|
||||
axiosMock
|
||||
.onGet(getCourseDetailsUrl(courseId, user.username))
|
||||
.reply(200, { courseId, name: courseName });
|
||||
axiosMock
|
||||
.onGet(getExportStatusApiUrl(courseId))
|
||||
.reply(200, { exportStatus: EXPORT_STAGES.PREPARING });
|
||||
cookies = new Cookies();
|
||||
cookies.get.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('should render page title correctly', async () => {
|
||||
renderComponent();
|
||||
await waitFor(() => {
|
||||
@@ -66,95 +71,96 @@ describe('<CourseExportPage />', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render without errors', async () => {
|
||||
const { getByText } = renderComponent();
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
const exportPageElement = getByText(messages.headingTitle.defaultMessage, {
|
||||
selector: 'h2.sub-header-title',
|
||||
});
|
||||
expect(exportPageElement).toBeInTheDocument();
|
||||
expect(getByText(messages.titleUnderButton.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.description2.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.buttonTitle.defaultMessage)).toBeInTheDocument();
|
||||
renderComponent();
|
||||
expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
const exportPageElement = screen.getByText(messages.headingTitle.defaultMessage, {
|
||||
selector: 'h2.sub-header-title',
|
||||
});
|
||||
expect(exportPageElement).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.titleUnderButton.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.description2.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.buttonTitle.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should start exporting on click', async () => {
|
||||
const { getByText, container } = renderComponent();
|
||||
const button = container.querySelector('.btn-primary');
|
||||
fireEvent.click(button);
|
||||
expect(getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument();
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderComponent();
|
||||
const button = container.querySelector('.btn-primary')!;
|
||||
await user.click(button);
|
||||
expect(screen.getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show modal error', async () => {
|
||||
axiosMock
|
||||
.onGet(getExportStatusApiUrl(courseId))
|
||||
.reply(200, { exportStatus: EXPORT_STAGES.EXPORTING, exportError: { rawErrorMsg: 'test error', editUnitUrl: 'http://test-url.test' } });
|
||||
const { getByText, queryByText, container } = renderComponent();
|
||||
const startExportButton = container.querySelector('.btn-primary');
|
||||
fireEvent.click(startExportButton);
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderComponent();
|
||||
const startExportButton = container.querySelector('.btn-primary')!;
|
||||
await user.click(startExportButton);
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
await new Promise((r) => setTimeout(r, 3500));
|
||||
expect(getByText(/There has been a failure to export to XML at least one component. It is recommended that you go to the edit page and repair the error before attempting another export. Please check that all components on the page are valid and do not display any error messages. The raw error message is: test error/i));
|
||||
const closeModalWindowButton = getByText('Return to export');
|
||||
fireEvent.click(closeModalWindowButton);
|
||||
expect(queryByText(modalErrorMessages.errorCancelButtonUnit.defaultMessage)).not.toBeInTheDocument();
|
||||
fireEvent.click(closeModalWindowButton);
|
||||
expect(screen.getByText(/There has been a failure to export to XML at least one component. It is recommended that you go to the edit page and repair the error before attempting another export. Please check that all components on the page are valid and do not display any error messages. The raw error message is: test error/i));
|
||||
const closeModalWindowButton = screen.getByText('Return to export');
|
||||
await user.click(closeModalWindowButton);
|
||||
expect(screen.queryByText(modalErrorMessages.errorCancelButtonUnit.defaultMessage)).not.toBeInTheDocument();
|
||||
await user.click(closeModalWindowButton);
|
||||
});
|
||||
|
||||
it('should fetch status without clicking when cookies has', async () => {
|
||||
cookies.get.mockReturnValue({ date: 1679787000 });
|
||||
const { getByText } = renderComponent();
|
||||
expect(getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument();
|
||||
renderComponent();
|
||||
expect(screen.getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show download path for relative path', async () => {
|
||||
axiosMock
|
||||
.onGet(getExportStatusApiUrl(courseId))
|
||||
.reply(200, { exportStatus: EXPORT_STAGES.SUCCESS, exportOutput: '/test-download-path.test' });
|
||||
const { getByText, container } = renderComponent();
|
||||
const startExportButton = container.querySelector('.btn-primary');
|
||||
fireEvent.click(startExportButton);
|
||||
await waitFor(() => {
|
||||
const downloadButton = getByText(stepperMessages.downloadCourseButtonTitle.defaultMessage);
|
||||
expect(downloadButton).toBeInTheDocument();
|
||||
expect(downloadButton.getAttribute('href')).toEqual(`${getConfig().STUDIO_BASE_URL}/test-download-path.test`);
|
||||
}, { timeout: 4_000 });
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderComponent();
|
||||
const startExportButton = container.querySelector('.btn-primary')!;
|
||||
await user.click(startExportButton);
|
||||
const downloadButton = screen.getByText(stepperMessages.downloadCourseButtonTitle.defaultMessage);
|
||||
expect(downloadButton).toBeInTheDocument();
|
||||
expect(downloadButton.getAttribute('href')).toEqual(`${getConfig().STUDIO_BASE_URL}/test-download-path.test`);
|
||||
});
|
||||
|
||||
it('should show download path for absolute path', async () => {
|
||||
axiosMock
|
||||
.onGet(getExportStatusApiUrl(courseId))
|
||||
.reply(200, { exportStatus: EXPORT_STAGES.SUCCESS, exportOutput: 'http://test-download-path.test' });
|
||||
const { getByText, container } = renderComponent();
|
||||
const startExportButton = container.querySelector('.btn-primary');
|
||||
fireEvent.click(startExportButton);
|
||||
await waitFor(() => {
|
||||
const downloadButton = getByText(stepperMessages.downloadCourseButtonTitle.defaultMessage);
|
||||
expect(downloadButton).toBeInTheDocument();
|
||||
expect(downloadButton.getAttribute('href')).toEqual('http://test-download-path.test');
|
||||
}, { timeout: 4_000 });
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderComponent();
|
||||
const startExportButton = container.querySelector('.btn-primary')!;
|
||||
await user.click(startExportButton);
|
||||
const downloadButton = screen.getByText(stepperMessages.downloadCourseButtonTitle.defaultMessage);
|
||||
expect(downloadButton).toBeInTheDocument();
|
||||
expect(downloadButton.getAttribute('href')).toEqual('http://test-download-path.test');
|
||||
});
|
||||
|
||||
it('displays an alert and sets status to DENIED when API responds with 403', async () => {
|
||||
axiosMock
|
||||
.onGet(getExportStatusApiUrl(courseId))
|
||||
.reply(403);
|
||||
const { getByRole, container } = renderComponent();
|
||||
const startExportButton = container.querySelector('.btn-primary');
|
||||
fireEvent.click(startExportButton);
|
||||
await waitFor(() => {
|
||||
expect(getByRole('alert')).toBeInTheDocument();
|
||||
}, { timeout: 4_000 });
|
||||
const { loadingStatus } = store.getState().courseExport;
|
||||
expect(loadingStatus).toEqual(RequestStatus.DENIED);
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderComponent();
|
||||
const startExportButton = container.querySelector('.btn-primary')!;
|
||||
await user.click(startExportButton);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets loading status to FAILED upon receiving a 404 response from the API', async () => {
|
||||
it('does not show a connection error alert upon receiving a 404 response from the API', async () => {
|
||||
axiosMock
|
||||
.onGet(getExportStatusApiUrl(courseId))
|
||||
.reply(404);
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderComponent();
|
||||
const startExportButton = container.querySelector('.btn-primary');
|
||||
fireEvent.click(startExportButton);
|
||||
await waitFor(() => {
|
||||
const { loadingStatus } = store.getState().courseExport;
|
||||
expect(loadingStatus).toEqual(RequestStatus.FAILED);
|
||||
}, { timeout: 4_000 });
|
||||
const startExportButton = container.querySelector('.btn-primary')!;
|
||||
await user.click(startExportButton);
|
||||
expect(screen.getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,54 +1,38 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Container, Layout, Button, Card,
|
||||
} from '@openedx/paragon';
|
||||
import { ArrowCircleDown as ArrowCircleDownIcon } from '@openedx/paragon/icons';
|
||||
import Cookies from 'universal-cookie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
|
||||
import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert';
|
||||
import SubHeader from '@src/generic/sub-header/SubHeader';
|
||||
|
||||
import messages from './messages';
|
||||
import ExportSidebar from './export-sidebar/ExportSidebar';
|
||||
import {
|
||||
getCurrentStage, getError, getExportTriggered, getLoadingStatus, getSavingStatus,
|
||||
} from './data/selectors';
|
||||
import { startExportingCourse } from './data/thunks';
|
||||
import { EXPORT_STAGES, LAST_EXPORT_COOKIE_NAME } from './data/constants';
|
||||
import { updateExportTriggered, updateSavingStatus, updateSuccessDate } from './data/slice';
|
||||
import { EXPORT_STAGES } from './data/constants';
|
||||
import ExportModalError from './export-modal-error/ExportModalError';
|
||||
import ExportFooter from './export-footer/ExportFooter';
|
||||
import ExportStepper from './export-stepper/ExportStepper';
|
||||
import { useCourseExportContext } from './CourseExportContext';
|
||||
|
||||
const CourseExportPage = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const exportTriggered = useSelector(getExportTriggered);
|
||||
const { courseId, courseDetails } = useCourseAuthoringContext();
|
||||
const currentStage = useSelector(getCurrentStage);
|
||||
const { msg: errorMessage } = useSelector(getError);
|
||||
const loadingStatus = useSelector(getLoadingStatus);
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
const cookies = new Cookies();
|
||||
const isShowExportButton = !exportTriggered || errorMessage || currentStage === EXPORT_STAGES.SUCCESS;
|
||||
const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED;
|
||||
const isLoadingDenied = loadingStatus === RequestStatus.DENIED;
|
||||
const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS;
|
||||
const { courseDetails } = useCourseAuthoringContext();
|
||||
const {
|
||||
currentStage,
|
||||
exportTriggered,
|
||||
fetchExportErrorMessage,
|
||||
anyRequestFailed,
|
||||
isLoadingDenied,
|
||||
anyRequestInProgress,
|
||||
handleStartExportingCourse,
|
||||
} = useCourseExportContext();
|
||||
|
||||
useEffect(() => {
|
||||
const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME);
|
||||
if (cookieData) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(updateExportTriggered(true));
|
||||
dispatch(updateSuccessDate(cookieData.date));
|
||||
}
|
||||
}, []);
|
||||
const isShowExportButton = !exportTriggered || fetchExportErrorMessage || currentStage === EXPORT_STAGES.SUCCESS;
|
||||
|
||||
if (isLoadingDenied) {
|
||||
return (
|
||||
@@ -97,7 +81,7 @@ const CourseExportPage = () => {
|
||||
size="lg"
|
||||
block
|
||||
className="mb-4"
|
||||
onClick={() => dispatch(startExportingCourse(courseId))}
|
||||
onClick={handleStartExportingCourse}
|
||||
iconBefore={ArrowCircleDownIcon}
|
||||
>
|
||||
{intl.formatMessage(messages.buttonTitle)}
|
||||
@@ -105,16 +89,16 @@ const CourseExportPage = () => {
|
||||
</Card.Section>
|
||||
)}
|
||||
</Card>
|
||||
{exportTriggered && <ExportStepper courseId={courseId} />}
|
||||
{exportTriggered && <ExportStepper />}
|
||||
<ExportFooter />
|
||||
</article>
|
||||
</Layout.Element>
|
||||
<Layout.Element>
|
||||
<ExportSidebar courseId={courseId} />
|
||||
<ExportSidebar />
|
||||
</Layout.Element>
|
||||
</Layout>
|
||||
</section>
|
||||
<ExportModalError courseId={courseId} />
|
||||
<ExportModalError />
|
||||
</Container>
|
||||
<div className="alert-toast">
|
||||
<InternetConnectionAlert
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const postExportCourseApiUrl = (courseId) => new URL(`export/${courseId}`, getApiBaseUrl()).href;
|
||||
export const getExportStatusApiUrl = (courseId) => new URL(`export_status/${courseId}`, getApiBaseUrl()).href;
|
||||
|
||||
export async function startCourseExporting(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(postExportCourseApiUrl(courseId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function getExportStatus(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getExportStatusApiUrl(courseId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { initializeMockApp, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { initializeMocks } from '@src/testUtils';
|
||||
|
||||
import { getExportStatus, postExportCourseApiUrl, startCourseExporting } from './api';
|
||||
|
||||
@@ -9,15 +8,7 @@ const courseId = 'course-123';
|
||||
|
||||
describe('API Functions', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
({ axiosMock } = initializeMocks());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
27
src/export-page/data/api.ts
Normal file
27
src/export-page/data/api.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const postExportCourseApiUrl = (courseId: string) => new URL(`export/${courseId}`, getApiBaseUrl()).href;
|
||||
export const getExportStatusApiUrl = (courseId: string) => new URL(`export_status/${courseId}`, getApiBaseUrl()).href;
|
||||
|
||||
export interface ExportStatusData {
|
||||
exportStatus: number;
|
||||
exportOutput?: string; // URL to the exported course file
|
||||
exportError?: {
|
||||
rawErrorMsg?: string;
|
||||
editUnitUrl?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startCourseExporting(courseId: string): Promise<ExportStatusData> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(postExportCourseApiUrl(courseId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function getExportStatus(courseId: string): Promise<ExportStatusData> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getExportStatusApiUrl(courseId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
46
src/export-page/data/apiHooks.ts
Normal file
46
src/export-page/data/apiHooks.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
useQueryClient, skipToken, useMutation, useQuery,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { getExportStatus, startCourseExporting, type ExportStatusData } from './api';
|
||||
|
||||
export const exportQueryKeys = {
|
||||
all: ['courseExport'],
|
||||
/** Key for the export status of a specific course */
|
||||
exportStatus: (courseId: string) => [...exportQueryKeys.all, courseId],
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a function to invalidate the export status query for a given course.
|
||||
*/
|
||||
export const useInvalidateExportStatus = (courseId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return () => queryClient.removeQueries({ queryKey: exportQueryKeys.exportStatus(courseId) });
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a mutation to start exporting a course.
|
||||
*/
|
||||
export const useStartCourseExporting = (courseId: string) => (
|
||||
useMutation({
|
||||
mutationFn: () => startCourseExporting(courseId),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the export status for a given course.
|
||||
* Only fetch while `stopRefetch` is false.
|
||||
*/
|
||||
export const useExportStatus = (
|
||||
courseId: string,
|
||||
stopRefetch: boolean,
|
||||
enabled: boolean,
|
||||
) => (
|
||||
useQuery<ExportStatusData, AxiosError>({
|
||||
queryKey: exportQueryKeys.exportStatus(courseId),
|
||||
queryFn: enabled ? () => getExportStatus(courseId) : skipToken,
|
||||
refetchInterval: (enabled && !stopRefetch) ? 3000 : false,
|
||||
})
|
||||
);
|
||||
@@ -1,8 +0,0 @@
|
||||
export const getExportTriggered = (state) => state.courseExport.exportTriggered;
|
||||
export const getCurrentStage = (state) => state.courseExport.currentStage;
|
||||
export const getDownloadPath = (state) => state.courseExport.downloadPath;
|
||||
export const getSuccessDate = (state) => state.courseExport.successDate;
|
||||
export const getError = (state) => state.courseExport.error;
|
||||
export const getIsErrorModalOpen = (state) => state.courseExport.isErrorModalOpen;
|
||||
export const getLoadingStatus = (state) => state.courseExport.loadingStatus;
|
||||
export const getSavingStatus = (state) => state.courseExport.savingStatus;
|
||||
@@ -1,63 +0,0 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
const initialState = {
|
||||
exportTriggered: false,
|
||||
currentStage: 0,
|
||||
error: { msg: null, unitUrl: null },
|
||||
downloadPath: null,
|
||||
successDate: null,
|
||||
isErrorModalOpen: false,
|
||||
loadingStatus: '',
|
||||
savingStatus: '',
|
||||
};
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'exportPage',
|
||||
initialState,
|
||||
reducers: {
|
||||
updateExportTriggered: (state, { payload }) => {
|
||||
state.exportTriggered = payload;
|
||||
},
|
||||
updateCurrentStage: (state, { payload }) => {
|
||||
if (payload >= state.currentStage) {
|
||||
state.currentStage = payload;
|
||||
}
|
||||
},
|
||||
updateDownloadPath: (state, { payload }) => {
|
||||
state.downloadPath = payload;
|
||||
},
|
||||
updateSuccessDate: (state, { payload }) => {
|
||||
state.successDate = payload;
|
||||
},
|
||||
updateError: (state, { payload }) => {
|
||||
state.error = payload;
|
||||
},
|
||||
updateIsErrorModalOpen: (state, { payload }) => {
|
||||
state.isErrorModalOpen = payload;
|
||||
},
|
||||
reset: () => initialState,
|
||||
updateLoadingStatus: (state, { payload }) => {
|
||||
state.loadingStatus = payload.status;
|
||||
},
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
updateExportTriggered,
|
||||
updateCurrentStage,
|
||||
updateDownloadPath,
|
||||
updateSuccessDate,
|
||||
updateError,
|
||||
updateIsErrorModalOpen,
|
||||
reset,
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
@@ -1,146 +0,0 @@
|
||||
import Cookies from 'universal-cookie';
|
||||
import { fetchExportStatus } from './thunks';
|
||||
import * as api from './api';
|
||||
import { EXPORT_STAGES } from './constants';
|
||||
|
||||
jest.mock('universal-cookie', () => jest.fn().mockImplementation(() => ({
|
||||
get: jest.fn().mockImplementation(() => ({ completed: false })),
|
||||
})));
|
||||
|
||||
jest.mock('../utils', () => ({
|
||||
setExportCookie: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('fetchExportStatus thunk', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
const courseId = 'course-123';
|
||||
const exportStatus = EXPORT_STAGES.COMPRESSING;
|
||||
const exportOutput = 'export output';
|
||||
const exportError = 'export error';
|
||||
let mockGetExportStatus;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockGetExportStatus = jest.spyOn(api, 'getExportStatus').mockResolvedValue({
|
||||
exportStatus,
|
||||
exportOutput,
|
||||
exportError,
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch updateCurrentStage with export status', async () => {
|
||||
mockGetExportStatus.mockResolvedValue({
|
||||
exportStatus,
|
||||
exportOutput,
|
||||
exportError,
|
||||
});
|
||||
|
||||
await fetchExportStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: exportStatus,
|
||||
type: 'exportPage/updateCurrentStage',
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch updateError on export error', async () => {
|
||||
mockGetExportStatus.mockResolvedValue({
|
||||
exportStatus,
|
||||
exportOutput,
|
||||
exportError,
|
||||
});
|
||||
|
||||
await fetchExportStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
msg: exportError,
|
||||
unitUrl: null,
|
||||
},
|
||||
type: 'exportPage/updateError',
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch updateIsErrorModalOpen with true if export error', async () => {
|
||||
mockGetExportStatus.mockResolvedValue({
|
||||
exportStatus,
|
||||
exportOutput,
|
||||
exportError,
|
||||
});
|
||||
|
||||
await fetchExportStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: true,
|
||||
type: 'exportPage/updateIsErrorModalOpen',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not dispatch updateIsErrorModalOpen if no export error', async () => {
|
||||
mockGetExportStatus.mockResolvedValue({
|
||||
exportStatus,
|
||||
exportOutput,
|
||||
exportError: null,
|
||||
});
|
||||
|
||||
await fetchExportStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalledWith({
|
||||
payload: false,
|
||||
type: 'exportPage/updateIsErrorModalOpen',
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch updateDownloadPath if there's export output", async () => {
|
||||
mockGetExportStatus.mockResolvedValue({
|
||||
exportStatus,
|
||||
exportOutput,
|
||||
exportError,
|
||||
});
|
||||
|
||||
await fetchExportStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: exportOutput,
|
||||
type: 'exportPage/updateDownloadPath',
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch updateSuccessDate with current date if export status is success', async () => {
|
||||
mockGetExportStatus.mockResolvedValue({
|
||||
exportStatus:
|
||||
EXPORT_STAGES.SUCCESS,
|
||||
exportOutput,
|
||||
exportError,
|
||||
});
|
||||
|
||||
await fetchExportStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: expect.any(Number),
|
||||
type: 'exportPage/updateSuccessDate',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not dispatch updateSuccessDate with current date if last-export cookie is already set', async () => {
|
||||
mockGetExportStatus.mockResolvedValue({
|
||||
exportStatus:
|
||||
EXPORT_STAGES.SUCCESS,
|
||||
exportOutput,
|
||||
exportError,
|
||||
});
|
||||
|
||||
Cookies.mockImplementation(() => ({
|
||||
get: jest.fn().mockReturnValueOnce({ completed: true }),
|
||||
}));
|
||||
|
||||
await fetchExportStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalledWith({
|
||||
payload: expect.any,
|
||||
type: 'exportPage/updateSuccessDate',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import Cookies from 'universal-cookie';
|
||||
import moment from 'moment';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { setExportCookie } from '../utils';
|
||||
import { EXPORT_STAGES, LAST_EXPORT_COOKIE_NAME } from './constants';
|
||||
|
||||
import {
|
||||
startCourseExporting,
|
||||
getExportStatus,
|
||||
} from './api';
|
||||
import {
|
||||
updateExportTriggered,
|
||||
updateCurrentStage,
|
||||
updateDownloadPath,
|
||||
updateSuccessDate,
|
||||
updateError,
|
||||
updateIsErrorModalOpen,
|
||||
reset,
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
} from './slice';
|
||||
|
||||
function setExportDate({
|
||||
date, exportStatus, exportOutput, dispatch,
|
||||
}) {
|
||||
// If there is no cookie for the last export date, set it now.
|
||||
const cookies = new Cookies();
|
||||
const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME);
|
||||
if (!cookieData?.completed) {
|
||||
setExportCookie(date, exportStatus === EXPORT_STAGES.SUCCESS);
|
||||
}
|
||||
// If we don't have export date set yet via cookie, set success date to current date.
|
||||
if (exportOutput && !cookieData?.completed) {
|
||||
dispatch(updateSuccessDate(date));
|
||||
}
|
||||
}
|
||||
|
||||
export function startExportingCourse(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
try {
|
||||
dispatch(reset());
|
||||
dispatch(updateExportTriggered(true));
|
||||
const exportData = await startCourseExporting(courseId);
|
||||
dispatch(updateCurrentStage(exportData.exportStatus));
|
||||
setExportCookie(moment().valueOf(), exportData.exportStatus === EXPORT_STAGES.SUCCESS);
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchExportStatus(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
try {
|
||||
const {
|
||||
exportStatus, exportOutput, exportError,
|
||||
} = await getExportStatus(courseId);
|
||||
dispatch(updateCurrentStage(Math.abs(exportStatus)));
|
||||
|
||||
const date = moment().valueOf();
|
||||
|
||||
setExportDate({
|
||||
date, exportStatus, exportOutput, dispatch,
|
||||
});
|
||||
|
||||
if (exportError) {
|
||||
const errorMessage = exportError.rawErrorMsg || exportError;
|
||||
const errorUnitUrl = exportError.editUnitUrl || null;
|
||||
dispatch(updateError({ msg: errorMessage, unitUrl: errorUnitUrl }));
|
||||
dispatch(updateIsErrorModalOpen(true));
|
||||
}
|
||||
|
||||
if (exportOutput) {
|
||||
if (exportOutput.startsWith('/')) {
|
||||
dispatch(updateDownloadPath(`${getConfig().STUDIO_BASE_URL}${exportOutput}`));
|
||||
} else {
|
||||
dispatch(updateDownloadPath(exportOutput));
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.response && error.response.status === 403) {
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.DENIED }));
|
||||
} else {
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Layout } from '@openedx/paragon';
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Error as ErrorIcon } from '@openedx/paragon/icons';
|
||||
|
||||
import ModalNotification from '../../generic/modal-notification';
|
||||
import { getError, getIsErrorModalOpen } from '../data/selectors';
|
||||
import { updateIsErrorModalOpen } from '../data/slice';
|
||||
import messages from './messages';
|
||||
|
||||
const ExportModalError = ({
|
||||
courseId,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const isErrorModalOpen = useSelector(getIsErrorModalOpen);
|
||||
const { msg: errorMessage, unitUrl: unitErrorUrl } = useSelector(getError);
|
||||
|
||||
const handleUnitRedirect = () => { window.location.assign(unitErrorUrl); };
|
||||
const handleRedirectCourseHome = () => { window.location.assign(`${getConfig().STUDIO_BASE_URL}/course/${courseId}`); };
|
||||
return (
|
||||
<ModalNotification
|
||||
isOpen={isErrorModalOpen}
|
||||
title={intl.formatMessage(messages.errorTitle)}
|
||||
message={
|
||||
intl.formatMessage(
|
||||
unitErrorUrl
|
||||
? messages.errorDescriptionUnit
|
||||
: messages.errorDescriptionNotUnit,
|
||||
{ errorMessage },
|
||||
)
|
||||
}
|
||||
cancelButtonText={
|
||||
intl.formatMessage(unitErrorUrl ? messages.errorCancelButtonUnit : messages.errorCancelButtonNotUnit)
|
||||
}
|
||||
actionButtonText={
|
||||
intl.formatMessage(unitErrorUrl ? messages.errorActionButtonUnit : messages.errorActionButtonNotUnit)
|
||||
}
|
||||
handleCancel={() => dispatch(updateIsErrorModalOpen(false))}
|
||||
handleAction={unitErrorUrl ? handleUnitRedirect : handleRedirectCourseHome}
|
||||
variant="danger"
|
||||
icon={ErrorIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ExportModalError.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
ExportModalError.defaultProps = {};
|
||||
|
||||
export default ExportModalError;
|
||||
55
src/export-page/export-modal-error/ExportModalError.tsx
Normal file
55
src/export-page/export-modal-error/ExportModalError.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Error as ErrorIcon } from '@openedx/paragon/icons';
|
||||
|
||||
import ModalNotification from '@src/generic/modal-notification';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import messages from './messages';
|
||||
import { useCourseExportContext } from '../CourseExportContext';
|
||||
|
||||
const ExportModalError = () => {
|
||||
const intl = useIntl();
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
const {
|
||||
fetchExportErrorMessage,
|
||||
errorUnitUrl,
|
||||
} = useCourseExportContext();
|
||||
|
||||
const [isErrorModalOpen, setIsErrorModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchExportErrorMessage) {
|
||||
setIsErrorModalOpen(true);
|
||||
}
|
||||
}, [fetchExportErrorMessage]);
|
||||
|
||||
const handleUnitRedirect = () => { window.location.assign(errorUnitUrl ?? ''); };
|
||||
const handleRedirectCourseHome = () => { window.location.assign(`${getConfig().STUDIO_BASE_URL}/course/${courseId}`); };
|
||||
return (
|
||||
<ModalNotification
|
||||
isOpen={isErrorModalOpen}
|
||||
title={intl.formatMessage(messages.errorTitle)}
|
||||
message={
|
||||
intl.formatMessage(
|
||||
errorUnitUrl
|
||||
? messages.errorDescriptionUnit
|
||||
: messages.errorDescriptionNotUnit,
|
||||
{ errorMessage: fetchExportErrorMessage },
|
||||
)
|
||||
}
|
||||
cancelButtonText={
|
||||
intl.formatMessage(errorUnitUrl ? messages.errorCancelButtonUnit : messages.errorCancelButtonNotUnit)
|
||||
}
|
||||
actionButtonText={
|
||||
intl.formatMessage(errorUnitUrl ? messages.errorActionButtonUnit : messages.errorActionButtonNotUnit)
|
||||
}
|
||||
handleCancel={() => setIsErrorModalOpen(false)}
|
||||
handleAction={errorUnitUrl ? handleUnitRedirect : handleRedirectCourseHome}
|
||||
variant="danger"
|
||||
icon={ErrorIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportModalError;
|
||||
@@ -1,17 +0,0 @@
|
||||
// @ts-check
|
||||
import { initializeMocks, render } from '../../testUtils';
|
||||
import messages from './messages';
|
||||
import ExportSidebar from './ExportSidebar';
|
||||
|
||||
const courseId = 'course-123';
|
||||
|
||||
describe('<ExportSidebar />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
it('render sidebar correctly', () => {
|
||||
const { getByText } = render(<ExportSidebar courseId={courseId} />);
|
||||
expect(getByText(messages.title1.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.exportedContentHeading.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
27
src/export-page/export-sidebar/ExportSidebar.test.tsx
Normal file
27
src/export-page/export-sidebar/ExportSidebar.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { initializeMocks, render, screen } from '@src/testUtils';
|
||||
|
||||
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
|
||||
import messages from './messages';
|
||||
import ExportSidebar from './ExportSidebar';
|
||||
import { CourseExportProvider } from '../CourseExportContext';
|
||||
|
||||
const courseId = 'course-123';
|
||||
|
||||
const renderComponent = () => render(
|
||||
<CourseAuthoringProvider courseId={courseId}>
|
||||
<CourseExportProvider>
|
||||
<ExportSidebar />
|
||||
</CourseExportProvider>
|
||||
</CourseAuthoringProvider>,
|
||||
);
|
||||
|
||||
describe('<ExportSidebar />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
it('render sidebar correctly', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText(messages.title1.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.exportedContentHeading.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Hyperlink } from '@openedx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { HelpSidebar } from '../../generic/help-sidebar';
|
||||
import { useHelpUrls } from '../../help-urls/hooks';
|
||||
import { HelpSidebar } from '@src/generic/help-sidebar';
|
||||
import { useHelpUrls } from '@src/help-urls/hooks';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const ExportSidebar = ({ courseId }) => {
|
||||
const ExportSidebar = () => {
|
||||
const intl = useIntl();
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
const { exportCourse: exportLearnMoreUrl } = useHelpUrls(['exportCourse']);
|
||||
return (
|
||||
<HelpSidebar courseId={courseId}>
|
||||
@@ -33,13 +35,11 @@ const ExportSidebar = ({ courseId }) => {
|
||||
<h4 className="help-sidebar-about-title">{intl.formatMessage(messages.openDownloadFile)}</h4>
|
||||
<p className="help-sidebar-about-descriptions">{intl.formatMessage(messages.openDownloadFileDescription)}</p>
|
||||
<hr />
|
||||
<Hyperlink className="small" href={exportLearnMoreUrl} target="_blank" variant="outline-primary">{intl.formatMessage(messages.learnMoreButtonTitle)}</Hyperlink>
|
||||
<Hyperlink className="small" destination={exportLearnMoreUrl} target="_blank">
|
||||
{intl.formatMessage(messages.learnMoreButtonTitle)}
|
||||
</Hyperlink>
|
||||
</HelpSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
ExportSidebar.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ExportSidebar;
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import messages from './messages';
|
||||
import ExportStepper from './ExportStepper';
|
||||
|
||||
const courseId = 'course-123';
|
||||
let store;
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<ExportStepper courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<ExportStepper />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
it('render stepper correctly', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText(messages.stepperHeaderTitle.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
27
src/export-page/export-stepper/ExportStepper.test.tsx
Normal file
27
src/export-page/export-stepper/ExportStepper.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { render, initializeMocks, screen } from '@src/testUtils';
|
||||
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
|
||||
|
||||
import messages from './messages';
|
||||
import ExportStepper from './ExportStepper';
|
||||
import { CourseExportProvider } from '../CourseExportContext';
|
||||
|
||||
const courseId = 'course-123';
|
||||
|
||||
const renderComponent = () => render(
|
||||
<CourseAuthoringProvider courseId={courseId}>
|
||||
<CourseExportProvider>
|
||||
<ExportStepper />
|
||||
</CourseExportProvider>
|
||||
</CourseAuthoringProvider>,
|
||||
);
|
||||
|
||||
describe('<ExportStepper />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('render stepper correctly', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText(messages.stepperHeaderTitle.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,42 +1,23 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Button } from '@openedx/paragon';
|
||||
|
||||
import CourseStepper from '../../generic/course-stepper';
|
||||
import {
|
||||
getCurrentStage, getDownloadPath, getError, getLoadingStatus, getSuccessDate,
|
||||
} from '../data/selectors';
|
||||
import { fetchExportStatus } from '../data/thunks';
|
||||
import CourseStepper from '@src/generic/course-stepper';
|
||||
|
||||
import { EXPORT_STAGES } from '../data/constants';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import messages from './messages';
|
||||
import { useCourseExportContext } from '../CourseExportContext';
|
||||
|
||||
const ExportStepper = ({ courseId }) => {
|
||||
const ExportStepper = () => {
|
||||
const intl = useIntl();
|
||||
const currentStage = useSelector(getCurrentStage);
|
||||
const downloadPath = useSelector(getDownloadPath);
|
||||
const successDate = useSelector(getSuccessDate);
|
||||
const loadingStatus = useSelector(getLoadingStatus);
|
||||
const { msg: errorMessage } = useSelector(getError);
|
||||
const dispatch = useDispatch();
|
||||
const isStopFetching = currentStage === EXPORT_STAGES.SUCCESS
|
||||
|| loadingStatus === RequestStatus.FAILED
|
||||
|| errorMessage;
|
||||
const {
|
||||
currentStage,
|
||||
successDate,
|
||||
fetchExportErrorMessage,
|
||||
downloadPath,
|
||||
} = useCourseExportContext();
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
if (isStopFetching) {
|
||||
clearInterval(id);
|
||||
} else {
|
||||
dispatch(fetchExportStatus(courseId));
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
let successTitle = intl.formatMessage(messages.stepperSuccessTitle);
|
||||
const successTitle = intl.formatMessage(messages.stepperSuccessTitle);
|
||||
let successTitleComponent;
|
||||
const localizedSuccessDate = successDate ? (
|
||||
<FormattedDate
|
||||
value={successDate}
|
||||
@@ -49,12 +30,11 @@ const ExportStepper = ({ courseId }) => {
|
||||
) : null;
|
||||
|
||||
if (localizedSuccessDate && currentStage === EXPORT_STAGES.SUCCESS) {
|
||||
const successWithDate = (
|
||||
successTitleComponent = (
|
||||
<>
|
||||
{successTitle} ({localizedSuccessDate})
|
||||
</>
|
||||
);
|
||||
successTitle = successWithDate;
|
||||
}
|
||||
|
||||
const steps = [
|
||||
@@ -74,6 +54,7 @@ const ExportStepper = ({ courseId }) => {
|
||||
title: successTitle,
|
||||
description: intl.formatMessage(messages.stepperSuccessDescription),
|
||||
key: EXPORT_STAGES.SUCCESS,
|
||||
titleComponent: successTitleComponent,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -81,19 +62,18 @@ const ExportStepper = ({ courseId }) => {
|
||||
<div>
|
||||
<h3 className="mt-4">{intl.formatMessage(messages.stepperHeaderTitle)}</h3>
|
||||
<CourseStepper
|
||||
courseId={courseId}
|
||||
steps={steps}
|
||||
activeKey={currentStage}
|
||||
errorMessage={errorMessage}
|
||||
hasError={!!errorMessage}
|
||||
errorMessage={fetchExportErrorMessage}
|
||||
hasError={!!fetchExportErrorMessage}
|
||||
/>
|
||||
{downloadPath && currentStage === EXPORT_STAGES.SUCCESS && <Button className="ml-5.5 mt-n2.5" href={downloadPath} download>{intl.formatMessage(messages.downloadCourseButtonTitle)}</Button>}
|
||||
{downloadPath && currentStage === EXPORT_STAGES.SUCCESS && (
|
||||
<Button className="ml-5.5 mt-n2.5" href={downloadPath}>
|
||||
{intl.formatMessage(messages.downloadCourseButtonTitle)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ExportStepper.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ExportStepper;
|
||||
@@ -29,6 +29,11 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.export.button.title',
|
||||
defaultMessage: 'Export course content',
|
||||
},
|
||||
unknownError: {
|
||||
id: 'course-authoring.export.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;
|
||||
|
||||
@@ -15,12 +15,11 @@ describe('setExportCookie', () => {
|
||||
it('should set the export cookie with the provided date and completed status', () => {
|
||||
const cookiesSetMock = jest.spyOn(Cookies.prototype, 'set');
|
||||
const date = moment('2023-07-24').valueOf();
|
||||
const completed = true;
|
||||
setExportCookie(date, completed);
|
||||
setExportCookie(date);
|
||||
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
LAST_EXPORT_COOKIE_NAME,
|
||||
{ date, completed },
|
||||
{ date },
|
||||
{ path: '/some-path' },
|
||||
);
|
||||
|
||||
|
||||
@@ -8,12 +8,11 @@ import { LAST_EXPORT_COOKIE_NAME, SUCCESS_DATE_FORMAT } from './data/constants';
|
||||
* Sets an export-related cookie with the provided information.
|
||||
*
|
||||
* @param date - Date of export (unix timestamp).
|
||||
* @param {boolean} completed - Indicates if export was completed successfully.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const setExportCookie = (date: number, completed: boolean): void => {
|
||||
export const setExportCookie = (date: number): void => {
|
||||
const cookies = new Cookies();
|
||||
cookies.set(LAST_EXPORT_COOKIE_NAME, { date, completed }, { path: window.location.pathname });
|
||||
cookies.set(LAST_EXPORT_COOKIE_NAME, { date }, { path: window.location.pathname });
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -144,7 +144,6 @@ export function Sidebar<T extends SidebarPages>({
|
||||
>
|
||||
{Object.entries(pages).map(([key, page]) => {
|
||||
const buttonData = {
|
||||
key,
|
||||
value: key,
|
||||
src: page.icon,
|
||||
alt: intl.formatMessage(page.title),
|
||||
@@ -155,6 +154,7 @@ export function Sidebar<T extends SidebarPages>({
|
||||
if (page.tooltip) {
|
||||
return (
|
||||
<IconButtonWithTooltip
|
||||
key={key}
|
||||
{...buttonData}
|
||||
style={{ pointerEvents: 'all' }}
|
||||
tooltipContent={<div>{intl.formatMessage(page.tooltip)}</div>}
|
||||
|
||||
@@ -27,14 +27,22 @@ interface SidebarContentProps {
|
||||
* </SidebarContent>
|
||||
* ```
|
||||
*/
|
||||
export const SidebarContent = ({ children } : SidebarContentProps) => (
|
||||
<Stack gap={1} className="px-3 py-1">
|
||||
{Array.isArray(children) ? children.map((child, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<React.Fragment key={index}>
|
||||
{child}
|
||||
{index !== children.length - 1 && <hr className="w-100" />}
|
||||
</React.Fragment>
|
||||
)) : children}
|
||||
</Stack>
|
||||
);
|
||||
export const SidebarContent = ({ children } : SidebarContentProps) => {
|
||||
// Flatten the array and filter out empty children to correctly render
|
||||
// the hr element between each child.
|
||||
const nonEmptyChildren = Array.isArray(children)
|
||||
? children.flat(Infinity).filter(child => !!child)
|
||||
: [children];
|
||||
|
||||
return (
|
||||
<Stack gap={1} className="px-3 py-1">
|
||||
{nonEmptyChildren.map((child, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<React.Fragment key={index}>
|
||||
{child}
|
||||
{index !== nonEmptyChildren.length - 1 && <hr className="w-100" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -63,6 +63,9 @@ describe('create library apiHooks', () => {
|
||||
expect(invalidateQueriesSpy).toHaveBeenCalledWith({
|
||||
queryKey: libraryAuthoringQueryKeys.contentLibraryList(),
|
||||
});
|
||||
expect(invalidateQueriesSpy).toHaveBeenCalledWith({
|
||||
queryKey: ['content_search'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ export const useCreateLibraryV2 = () => {
|
||||
mutationFn: createLibraryV2,
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibraryList() });
|
||||
// Invalidate the search token to refresh with the new library's access_id
|
||||
queryClient.invalidateQueries({ queryKey: ['content_search'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -16,7 +16,6 @@ import { reducer as scheduleAndDetailsReducer } from './schedule-and-details/dat
|
||||
import { reducer as filesReducer } from './files-and-videos/files-page/data/slice';
|
||||
import { reducer as CourseUpdatesReducer } from './course-updates/data/slice';
|
||||
import { reducer as processingNotificationReducer } from './generic/processing-notification/data/slice';
|
||||
import { reducer as courseExportReducer } from './export-page/data/slice';
|
||||
import { reducer as courseOptimizerReducer } from './optimizer-page/data/slice';
|
||||
import { reducer as genericReducer } from './generic/data/slice';
|
||||
import { reducer as videosReducer } from './files-and-videos/videos-page/data/slice';
|
||||
@@ -43,7 +42,6 @@ export interface DeprecatedReduxState {
|
||||
live: Record<string, any>;
|
||||
courseUpdates: Record<string, any>;
|
||||
processingNotification: Record<string, any>;
|
||||
courseExport: Record<string, any>;
|
||||
courseOptimizer: Record<string, any>;
|
||||
generic: Record<string, any>;
|
||||
videos: Record<string, any>;
|
||||
@@ -74,7 +72,6 @@ export default function initializeStore(preloadedState: Partial<DeprecatedReduxS
|
||||
live: liveReducer,
|
||||
courseUpdates: CourseUpdatesReducer,
|
||||
processingNotification: processingNotificationReducer,
|
||||
courseExport: courseExportReducer,
|
||||
courseOptimizer: courseOptimizerReducer,
|
||||
generic: genericReducer,
|
||||
videos: videosReducer,
|
||||
|
||||
Reference in New Issue
Block a user