Compare commits
1 Commits
master
...
kshitij/ag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e11b1d9bac |
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@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
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@v8
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: code-coverage-report
|
||||
path: coverage
|
||||
|
||||
74
package-lock.json
generated
74
package-lock.json
generated
@@ -95,7 +95,7 @@
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"oxlint": "^1.42.0",
|
||||
"oxlint-tsgolint": "^0.16.0",
|
||||
"oxlint-tsgolint": "^0.14.0",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"redux-mock-store": "^1.5.4"
|
||||
}
|
||||
@@ -2305,9 +2305,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
|
||||
"integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
|
||||
"version": "6.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.4.tgz",
|
||||
"integrity": "sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -2336,9 +2336,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.39.16",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.16.tgz",
|
||||
"integrity": "sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==",
|
||||
"version": "6.39.14",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.14.tgz",
|
||||
"integrity": "sha512-WJcvgHm/6Q7dvGT0YFv/6PSkoc36QlR0VCESS6x9tGsnF1lWLmmYxOgX3HH6v8fo6AvSLgpcs+H0Olre6MKXlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
@@ -5574,9 +5574,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/darwin-arm64": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.16.0.tgz",
|
||||
"integrity": "sha512-WQt5lGwRPJBw7q2KNR0mSPDAaMmZmVvDlEEti96xLO7ONhyomQc6fBZxxwZ4qTFedjJnrHX94sFelZ4OKzS7UQ==",
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.14.2.tgz",
|
||||
"integrity": "sha512-03WxIXguCXf1pTmoG2C6vqRcbrU9GaJCW6uTIiQdIQq4BrJnVWZv99KEUQQRkuHK78lOLa9g7B4K58NcVcB54g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5588,9 +5588,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/darwin-x64": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.16.0.tgz",
|
||||
"integrity": "sha512-VJo29XOzdkalvCTiE2v6FU3qZlgHaM8x8hUEVJGPU2i5W+FlocPpmn00+Ld2n7Q0pqIjyD5EyvZ5UmoIEJMfqg==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5602,9 +5602,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/linux-arm64": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.16.0.tgz",
|
||||
"integrity": "sha512-MPfqRt1+XRHv9oHomcBMQ3KpTE+CSkZz14wUxDQoqTNdUlV0HWdzwIE9q65I3D9YyxEnqpM7j4qtDQ3apqVvbQ==",
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.14.2.tgz",
|
||||
"integrity": "sha512-2BgR535w7GLxBCyQD5DR3dBzbAgiBbG5QX1kAEVzOmWxJhhGxt5lsHdHebRo7ilukYLpBDkerz0mbMErblghCQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5616,9 +5616,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/linux-x64": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.16.0.tgz",
|
||||
"integrity": "sha512-XQSwVUsnwLokMhe1TD6IjgvW5WMTPzOGGkdFDtXWQmlN2YeTw94s/NN0KgDrn2agM1WIgAenEkvnm0u7NgwEyw==",
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.14.2.tgz",
|
||||
"integrity": "sha512-TUHFyVHfbbGtnTQZbUFgwvv3NzXBgzNLKdMUJw06thpiC7u5OW5qdk4yVXIC/xeVvdl3NAqTfcT4sA32aiMubg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5630,9 +5630,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/win32-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5644,9 +5644,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/win32-x64": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.16.0.tgz",
|
||||
"integrity": "sha512-1ufk8cgktXJuJZHKF63zCHAkaLMwZrEXnZ89H2y6NO85PtOXqu4zbdNl0VBpPP3fCUuUBu9RvNqMFiv0VsbXWA==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -9361,9 +9361,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001777",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
|
||||
"integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==",
|
||||
"version": "1.0.30001775",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz",
|
||||
"integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -17891,21 +17891,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oxlint-tsgolint": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.16.0.tgz",
|
||||
"integrity": "sha512-4RuJK2jP08XwqtUu+5yhCbxEauCm6tv2MFHKEMsjbosK2+vy5us82oI3VLuHwbNyZG7ekZA26U2LLHnGR4frIA==",
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.14.2.tgz",
|
||||
"integrity": "sha512-XJsFIQwnYJgXFlNDz2MncQMWYxwnfy4BCy73mdiFN/P13gEZrAfBU4Jmz2XXFf9UG0wPILdi7hYa6t0KmKQLhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tsgolint": "bin/tsgolint.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@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"
|
||||
"@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"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"oxlint": "^1.42.0",
|
||||
"oxlint-tsgolint": "^0.16.0",
|
||||
"oxlint-tsgolint": "^0.14.0",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"redux-mock-store": "^1.5.4"
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ 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:
|
||||
@@ -153,13 +152,7 @@ const CourseAuthoringRoutes = () => {
|
||||
/>
|
||||
<Route
|
||||
path="export"
|
||||
element={(
|
||||
<PageWrap>
|
||||
<CourseExportProvider>
|
||||
<CourseExportPage />
|
||||
</CourseExportProvider>
|
||||
</PageWrap>
|
||||
)}
|
||||
element={<PageWrap><CourseExportPage /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="optimizer"
|
||||
|
||||
@@ -116,3 +116,9 @@ export const BROKEN = 'broken';
|
||||
export const LOCKED = 'locked';
|
||||
|
||||
export const MANUAL = 'manual';
|
||||
|
||||
export enum AgreementGated {
|
||||
UPLOAD = 'upload',
|
||||
UPLOAD_VIDEOS = 'upload.videos',
|
||||
UPLOAD_FILES = 'upload.files',
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { usePasteFileNotices } from '@src/course-outline/data/apiHooks';
|
||||
import { AlertAgreementGatedFeature } from '@src/generic/agreement-gated-feature';
|
||||
import { AgreementGated } from '../../constants';
|
||||
import CourseOutlinePageAlertsSlot from '../../plugin-slots/CourseOutlinePageAlertsSlot';
|
||||
import advancedSettingsMessages from '../../advanced-settings/messages';
|
||||
import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert';
|
||||
@@ -441,6 +443,9 @@ const PageAlerts = ({
|
||||
{conflictingFilesPasteAlert()}
|
||||
{newFilesPasteAlert()}
|
||||
{renderOutOfSyncAlert()}
|
||||
<AlertAgreementGatedFeature
|
||||
gatingTypes={[AgreementGated.UPLOAD, AgreementGated.UPLOAD_VIDEOS, AgreementGated.UPLOAD_FILES]}
|
||||
/>
|
||||
<CourseOutlinePageAlertsSlot />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -208,3 +208,25 @@ export async function getPreviewModulestoreMigration(
|
||||
const { data } = await client.get(getPreviewModulestoreMigrationUrl(), { params });
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export const getUserAgreementRecordApi = (agreementType: string) => `${getConfig().LMS_BASE_URL}/api/agreements/v1/agreement_record/${agreementType}`;
|
||||
|
||||
export async function getUserAgreementRecord(agreementType: string) {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
const { data } = await client.get(getUserAgreementRecordApi(agreementType));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function updateUserAgreementRecord(agreementType: string) {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
const { data } = await client.post(getUserAgreementRecordApi(agreementType));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export const getUserAgreementApi = (agreementType: string) => `${getConfig().LMS_BASE_URL}/api/agreements/v1/agreement/${agreementType}/`;
|
||||
|
||||
export async function getUserAgreement(agreementType: string) {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
const { data } = await client.get(getUserAgreementApi(agreementType));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import {
|
||||
skipToken, useMutation, useQuery, useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { UserAgreement, UserAgreementRecord } from '@src/data/types';
|
||||
import { libraryAuthoringQueryKeys } from '@src/library-authoring/data/apiHooks';
|
||||
import {
|
||||
getWaffleFlags,
|
||||
waffleFlagDefaults,
|
||||
bulkModulestoreMigrate,
|
||||
getModulestoreMigrationStatus,
|
||||
skipToken, useMutation, useQueries, useQuery, useQueryClient, UseQueryOptions,
|
||||
} from '@tanstack/react-query';
|
||||
import {
|
||||
BulkMigrateRequestData,
|
||||
bulkModulestoreMigrate,
|
||||
getCourseDetails,
|
||||
getPreviewModulestoreMigration,
|
||||
getModulestoreMigrationStatus,
|
||||
getPreviewModulestoreMigration, getUserAgreement,
|
||||
getUserAgreementRecord,
|
||||
getWaffleFlags, updateUserAgreementRecord,
|
||||
waffleFlagDefaults,
|
||||
} from './api';
|
||||
import { RequestStatus, RequestStatusType } from './constants';
|
||||
|
||||
@@ -165,3 +168,47 @@ export function createGlobalState<T>(
|
||||
return { data, setData, resetData };
|
||||
};
|
||||
}
|
||||
|
||||
export const getGatingAgreementTypes = (gatingTypes: string[]): string[] => (
|
||||
[...new Set(
|
||||
gatingTypes
|
||||
.flatMap(gatingType => getConfig().AGREEMENT_GATING?.[gatingType])
|
||||
.filter(item => Boolean(item)),
|
||||
)]
|
||||
);
|
||||
|
||||
export const useUserAgreementRecord = (agreementType:string) => (
|
||||
useQuery<UserAgreementRecord, Error>({
|
||||
queryKey: ['agreement-record', agreementType],
|
||||
queryFn: () => getUserAgreementRecord(agreementType),
|
||||
retry: false,
|
||||
})
|
||||
);
|
||||
|
||||
export const useUserAgreementRecords = (agreementTypes:string[]) => (
|
||||
useQueries({
|
||||
queries: agreementTypes.map<UseQueryOptions<UserAgreementRecord, Error>>(agreementType => ({
|
||||
queryKey: ['agreement-record', agreementType],
|
||||
queryFn: () => getUserAgreementRecord(agreementType),
|
||||
retry: false,
|
||||
})),
|
||||
})
|
||||
);
|
||||
|
||||
export const useUserAgreementRecordUpdater = (agreementType:string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => updateUserAgreementRecord(agreementType),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agreement-record', agreementType] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUserAgreement = (agreementType:string) => (
|
||||
useQuery<UserAgreement, Error>({
|
||||
queryKey: ['agreements', agreementType],
|
||||
queryFn: () => getUserAgreement(agreementType),
|
||||
retry: false,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -201,3 +201,19 @@ export type AccessManagedXBlockDataTypes = {
|
||||
onlineProctoringRules?: string;
|
||||
discussionEnabled?: boolean;
|
||||
};
|
||||
|
||||
export interface UserAgreementRecord {
|
||||
username: string;
|
||||
agreementType: string;
|
||||
acceptedAt: string | null;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface UserAgreement {
|
||||
type: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
hasText: boolean;
|
||||
url: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
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,4 +1,3 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Cookies from 'universal-cookie';
|
||||
@@ -7,10 +6,11 @@ import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
|
||||
import { getCourseDetailsUrl } from '@src/data/api';
|
||||
import {
|
||||
initializeMocks,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@src/testUtils';
|
||||
} from '../testUtils';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
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,9 +35,7 @@ jest.mock('universal-cookie', () => {
|
||||
|
||||
const renderComponent = () => render(
|
||||
<CourseAuthoringProvider courseId={courseId}>
|
||||
<CourseExportProvider>
|
||||
<CourseExportPage />
|
||||
</CourseExportProvider>
|
||||
<CourseExportPage />
|
||||
</CourseAuthoringProvider>,
|
||||
);
|
||||
|
||||
@@ -48,20 +46,17 @@ describe('<CourseExportPage />', () => {
|
||||
username: 'username',
|
||||
};
|
||||
const mocks = initializeMocks({ user });
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
axiosMock
|
||||
.onPost(postExportCourseApiUrl(courseId))
|
||||
.onGet(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(() => {
|
||||
@@ -71,96 +66,95 @@ describe('<CourseExportPage />', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render without errors', async () => {
|
||||
renderComponent();
|
||||
expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
const exportPageElement = screen.getByText(messages.headingTitle.defaultMessage, {
|
||||
selector: 'h2.sub-header-title',
|
||||
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();
|
||||
});
|
||||
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 user = userEvent.setup();
|
||||
const { container } = renderComponent();
|
||||
const button = container.querySelector('.btn-primary')!;
|
||||
await user.click(button);
|
||||
expect(screen.getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument();
|
||||
const { getByText, container } = renderComponent();
|
||||
const button = container.querySelector('.btn-primary');
|
||||
fireEvent.click(button);
|
||||
expect(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 user = userEvent.setup();
|
||||
const { container } = renderComponent();
|
||||
const startExportButton = container.querySelector('.btn-primary')!;
|
||||
await user.click(startExportButton);
|
||||
const { getByText, queryByText, container } = renderComponent();
|
||||
const startExportButton = container.querySelector('.btn-primary');
|
||||
fireEvent.click(startExportButton);
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
await new Promise((r) => setTimeout(r, 3500));
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
it('should fetch status without clicking when cookies has', async () => {
|
||||
cookies.get.mockReturnValue({ date: 1679787000 });
|
||||
renderComponent();
|
||||
expect(screen.getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument();
|
||||
const { getByText } = renderComponent();
|
||||
expect(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 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`);
|
||||
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 });
|
||||
});
|
||||
|
||||
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 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');
|
||||
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 });
|
||||
});
|
||||
|
||||
it('displays an alert and sets status to DENIED when API responds with 403', async () => {
|
||||
axiosMock
|
||||
.onGet(getExportStatusApiUrl(courseId))
|
||||
.reply(403);
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderComponent();
|
||||
const startExportButton = container.querySelector('.btn-primary')!;
|
||||
await user.click(startExportButton);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
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);
|
||||
});
|
||||
|
||||
it('does not show a connection error alert upon receiving a 404 response from the API', async () => {
|
||||
it('sets loading status to FAILED 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')!;
|
||||
await user.click(startExportButton);
|
||||
expect(screen.getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
const startExportButton = container.querySelector('.btn-primary');
|
||||
fireEvent.click(startExportButton);
|
||||
await waitFor(() => {
|
||||
const { loadingStatus } = store.getState().courseExport;
|
||||
expect(loadingStatus).toEqual(RequestStatus.FAILED);
|
||||
}, { timeout: 4_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,38 +1,54 @@
|
||||
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 '@src/generic/internet-connection-alert';
|
||||
import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert';
|
||||
import SubHeader from '@src/generic/sub-header/SubHeader';
|
||||
|
||||
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 messages from './messages';
|
||||
import ExportSidebar from './export-sidebar/ExportSidebar';
|
||||
import { EXPORT_STAGES } from './data/constants';
|
||||
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 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 { courseDetails } = useCourseAuthoringContext();
|
||||
const {
|
||||
currentStage,
|
||||
exportTriggered,
|
||||
fetchExportErrorMessage,
|
||||
anyRequestFailed,
|
||||
isLoadingDenied,
|
||||
anyRequestInProgress,
|
||||
handleStartExportingCourse,
|
||||
} = useCourseExportContext();
|
||||
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 isShowExportButton = !exportTriggered || fetchExportErrorMessage || currentStage === EXPORT_STAGES.SUCCESS;
|
||||
useEffect(() => {
|
||||
const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME);
|
||||
if (cookieData) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(updateExportTriggered(true));
|
||||
dispatch(updateSuccessDate(cookieData.date));
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (isLoadingDenied) {
|
||||
return (
|
||||
@@ -81,7 +97,7 @@ const CourseExportPage = () => {
|
||||
size="lg"
|
||||
block
|
||||
className="mb-4"
|
||||
onClick={handleStartExportingCourse}
|
||||
onClick={() => dispatch(startExportingCourse(courseId))}
|
||||
iconBefore={ArrowCircleDownIcon}
|
||||
>
|
||||
{intl.formatMessage(messages.buttonTitle)}
|
||||
@@ -89,16 +105,16 @@ const CourseExportPage = () => {
|
||||
</Card.Section>
|
||||
)}
|
||||
</Card>
|
||||
{exportTriggered && <ExportStepper />}
|
||||
{exportTriggered && <ExportStepper courseId={courseId} />}
|
||||
<ExportFooter />
|
||||
</article>
|
||||
</Layout.Element>
|
||||
<Layout.Element>
|
||||
<ExportSidebar />
|
||||
<ExportSidebar courseId={courseId} />
|
||||
</Layout.Element>
|
||||
</Layout>
|
||||
</section>
|
||||
<ExportModalError />
|
||||
<ExportModalError courseId={courseId} />
|
||||
</Container>
|
||||
<div className="alert-toast">
|
||||
<InternetConnectionAlert
|
||||
|
||||
18
src/export-page/data/api.js
Normal file
18
src/export-page/data/api.js
Normal file
@@ -0,0 +1,18 @@
|
||||
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,5 +1,6 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { initializeMocks } from '@src/testUtils';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { initializeMockApp, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { getExportStatus, postExportCourseApiUrl, startCourseExporting } from './api';
|
||||
|
||||
@@ -8,7 +9,15 @@ const courseId = 'course-123';
|
||||
|
||||
describe('API Functions', () => {
|
||||
beforeEach(() => {
|
||||
({ axiosMock } = initializeMocks());
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,27 +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: 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);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/* 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,
|
||||
})
|
||||
);
|
||||
8
src/export-page/data/selectors.js
Normal file
8
src/export-page/data/selectors.js
Normal file
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
63
src/export-page/data/slice.js
Normal file
63
src/export-page/data/slice.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/* 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;
|
||||
146
src/export-page/data/thunks.test.js
Normal file
146
src/export-page/data/thunks.test.js
Normal file
@@ -0,0 +1,146 @@
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
100
src/export-page/data/thunks.ts
Normal file
100
src/export-page/data/thunks.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
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,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Layout } from '@openedx/paragon';
|
||||
|
||||
55
src/export-page/export-modal-error/ExportModalError.jsx
Normal file
55
src/export-page/export-modal-error/ExportModalError.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
@@ -1,55 +0,0 @@
|
||||
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,16 +1,14 @@
|
||||
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 '@src/generic/help-sidebar';
|
||||
import { useHelpUrls } from '@src/help-urls/hooks';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
|
||||
import { HelpSidebar } from '../../generic/help-sidebar';
|
||||
import { useHelpUrls } from '../../help-urls/hooks';
|
||||
import messages from './messages';
|
||||
|
||||
const ExportSidebar = () => {
|
||||
const ExportSidebar = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
const { exportCourse: exportLearnMoreUrl } = useHelpUrls(['exportCourse']);
|
||||
return (
|
||||
<HelpSidebar courseId={courseId}>
|
||||
@@ -35,11 +33,13 @@ const ExportSidebar = () => {
|
||||
<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" destination={exportLearnMoreUrl} target="_blank">
|
||||
{intl.formatMessage(messages.learnMoreButtonTitle)}
|
||||
</Hyperlink>
|
||||
<Hyperlink className="small" href={exportLearnMoreUrl} target="_blank" variant="outline-primary">{intl.formatMessage(messages.learnMoreButtonTitle)}</Hyperlink>
|
||||
</HelpSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
ExportSidebar.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ExportSidebar;
|
||||
17
src/export-page/export-sidebar/ExportSidebar.test.jsx
Normal file
17
src/export-page/export-sidebar/ExportSidebar.test.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
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,23 +1,42 @@
|
||||
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 '@src/generic/course-stepper';
|
||||
|
||||
import CourseStepper from '../../generic/course-stepper';
|
||||
import {
|
||||
getCurrentStage, getDownloadPath, getError, getLoadingStatus, getSuccessDate,
|
||||
} from '../data/selectors';
|
||||
import { fetchExportStatus } from '../data/thunks';
|
||||
import { EXPORT_STAGES } from '../data/constants';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import messages from './messages';
|
||||
import { useCourseExportContext } from '../CourseExportContext';
|
||||
|
||||
const ExportStepper = () => {
|
||||
const ExportStepper = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
currentStage,
|
||||
successDate,
|
||||
fetchExportErrorMessage,
|
||||
downloadPath,
|
||||
} = useCourseExportContext();
|
||||
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 successTitle = intl.formatMessage(messages.stepperSuccessTitle);
|
||||
let successTitleComponent;
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
if (isStopFetching) {
|
||||
clearInterval(id);
|
||||
} else {
|
||||
dispatch(fetchExportStatus(courseId));
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
let successTitle = intl.formatMessage(messages.stepperSuccessTitle);
|
||||
const localizedSuccessDate = successDate ? (
|
||||
<FormattedDate
|
||||
value={successDate}
|
||||
@@ -30,11 +49,12 @@ const ExportStepper = () => {
|
||||
) : null;
|
||||
|
||||
if (localizedSuccessDate && currentStage === EXPORT_STAGES.SUCCESS) {
|
||||
successTitleComponent = (
|
||||
const successWithDate = (
|
||||
<>
|
||||
{successTitle} ({localizedSuccessDate})
|
||||
</>
|
||||
);
|
||||
successTitle = successWithDate;
|
||||
}
|
||||
|
||||
const steps = [
|
||||
@@ -54,7 +74,6 @@ const ExportStepper = () => {
|
||||
title: successTitle,
|
||||
description: intl.formatMessage(messages.stepperSuccessDescription),
|
||||
key: EXPORT_STAGES.SUCCESS,
|
||||
titleComponent: successTitleComponent,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -62,18 +81,19 @@ const ExportStepper = () => {
|
||||
<div>
|
||||
<h3 className="mt-4">{intl.formatMessage(messages.stepperHeaderTitle)}</h3>
|
||||
<CourseStepper
|
||||
courseId={courseId}
|
||||
steps={steps}
|
||||
activeKey={currentStage}
|
||||
errorMessage={fetchExportErrorMessage}
|
||||
hasError={!!fetchExportErrorMessage}
|
||||
errorMessage={errorMessage}
|
||||
hasError={!!errorMessage}
|
||||
/>
|
||||
{downloadPath && currentStage === EXPORT_STAGES.SUCCESS && (
|
||||
<Button className="ml-5.5 mt-n2.5" href={downloadPath}>
|
||||
{intl.formatMessage(messages.downloadCourseButtonTitle)}
|
||||
</Button>
|
||||
)}
|
||||
{downloadPath && currentStage === EXPORT_STAGES.SUCCESS && <Button className="ml-5.5 mt-n2.5" href={downloadPath} download>{intl.formatMessage(messages.downloadCourseButtonTitle)}</Button>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ExportStepper.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ExportStepper;
|
||||
38
src/export-page/export-stepper/ExportStepper.test.jsx
Normal file
38
src/export-page/export-stepper/ExportStepper.test.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -29,11 +29,6 @@ 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,11 +15,12 @@ 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();
|
||||
setExportCookie(date);
|
||||
const completed = true;
|
||||
setExportCookie(date, completed);
|
||||
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
LAST_EXPORT_COOKIE_NAME,
|
||||
{ date },
|
||||
{ date, completed },
|
||||
{ path: '/some-path' },
|
||||
);
|
||||
|
||||
|
||||
@@ -8,11 +8,12 @@ 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): void => {
|
||||
export const setExportCookie = (date: number, completed: boolean): void => {
|
||||
const cookies = new Cookies();
|
||||
cookies.set(LAST_EXPORT_COOKIE_NAME, { date }, { path: window.location.pathname });
|
||||
cookies.set(LAST_EXPORT_COOKIE_NAME, { date, completed }, { path: window.location.pathname });
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { CheckboxFilter } from '@openedx/paragon';
|
||||
import { AgreementGated, UPLOAD_FILE_MAX_SIZE } from '@src/constants';
|
||||
import {
|
||||
addAssetFile,
|
||||
deleteAssetFile,
|
||||
@@ -20,13 +21,13 @@ import {
|
||||
FileTable,
|
||||
ThumbnailColumn,
|
||||
} from '@src/files-and-videos/generic';
|
||||
import { GatedComponentWrapper } from '@src/generic/agreement-gated-feature';
|
||||
import { useModels } from '@src/generic/model-store';
|
||||
import { DeprecatedReduxState } from '@src/store';
|
||||
import { getFileSizeToClosestByte } from '@src/utils';
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { UPLOAD_FILE_MAX_SIZE } from '@src/constants';
|
||||
|
||||
export const CourseFilesTable = () => {
|
||||
const intl = useIntl();
|
||||
@@ -159,26 +160,28 @@ export const CourseFilesTable = () => {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FileTable
|
||||
{...{
|
||||
courseId,
|
||||
data,
|
||||
handleAddFile,
|
||||
handleDeleteFile,
|
||||
handleDownloadFile,
|
||||
handleLockFile,
|
||||
handleUsagePaths,
|
||||
handleErrorReset,
|
||||
handleFileOrder,
|
||||
tableColumns,
|
||||
maxFileSize,
|
||||
thumbnailPreview,
|
||||
infoModalSidebar,
|
||||
files: assets,
|
||||
}}
|
||||
/>
|
||||
<FileValidationModal {...{ handleFileOverwrite }} />
|
||||
</>
|
||||
<GatedComponentWrapper gatingTypes={[AgreementGated.UPLOAD, AgreementGated.UPLOAD_FILES]}>
|
||||
<>
|
||||
<FileTable
|
||||
{...{
|
||||
courseId,
|
||||
data,
|
||||
handleAddFile,
|
||||
handleDeleteFile,
|
||||
handleDownloadFile,
|
||||
handleLockFile,
|
||||
handleUsagePaths,
|
||||
handleErrorReset,
|
||||
handleFileOrder,
|
||||
tableColumns,
|
||||
maxFileSize,
|
||||
thumbnailPreview,
|
||||
infoModalSidebar,
|
||||
files: assets,
|
||||
}}
|
||||
/>
|
||||
<FileValidationModal {...{ handleFileOverwrite }} />
|
||||
</>
|
||||
</GatedComponentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Container } from '@openedx/paragon';
|
||||
import { useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
@@ -10,6 +10,8 @@ import Placeholder from '@src/editors/Placeholder';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import getPageHeadTitle from '@src/generic/utils';
|
||||
import EditFileAlertsSlot from '@src/plugin-slots/EditFileAlertsSlot';
|
||||
import { AlertAgreementGatedFeature } from '@src/generic/agreement-gated-feature';
|
||||
import { AgreementGated } from '@src/constants';
|
||||
|
||||
import { EditFileErrors } from '../generic';
|
||||
import { fetchAssets, resetErrors } from './data/thunks';
|
||||
@@ -55,6 +57,9 @@ const FilesPage = () => {
|
||||
updateFileStatus={updateAssetStatus}
|
||||
loadingStatus={loadingStatus}
|
||||
/>
|
||||
<AlertAgreementGatedFeature
|
||||
gatingTypes={[AgreementGated.UPLOAD, AgreementGated.UPLOAD_FILES]}
|
||||
/>
|
||||
<EditFileAlertsSlot />
|
||||
<div className="h2">
|
||||
{intl.formatMessage(messages.heading)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow, Button, CheckboxFilter, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { AgreementGated } from '@src/constants';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import {
|
||||
ActiveColumn,
|
||||
@@ -29,6 +30,7 @@ import messages from '@src/files-and-videos/videos-page/messages';
|
||||
import TranscriptSettings from '@src/files-and-videos/videos-page/transcript-settings';
|
||||
import UploadModal from '@src/files-and-videos/videos-page/upload-modal';
|
||||
import VideoThumbnail from '@src/files-and-videos/videos-page/VideoThumbnail';
|
||||
import { GatedComponentWrapper } from '@src/generic/agreement-gated-feature';
|
||||
import { useModels } from '@src/generic/model-store';
|
||||
import { DeprecatedReduxState } from '@src/store';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
@@ -224,23 +226,24 @@ export const CourseVideosTable = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionRow>
|
||||
<ActionRow.Spacer />
|
||||
{isVideoTranscriptEnabled ? (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
openTranscriptSettings();
|
||||
handleErrorReset({ errorType: 'transcript' });
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.transcriptSettingsButtonLabel)}
|
||||
</Button>
|
||||
) : null}
|
||||
</ActionRow>
|
||||
{
|
||||
<GatedComponentWrapper gatingTypes={[AgreementGated.UPLOAD, AgreementGated.UPLOAD_VIDEOS]}>
|
||||
<>
|
||||
<ActionRow>
|
||||
<ActionRow.Spacer />
|
||||
{isVideoTranscriptEnabled ? (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
openTranscriptSettings();
|
||||
handleErrorReset({ errorType: 'transcript' });
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.transcriptSettingsButtonLabel)}
|
||||
</Button>
|
||||
) : null}
|
||||
</ActionRow>
|
||||
{
|
||||
loadingStatus !== RequestStatus.FAILED && (
|
||||
<>
|
||||
{isVideoTranscriptEnabled && (
|
||||
@@ -275,14 +278,15 @@ export const CourseVideosTable = () => {
|
||||
</>
|
||||
)
|
||||
}
|
||||
<UploadModal
|
||||
{...{
|
||||
isUploadTrackerOpen,
|
||||
currentUploadingIdsRef: uploadingIdsRef.current,
|
||||
handleUploadCancel,
|
||||
addVideoStatus,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<UploadModal
|
||||
{...{
|
||||
isUploadTrackerOpen,
|
||||
currentUploadingIdsRef: uploadingIdsRef.current,
|
||||
handleUploadCancel,
|
||||
addVideoStatus,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</GatedComponentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { AgreementGated } from '@src/constants';
|
||||
import { AlertAgreementGatedFeature } from '@src/generic/agreement-gated-feature';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
@@ -57,6 +59,9 @@ const VideosPage = () => {
|
||||
updateFileStatus={updateVideoStatus}
|
||||
loadingStatus={loadingStatus}
|
||||
/>
|
||||
<AlertAgreementGatedFeature
|
||||
gatingTypes={[AgreementGated.UPLOAD, AgreementGated.UPLOAD_VIDEOS]}
|
||||
/>
|
||||
<EditVideoAlertsSlot />
|
||||
<h2>{intl.formatMessage(messages.heading)}</h2>
|
||||
<CourseVideosSlot />
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { initializeMockApp, mergeConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { AgreementGated } from '@src/constants';
|
||||
import { getUserAgreementApi, getUserAgreementRecordApi } from '@src/data/api';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import React from 'react';
|
||||
import { AlertAgreementGatedFeature } from './AlertAgreementGatedFeature';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function renderComponent(gatingTypes: AgreementGated[]) {
|
||||
return render(
|
||||
<AppProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AlertAgreementGatedFeature gatingTypes={gatingTypes} />
|
||||
</QueryClientProvider>,
|
||||
</AppProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('AlertAgreementGatedFeature', () => {
|
||||
let axiosMock;
|
||||
beforeAll(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(getUserAgreementApi('agreement1')).reply(200, {
|
||||
type: 'agreement1',
|
||||
name: 'agreement1',
|
||||
summary: 'summary1',
|
||||
has_text: true,
|
||||
url: 'https://example.com/agreement1',
|
||||
updated: '2023-01-01T00:00:00Z',
|
||||
});
|
||||
axiosMock.onGet(getUserAgreementApi('agreement2')).reply(200, {
|
||||
type: 'agreement2',
|
||||
name: 'agreement2',
|
||||
summary: 'summary2',
|
||||
has_text: true,
|
||||
url: 'https://example.com/agreement2',
|
||||
});
|
||||
axiosMock.onGet(getUserAgreementApi('agreement3')).reply(404);
|
||||
axiosMock.onGet(getUserAgreementRecordApi('agreement1')).reply(200, {});
|
||||
axiosMock.onGet(getUserAgreementRecordApi('agreement2')).reply(200, {});
|
||||
mergeConfig({
|
||||
AGREEMENT_GATING: {
|
||||
[AgreementGated.UPLOAD]: ['agreement1', 'agreement2'],
|
||||
[AgreementGated.UPLOAD_VIDEOS]: ['agreement2'],
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
it('renders no alerts when gatingTypes is empty', async () => {
|
||||
await renderComponent([]);
|
||||
await waitFor(() => expect(queryClient.isFetching()).toBe(0));
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no alerts when gatingTypes have no associated agreement', async () => {
|
||||
await renderComponent([AgreementGated.UPLOAD_FILES]);
|
||||
await waitFor(() => expect(queryClient.isFetching()).toBe(0));
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no alerts when associated agreement does not exist', async () => {
|
||||
mergeConfig({
|
||||
AGREEMENT_GATING: {
|
||||
[AgreementGated.UPLOAD_FILES]: ['agreement3'],
|
||||
},
|
||||
});
|
||||
await renderComponent([AgreementGated.UPLOAD_FILES]);
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an alert for each agreement type associated with the gating types', async () => {
|
||||
const gatingTypes = [AgreementGated.UPLOAD];
|
||||
await renderComponent(gatingTypes);
|
||||
await waitFor(() => expect(queryClient.isFetching()).toBe(0));
|
||||
|
||||
expect(screen.queryAllByRole('alert')).toHaveLength(2);
|
||||
expect(screen.getByText('agreement1')).toBeInTheDocument();
|
||||
expect(screen.getByText('summary1')).toBeInTheDocument();
|
||||
expect(screen.getByText('agreement2')).toBeInTheDocument();
|
||||
expect(screen.getByText('summary2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders skips alerts for agreements that have already been accepted', async () => {
|
||||
const gatingTypes = [AgreementGated.UPLOAD];
|
||||
axiosMock.onGet(getUserAgreementRecordApi('agreement2')).reply(200, { is_current: true });
|
||||
await renderComponent(gatingTypes);
|
||||
await waitFor(() => expect(queryClient.isFetching()).toBe(0));
|
||||
|
||||
expect(screen.queryAllByRole('alert')).toHaveLength(1);
|
||||
expect(screen.getByText('agreement1')).toBeInTheDocument();
|
||||
expect(screen.getByText('summary1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('agreement2')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('summary2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not duplicate alert if multiple gating types have the same agreement type', async () => {
|
||||
const gatingTypes = [AgreementGated.UPLOAD, AgreementGated.UPLOAD_FILES];
|
||||
await renderComponent(gatingTypes);
|
||||
await waitFor(() => expect(queryClient.isFetching()).toBe(0));
|
||||
|
||||
expect(screen.queryAllByRole('alert')).toHaveLength(2);
|
||||
expect(screen.getByText('agreement1')).toBeInTheDocument();
|
||||
expect(screen.getByText('summary1')).toBeInTheDocument();
|
||||
expect(screen.getByText('agreement2')).toBeInTheDocument();
|
||||
expect(screen.getByText('summary2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('posts a request to mark acceptance when user clicks Agree', async () => {
|
||||
const user = userEvent.setup();
|
||||
const gatingTypes = [AgreementGated.UPLOAD_VIDEOS];
|
||||
await renderComponent(gatingTypes);
|
||||
await waitFor(() => expect(queryClient.isFetching()).toBe(0));
|
||||
axiosMock.onPost(new RegExp(getUserAgreementRecordApi('*'))).reply(201, {});
|
||||
await user.click(screen.getByRole('button', { name: 'Agree' }));
|
||||
expect(axiosMock.history.post[0].url).toBe(getUserAgreementRecordApi('agreement2'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Button, Hyperlink } from '@openedx/paragon';
|
||||
import { Policy } from '@openedx/paragon/icons';
|
||||
import { AgreementGated } from '@src/constants';
|
||||
import {
|
||||
getGatingAgreementTypes,
|
||||
useUserAgreement,
|
||||
useUserAgreementRecord,
|
||||
useUserAgreementRecordUpdater,
|
||||
} from '@src/data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
const AlertAgreement = ({ agreementType }: { agreementType: string }) => {
|
||||
const intl = useIntl();
|
||||
const { data, isLoading, isError } = useUserAgreement(agreementType);
|
||||
const mutation = useUserAgreementRecordUpdater(agreementType);
|
||||
const showAlert = data && !isLoading && !isError;
|
||||
const handleAcceptAgreement = async () => {
|
||||
try {
|
||||
await mutation.mutateAsync();
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error accepting agreement', e);
|
||||
}
|
||||
};
|
||||
if (!showAlert) { return null; }
|
||||
const { url, name, summary } = data;
|
||||
return (
|
||||
<Alert
|
||||
variant="warning"
|
||||
icon={Policy}
|
||||
actions={[
|
||||
<Hyperlink destination={url}>{intl.formatMessage(messages.learnMoreLinkLabel)}</Hyperlink>,
|
||||
<Button onClick={handleAcceptAgreement}>{intl.formatMessage(messages.agreeButtonLabel)}</Button>,
|
||||
]}
|
||||
>
|
||||
<Alert.Heading>{name}</Alert.Heading>
|
||||
{summary}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
const AlertAgreementWrapper = (
|
||||
{ agreementType }: { agreementType: string },
|
||||
) => {
|
||||
const { data, isLoading, isError } = useUserAgreementRecord(agreementType);
|
||||
const showAlert = !data?.isCurrent && !isLoading && !isError;
|
||||
if (!showAlert) { return null; }
|
||||
return <AlertAgreement agreementType={agreementType} />;
|
||||
};
|
||||
|
||||
export const AlertAgreementGatedFeature = (
|
||||
{ gatingTypes }: { gatingTypes: AgreementGated[] },
|
||||
) => {
|
||||
const agreementTypes = getGatingAgreementTypes(gatingTypes);
|
||||
return (
|
||||
<>
|
||||
{agreementTypes.map((agreementType) => (
|
||||
<AlertAgreementWrapper
|
||||
key={agreementType}
|
||||
agreementType={agreementType}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { initializeMockApp, mergeConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { AgreementGated } from '@src/constants';
|
||||
import { getUserAgreementRecordApi } from '@src/data/api';
|
||||
import { GatedComponentWrapper } from '@src/generic/agreement-gated-feature/GatedComponentWrapper';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import React from 'react';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function renderComponent(gatingTypes: AgreementGated[]) {
|
||||
return render(
|
||||
<AppProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GatedComponentWrapper gatingTypes={gatingTypes}>
|
||||
<button type="button">
|
||||
Test button
|
||||
</button>
|
||||
</GatedComponentWrapper>
|
||||
</QueryClientProvider>,
|
||||
</AppProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('GatedComponentWrapper', () => {
|
||||
let axiosMock;
|
||||
beforeAll(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(getUserAgreementRecordApi('agreement1')).reply(200, {});
|
||||
axiosMock.onGet(getUserAgreementRecordApi('agreement2')).reply(200, { is_current: true });
|
||||
mergeConfig({
|
||||
AGREEMENT_GATING: {
|
||||
[AgreementGated.UPLOAD]: ['agreement1', 'agreement2'],
|
||||
[AgreementGated.UPLOAD_VIDEOS]: ['agreement2'],
|
||||
[AgreementGated.UPLOAD_FILES]: ['agreement1'],
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
it('applies no gating when gatingTypes is empty', async () => {
|
||||
await renderComponent([]);
|
||||
await waitFor(() => expect(queryClient.isFetching()).toBe(0));
|
||||
expect(screen.getByRole('button').parentNode).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('applies no gating when associated agreement has been accepted', async () => {
|
||||
await renderComponent([AgreementGated.UPLOAD_VIDEOS]);
|
||||
await waitFor(() => expect(queryClient.isFetching()).toBe(0));
|
||||
expect(screen.getByRole('button').parentNode).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('applies gating when associated agreement has not been accepted', async () => {
|
||||
await renderComponent([AgreementGated.UPLOAD_FILES]);
|
||||
await waitFor(() => expect(queryClient.isFetching()).toBe(0));
|
||||
expect(screen.getByRole('button').parentNode).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { AgreementGated } from '@src/constants';
|
||||
import {
|
||||
getGatingAgreementTypes,
|
||||
useUserAgreementRecords,
|
||||
} from '@src/data/apiHooks';
|
||||
|
||||
interface GatedComponentWrapperProps {
|
||||
gatingTypes: AgreementGated[];
|
||||
children: React.ReactElement;
|
||||
}
|
||||
|
||||
export const GatedComponentWrapper = (
|
||||
{ gatingTypes, children }: GatedComponentWrapperProps,
|
||||
) => {
|
||||
const agreementTypes = getGatingAgreementTypes(gatingTypes);
|
||||
const results = useUserAgreementRecords(agreementTypes);
|
||||
const isNotGated = results.every((result) => !!result?.data?.isCurrent);
|
||||
return isNotGated ? children : (
|
||||
<div
|
||||
aria-disabled
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
filter: 'blur(2px)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
2
src/generic/agreement-gated-feature/index.ts
Normal file
2
src/generic/agreement-gated-feature/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AlertAgreementGatedFeature } from './AlertAgreementGatedFeature';
|
||||
export { GatedComponentWrapper } from './GatedComponentWrapper';
|
||||
16
src/generic/agreement-gated-feature/messages.ts
Normal file
16
src/generic/agreement-gated-feature/messages.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
agreeButtonLabel: {
|
||||
id: 'authoring.agreement-gated-feature.agree',
|
||||
defaultMessage: 'Agree',
|
||||
description: 'The label for the Agree button on an alert asking users to agree with terms.',
|
||||
},
|
||||
learnMoreLinkLabel: {
|
||||
id: 'authoring.agreement-gated-feature.learn-more',
|
||||
defaultMessage: 'Learn more',
|
||||
description: 'The label for a "learn more" link on an alert asking users to agree with terms.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,81 +1,5 @@
|
||||
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints.css" as paragonCustomMediaBreakpoints;
|
||||
|
||||
// Remove global primary color overrides - only apply to specific elements
|
||||
|
||||
// Button overrides - Andal orange
|
||||
.btn-primary {
|
||||
background-color: #ff4f00 !important;
|
||||
border-color: #ff4f00 !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #cc3f00 !important;
|
||||
border-color: #cc3f00 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: #ff4f00 !important;
|
||||
border-color: #ff4f00 !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #ff4f00 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-brand {
|
||||
background-color: #ff4f00 !important;
|
||||
border-color: #ff4f00 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
// Navbar/Header - Andal orange
|
||||
.header {
|
||||
background-color: #ff4f00 !important;
|
||||
|
||||
.nav-link.active,
|
||||
.nav-item.active {
|
||||
background-color: #cc3f00 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Tabs - Andal orange
|
||||
.nav-tabs {
|
||||
.nav-link.active {
|
||||
background-color: #ff4f00 !important;
|
||||
border-color: #ff4f00 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
border-color: #ff4f00 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Search field - keep original colors
|
||||
.pgn__searchfield {
|
||||
// Remove any orange overrides - keep default
|
||||
}
|
||||
|
||||
// Dropdowns - keep original
|
||||
.dropdown-toggle {
|
||||
// Keep original colors
|
||||
}
|
||||
|
||||
// Cards - keep original
|
||||
.pgn__card {
|
||||
// Keep original colors
|
||||
}
|
||||
|
||||
// Links - keep original black
|
||||
a {
|
||||
color: #000;
|
||||
|
||||
&:hover {
|
||||
color: #ff4f00;
|
||||
}
|
||||
}
|
||||
|
||||
@import "~@edx/frontend-component-header/dist/index";
|
||||
@import "assets/scss/variables";
|
||||
@import "assets/scss/form";
|
||||
@@ -117,7 +41,7 @@ div.row:has(> div > div.highlight) {
|
||||
|
||||
// To apply selection style to selected Section/Subsecion/Units, in the Course Outline
|
||||
div.row:has(> div > div.outline-card-selected) {
|
||||
box-shadow: 0 0 3px 3px #ff4f00 !important;
|
||||
box-shadow: 0 0 3px 3px var(--pgn-color-primary-500) !important;
|
||||
}
|
||||
|
||||
// To apply the glow effect to the selected xblock, in the Unit Outline
|
||||
@@ -128,7 +52,7 @@ div.xblock-highlight {
|
||||
|
||||
@keyframes glow {
|
||||
0% {
|
||||
box-shadow: 0 0 5px 5px #ff4f00;
|
||||
box-shadow: 0 0 5px 5px var(--pgn-color-primary-500);
|
||||
}
|
||||
|
||||
100% {
|
||||
|
||||
@@ -63,9 +63,6 @@ describe('create library apiHooks', () => {
|
||||
expect(invalidateQueriesSpy).toHaveBeenCalledWith({
|
||||
queryKey: libraryAuthoringQueryKeys.contentLibraryList(),
|
||||
});
|
||||
expect(invalidateQueriesSpy).toHaveBeenCalledWith({
|
||||
queryKey: ['content_search'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ 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,6 +16,7 @@ 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';
|
||||
@@ -42,6 +43,7 @@ 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>;
|
||||
@@ -72,6 +74,7 @@ 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