Skip to content

Commit

Permalink
Merge branch 'master' into rpenido/fal-3600-merge-library-authoring
Browse files Browse the repository at this point in the history
  • Loading branch information
rpenido committed Jan 5, 2024
2 parents 996a316 + 2205506 commit 43b54f2
Show file tree
Hide file tree
Showing 28 changed files with 351 additions and 68 deletions.
9 changes: 8 additions & 1 deletion src/CourseAuthoringPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { StudioFooter } from '@edx/frontend-component-footer';
import Header from './header';
import { fetchCourseDetail } from './data/thunks';
import { useModel } from './generic/model-store';
import NotFoundAlert from './generic/NotFoundAlert';
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants';
Expand Down Expand Up @@ -50,10 +51,16 @@ const CourseAuthoringPage = ({ courseId, children }) => {
const courseOrg = courseDetail ? courseDetail.org : null;
const courseTitle = courseDetail ? courseDetail.name : courseId;
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
const inProgress = useSelector(state => state.courseDetail.status) === RequestStatus.IN_PROGRESS;
const courseDetailStatus = useSelector(state => state.courseDetail.status);
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS;
const { pathname } = useLocation();
const showHeader = !pathname.includes('/editor');

if (courseDetailStatus === RequestStatus.NOT_FOUND) {
return (
<NotFoundAlert />
);
}
if (courseAppsApiStatus === RequestStatus.DENIED) {
return (
<PermissionDeniedAlert />
Expand Down
79 changes: 67 additions & 12 deletions src/CourseAuthoringPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import CourseAuthoringPage from './CourseAuthoringPage';
import PagesAndResources from './pages-and-resources/PagesAndResources';
import { executeThunk } from './utils';
import { fetchCourseApps } from './pages-and-resources/data/thunks';
import { fetchCourseDetail } from './data/thunks';

const courseId = 'course-v1:edX+TestX+Test_Course';
let mockPathname = '/evilguy/';
Expand All @@ -24,6 +25,19 @@ jest.mock('react-router-dom', () => ({
let axiosMock;
let store;

beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});

describe('Editor Pages Load no header', () => {
const mockStoreSuccess = async () => {
const apiBaseUrl = getConfig().STUDIO_BASE_URL;
Expand All @@ -33,18 +47,6 @@ describe('Editor Pages Load no header', () => {
});
await executeThunk(fetchCourseApps(courseId), store.dispatch);
};
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
test('renders no loading wheel on editor pages', async () => {
mockPathname = '/editor/';
await mockStoreSuccess();
Expand Down Expand Up @@ -76,3 +78,56 @@ describe('Editor Pages Load no header', () => {
expect(wrapper.queryByRole('status')).toBeInTheDocument();
});
});

describe('Course authoring page', () => {
const lmsApiBaseUrl = getConfig().LMS_BASE_URL;
const courseDetailApiUrl = `${lmsApiBaseUrl}/api/courses/v1/courses`;
const mockStoreNotFound = async () => {
axiosMock.onGet(
`${courseDetailApiUrl}/${courseId}?username=abc123`,
).reply(404, {
response: { status: 404 },
});
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
};
const mockStoreError = async () => {
axiosMock.onGet(
`${courseDetailApiUrl}/${courseId}?username=abc123`,
).reply(500, {
response: { status: 500 },
});
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
};
test('renders not found page on non-existent course key', async () => {
await mockStoreNotFound();
const wrapper = render(
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseAuthoringPage courseId={courseId} />
</IntlProvider>
</AppProvider>
,
);
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
});
test('does not render not found page on other kinds of error', async () => {
await mockStoreError();
// Currently, loading errors are not handled, so we wait for the child
// content to be rendered -which happens when request status is no longer
// IN_PROGRESS but also not NOT_FOUND or DENIED- then check that the not
// found alert is not present.
const contentTestId = 'courseAuthoringPageContent';
const wrapper = render(
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseAuthoringPage courseId={courseId}>
<div data-testid={contentTestId} />
</CourseAuthoringPage>
</IntlProvider>
</AppProvider>
,
);
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
expect(wrapper.queryByTestId('notFoundAlert')).not.toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions src/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const RequestStatus = {
PENDING: 'pending',
CLEAR: 'clear',
PARTIAL: 'partial',
NOT_FOUND: 'not-found',
};

/**
Expand Down
6 changes: 5 additions & 1 deletion src/data/thunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ export function fetchCourseDetail(courseId) {
canChangeProviders: getAuthenticatedUser().administrator || new Date(courseDetail.start) > new Date(),
}));
} catch (error) {
dispatch(updateStatus({ courseId, status: RequestStatus.FAILED }));
if (error.response && error.response.status === 404) {
dispatch(updateStatus({ courseId, status: RequestStatus.NOT_FOUND }));
} else {
dispatch(updateStatus({ courseId, status: RequestStatus.FAILED }));
}
}
};
}
14 changes: 14 additions & 0 deletions src/generic/NotFoundAlert.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';

const NotFoundAlert = () => (
<Alert variant="danger" data-testid="notFoundAlert">
<FormattedMessage
id="authoring.alert.error.notfound"
defaultMessage="Not found."
/>
</Alert>
);

export default NotFoundAlert;
7 changes: 4 additions & 3 deletions src/generic/data/api.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

Expand All @@ -9,8 +10,8 @@ export const getCourseRerunUrl = (courseId) => new URL(`/api/contentstore/v1/cou
export const getOrganizationsUrl = () => new URL('organizations', getApiBaseUrl()).href;

/**
* Get's organizations data.
* @returns {Promise<Object>}
* Get's organizations data. Returns list of organization names.
* @returns {Promise<string[]>}
*/
export async function getOrganizations() {
const { data } = await getAuthenticatedHttpClient().get(
Expand All @@ -32,7 +33,7 @@ export async function getCourseRerun(courseId) {

/**
* Create or rerun course with data.
* @param {object} data
* @param {object} courseData
* @returns {Promise<Object>}
*/
export async function createOrRerunCourse(courseData) {
Expand Down
15 changes: 15 additions & 0 deletions src/generic/data/apiHooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// @ts-check
import { useQuery } from '@tanstack/react-query';
import { getOrganizations } from './api';

/**
* Builds the query to get a list of available organizations
*/
export const useOrganizationListData = () => (
useQuery({
queryKey: ['organizationList'],
queryFn: () => getOrganizations(),
})
);

export default useOrganizationListData;
9 changes: 5 additions & 4 deletions src/import-page/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,12 @@ export const getImportStatusApiUrl = (courseId, fileName) => `${getApiBaseUrl()}
* @param {Object} requestConfig
* @returns {Promise<Object>}
*/
export async function startCourseImporting(courseId, fileData, requestConfig) {
export async function startCourseImporting(courseId, fileData, requestConfig, updateProgress) {
const chunkSize = 20 * 1000000; // 20 MB
const fileSize = fileData.size || 0;
const chunkLength = Math.ceil(fileSize / chunkSize);
let resp;

const upload = async (blob, start, stop) => {
const upload = async (blob, start, stop, index) => {
const contentRange = `bytes ${start}-${stop}/${fileSize}`;
const contentDisposition = `attachment; filename="${fileData.name}"`;
const headers = {
Expand All @@ -33,14 +32,16 @@ export async function startCourseImporting(courseId, fileData, requestConfig) {
formData,
{ headers, ...requestConfig },
);
const percent = Math.trunc(((1 / chunkLength) * (index + 1)) * 100);
updateProgress(percent);
resp = camelCaseObject(data);
};

const chunkUpload = async (file, index) => {
const start = index * chunkSize;
const stop = start + chunkSize < fileSize ? start + chunkSize : fileSize;
const blob = file.slice(start, stop, file.type);
await upload(blob, start, stop - 1);
await upload(blob, start, stop - 1, index);
};

/* eslint-disable no-await-in-loop */
Expand Down
2 changes: 1 addition & 1 deletion src/import-page/data/api.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe('API Functions', () => {
const data = { importStatus: 1 };
axiosMock.onPost(postImportCourseApiUrl(courseId)).reply(200, data);

const result = await startCourseImporting(courseId, file);
const result = await startCourseImporting(courseId, file, {}, jest.fn());
expect(axiosMock.history.post[0].url).toEqual(postImportCourseApiUrl(courseId));
expect(result).toEqual(data);
});
Expand Down
11 changes: 8 additions & 3 deletions src/import-page/data/thunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { RequestStatus } from '../../data/constants';
import { setImportCookie } from '../utils';
import { getImportStatus, startCourseImporting } from './api';
import {
reset, updateCurrentStage, updateError, updateFileName,
reset, updateCurrentStage, updateError, updateFileName, updateProgress,
updateImportTriggered, updateLoadingStatus, updateSavingStatus, updateSuccessDate,
} from './slice';
import { IMPORT_STAGES, LAST_IMPORT_COOKIE_NAME } from './constants';
Expand Down Expand Up @@ -44,9 +44,14 @@ export function handleProcessUpload(courseId, fileData, requestConfig, handleErr
const file = fileData.get('file');
dispatch(reset());
dispatch(updateSavingStatus(RequestStatus.PENDING));
dispatch(updateImportTriggered(true));
dispatch(updateFileName(file.name));
const { importStatus } = await startCourseImporting(courseId, file, requestConfig);
dispatch(updateImportTriggered(true));
const { importStatus } = await startCourseImporting(
courseId,
file,
requestConfig,
(percent) => dispatch(updateProgress(percent)),
);
dispatch(updateCurrentStage(importStatus));
setImportCookie(moment().valueOf(), importStatus === IMPORT_STAGES.SUCCESS, file.name);
dispatch(updateSavingStatus(RequestStatus.SUCCESSFUL));
Expand Down
2 changes: 0 additions & 2 deletions src/import-page/file-section/FileSection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { IMPORT_STAGES } from '../data/constants';
import {
getCurrentStage, getError, getFileName, getImportTriggered,
} from '../data/selectors';
import { updateProgress } from '../data/slice';
import messages from './messages';
import { handleProcessUpload } from '../data/thunks';

Expand Down Expand Up @@ -42,7 +41,6 @@ const FileSection = ({ intl, courseId }) => {
handleError,
))
}
onUploadProgress={(percent) => dispatch(updateProgress(percent))}
accept={{ 'application/gzip': ['.tar.gz'] }}
data-testid="dropzone"
/>
Expand Down
2 changes: 1 addition & 1 deletion src/import-page/import-stepper/ImportStepper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const ImportStepper = ({ intl, courseId }) => {
<h3 className="mt-4">{intl.formatMessage(messages.stepperHeaderTitle)}</h3>
<CourseStepper
courseId={courseId}
percent={progress}
percent={currentStage === IMPORT_STAGES.UPLOADING ? progress : null}
steps={steps}
activeKey={currentStage}
hasError={hasError}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ const OpenedXConfigForm = ({
unitLevelVisibility: true,
allowAnonymousPostsPeers: appConfigObj?.allowAnonymousPostsPeers || false,
reportedContentEmailNotifications: appConfigObj?.reportedContentEmailNotifications || false,
enableReportedContentEmailNotifications: Boolean(appConfigObj?.enableReportedContentEmailNotifications) || false,
restrictedDates: appConfigObj?.restrictedDates || [],
discussionTopics: discussionTopicsModel || [],
divideByCohorts: appConfigObj?.divideByCohorts || false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ const defaultAppConfig = (divideDiscussionIds = []) => ({
allowAnonymousPosts: false,
allowAnonymousPostsPeers: false,
reportedContentEmailNotifications: false,
enableReportedContentEmailNotifications: false,
allowDivisionByUnit: false,
restrictedDates: [],
cohortsEnabled: false,
Expand Down Expand Up @@ -141,7 +140,6 @@ describe('OpenedXConfigForm', () => {
...legacyApiResponse,
plugin_configuration: {
...legacyApiResponse.plugin_configuration,
reported_content_email_notifications_flag: true,
divided_course_wide_discussions: [],
available_division_schemes: [],
},
Expand Down Expand Up @@ -181,7 +179,6 @@ describe('OpenedXConfigForm', () => {
...legacyApiResponse.plugin_configuration,
allow_anonymous: true,
reported_content_email_notifications: true,
reported_content_email_notifications_flag: true,
always_divide_inline_discussions: true,
divided_course_wide_discussions: [],
available_division_schemes: ['cohorts'],
Expand Down Expand Up @@ -222,7 +219,6 @@ describe('OpenedXConfigForm', () => {
...legacyApiResponse.plugin_configuration,
allow_anonymous: true,
reported_content_email_notifications: true,
reported_content_email_notifications_flag: true,
always_divide_inline_discussions: true,
divided_course_wide_discussions: ['13f106c6-6735-4e84-b097-0456cff55960', 'course'],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,19 @@ const ReportedContentEmailNotifications = ({ intl }) => {
} = useFormikContext();

return (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
{values.enableReportedContentEmailNotifications && (
<div>
<h5 className="text-gray-500 mt-4 mb-2 ">{intl.formatMessage(messages.reportedContentEmailNotifications)}</h5>
<FormSwitchGroup
className="mb-4"
onChange={handleChange}
onBlur={handleBlur}
id="reportedContentEmailNotifications"
checked={values.reportedContentEmailNotifications}
label={intl.formatMessage(messages.reportedContentEmailNotificationsLabel)}
helpText={intl.formatMessage(messages.reportedContentEmailNotificationsHelp)}
/>
<AppConfigFormDivider thick />
</div>
)}
</>
<div>
<h5 className="text-gray-500 mt-4 mb-2 ">{intl.formatMessage(messages.reportedContentEmailNotifications)}</h5>
<FormSwitchGroup
className="mb-4"
onChange={handleChange}
onBlur={handleBlur}
id="reportedContentEmailNotifications"
checked={values.reportedContentEmailNotifications}
label={intl.formatMessage(messages.reportedContentEmailNotificationsLabel)}
helpText={intl.formatMessage(messages.reportedContentEmailNotificationsHelp)}
/>
<AppConfigFormDivider thick />
</div>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ const appConfig = {
allowAnonymousPosts: false,
allowAnonymousPostsPeers: false,
reportedContentEmailNotifications: false,
enableReportedContentEmailNotifications: false,
allowDivisionByUnit: false,
restrictedDates: [],
};
Expand Down
1 change: 0 additions & 1 deletion src/pages-and-resources/discussions/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ function normalizePluginConfig(data) {
allowAnonymousPosts: data.allow_anonymous,
allowAnonymousPostsPeers: data.allow_anonymous_to_peers,
reportedContentEmailNotifications: data.reported_content_email_notifications,
enableReportedContentEmailNotifications: data.reported_content_email_notifications_flag,
divisionScheme: data.division_scheme,
alwaysDivideInlineDiscussions: data.always_divide_inline_discussions,
restrictedDates: normalizeRestrictedDates(data.discussion_blackouts),
Expand Down
Loading

0 comments on commit 43b54f2

Please sign in to comment.