refactor: Migration of course details to React query (#2724)

- Migrates the `courseDetails` part from the Redux Store to React Query.
- Creates a new `CourseAuthoringContext` 
- Update the pages in `<CourseAuthoringRoutes>` to use the newly created context.
- Migrates some files to Typescript
- Migrates some tests to use `src/testUtils.tsx`
This commit is contained in:
Chris Chávez
2025-12-05 19:14:32 -05:00
committed by GitHub
parent 50f4f70671
commit dad736f9d1
77 changed files with 1396 additions and 1601 deletions

View File

@@ -4,21 +4,16 @@ import {
getByRole,
getAllByRole,
waitForElementToBeRemoved,
} from '@testing-library/react';
initializeMocks,
} from 'CourseAuthoring/testUtils';
import ReactDOM from 'react-dom';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import userEvent from '@testing-library/user-event';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
@@ -40,17 +35,20 @@ ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store} wrapWithRouter={false}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={[liveSettingsUrl]}>
<Routes>
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
</Routes>
</MemoryRouter>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
<CourseAuthoringProvider courseId={courseId}>
<PagesAndResourcesProvider courseId={courseId}>
<LiveSettings onClose={() => {}} />
</PagesAndResourcesProvider>
</CourseAuthoringProvider>,
{
path: liveSettingsUrl,
routerProps: {
initialEntries: [liveSettingsUrl],
},
params: {
courseId,
},
},
);
container = wrapper.container;
};
@@ -74,16 +72,9 @@ const mockStore = async ({
describe('BBB Settings', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const mocks = initializeMocks({ initialState });
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
});
test('Plan dropdown to be visible and enabled in UI', async () => {

View File

@@ -11,6 +11,7 @@ import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-m
import { useModel } from 'CourseAuthoring/generic/model-store';
import Loading from 'CourseAuthoring/generic/Loading';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import { useCourseAuthoringContext } from 'CourseAuthoring/CourseAuthoringContext';
import { fetchLiveData, saveLiveConfiguration, saveLiveConfigurationAsDraft } from './data/thunks';
import { selectApp } from './data/slice';
@@ -25,7 +26,7 @@ const LiveSettings = ({
const intl = useIntl();
const navigate = useNavigate();
const dispatch = useDispatch();
const courseId = useSelector(state => state.courseDetail.courseId);
const { courseId } = useCourseAuthoringContext();
const availableProviders = useSelector((state) => state.live.appIds);
const {
piiSharingAllowed, selectedAppId, enabled, status,

View File

@@ -8,20 +8,14 @@ import {
queryByText,
getByRole,
waitForElementToBeRemoved,
} from '@testing-library/react';
initializeMocks,
} from 'CourseAuthoring/testUtils';
import ReactDOM from 'react-dom';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
@@ -44,17 +38,20 @@ ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store} wrapWithRouter={false}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={[liveSettingsUrl]}>
<Routes>
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
</Routes>
</MemoryRouter>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
<PagesAndResourcesProvider courseId={courseId}>
<CourseAuthoringProvider>
<LiveSettings onClose={() => {}} />
</CourseAuthoringProvider>
</PagesAndResourcesProvider>,
{
path: liveSettingsUrl,
routerProps: {
initialEntries: [liveSettingsUrl],
},
params: {
courseId,
},
},
);
container = wrapper.container;
};
@@ -77,16 +74,11 @@ const mockStore = async ({
describe('LiveSettings', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
const mocks = initializeMocks({
initialState,
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
});
test('Live Configuration modal is visible', async () => {

View File

@@ -3,19 +3,14 @@ import {
queryByTestId,
getByRole,
waitForElementToBeRemoved,
} from '@testing-library/react';
initializeMocks,
} from 'CourseAuthoring/testUtils';
import ReactDOM from 'react-dom';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
@@ -38,17 +33,20 @@ ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store} wrapWithRouter={false}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={[liveSettingsUrl]}>
<Routes>
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
</Routes>
</MemoryRouter>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
<CourseAuthoringProvider courseId={courseId}>
<PagesAndResourcesProvider courseId={courseId}>
<LiveSettings onClose={() => {}} />
</PagesAndResourcesProvider>
</CourseAuthoringProvider>,
{
path: liveSettingsUrl,
routerProps: {
initialEntries: [liveSettingsUrl],
},
params: {
courseId,
},
},
);
container = wrapper.container;
};
@@ -71,16 +69,9 @@ const mockStore = async ({
describe('Zoom Settings', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const mocks = initializeMocks({ initialState });
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
});
test('LTI fields are visible when pii sharing is enabled', async () => {

View File

@@ -22,6 +22,7 @@ import { useModel } from 'CourseAuthoring/generic/model-store';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import { useIsMobile } from 'CourseAuthoring/utils';
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { useCourseAuthoringContext } from 'CourseAuthoring/CourseAuthoringContext';
import messages from './messages';
@@ -66,7 +67,7 @@ const ProctoringSettings = ({ onClose }) => {
}
const { courseId } = useContext(PagesAndResourcesContext);
const courseDetails = useModel('courseDetails', courseId);
const { courseDetails } = useCourseAuthoringContext();
const org = courseDetails?.org;
const appInfo = useModel('courseApps', 'proctoring');
const alertRef = React.createRef();

View File

@@ -1,18 +1,15 @@
import React from 'react';
import {
render, screen, cleanup, waitFor, fireEvent, act,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
initializeMocks,
} from 'CourseAuthoring/testUtils';
import { initializeMockApp, mergeConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { mergeConfig } from '@edx/frontend-platform';
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService';
import initializeStore from 'CourseAuthoring/store';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
import { getCourseDetailsUrl } from 'CourseAuthoring/data/api';
import ProctoredExamSettings from './Settings';
const courseId = 'course-v1%3AedX%2BDemoX%2BDemo_Course';
@@ -20,16 +17,13 @@ const defaultProps = {
courseId,
onClose: () => {},
};
let store;
const intlWrapper = children => (
<AppProvider store={store}>
const renderComponent = children => (
<CourseAuthoringProvider courseId={defaultProps.courseId}>
<PagesAndResourcesProvider courseId={defaultProps.courseId}>
<IntlProvider locale="en">
{children}
</IntlProvider>
{children}
</PagesAndResourcesProvider>
</AppProvider>
</CourseAuthoringProvider>
);
let axiosMock;
@@ -38,38 +32,38 @@ describe('ProctoredExamSettings', () => {
mergeConfig({
EXAMS_BASE_URL: 'http://exams.testing.co',
}, 'CourseAuthoringConfig');
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: isAdmin,
roles: [],
},
});
store = initializeStore({
models: {
courseApps: {
proctoring: {},
},
courseDetails: {
[courseId]: {
start: Date(),
const user = {
userId: 3,
username: 'abc123',
administrator: isAdmin,
roles: [],
};
const mocks = initializeMocks({
user,
initialState: {
models: {
courseApps: {
proctoring: {},
},
},
...(org ? { courseDetails: { [courseId]: { org } } } : {}),
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers${org ? `?org=${org}` : ''}`,
).reply(200, [
{
name: 'test_lti',
verbose_name: 'LTI Provider',
},
]);
axiosMock = mocks.axiosMock;
axiosMock
.onGet(getCourseDetailsUrl(courseId, user.username))
.reply(200, {
courseId,
name: 'Course Test',
start: Date(),
...(org ? { org } : {}),
});
axiosMock.onGet(`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`)
.reply(200, [{ name: 'test_lti', verbose_name: 'LTI Provider' }]);
if (org) {
axiosMock.onGet(`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers?org=${org}`)
.reply(200, [{ name: 'test_lti', verbose_name: 'LTI Provider' }]);
}
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, {
@@ -92,17 +86,13 @@ describe('ProctoredExamSettings', () => {
});
}
afterEach(() => {
cleanup();
axiosMock.reset();
});
beforeEach(async () => {
setupApp();
});
describe('Field dependencies', () => {
beforeEach(async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
});
it('Updates Zendesk ticket field if software_secure is provider', async () => {
@@ -153,7 +143,7 @@ describe('ProctoredExamSettings', () => {
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByText('Proctored exams');
});
@@ -234,7 +224,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {});
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
});
proctoringProvidersRequiringEscalationEmail.forEach(provider => {
@@ -420,7 +410,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure');
expect(providerOption.hasAttribute('disabled')).toEqual(true);
});
@@ -429,7 +419,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -439,7 +429,7 @@ describe('ProctoredExamSettings', () => {
const org = 'test-org';
setupApp(isAdmin, org);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -448,7 +438,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -457,7 +447,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -468,7 +458,7 @@ describe('ProctoredExamSettings', () => {
available_proctoring_providers: ['lti_external', 'mockproc'],
};
mockCourseData(courseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -481,7 +471,7 @@ describe('ProctoredExamSettings', () => {
available_proctoring_providers: ['lti_external', 'mockproc'],
};
mockCourseData(courseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -494,7 +484,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -508,12 +498,13 @@ describe('ProctoredExamSettings', () => {
EXAMS_BASE_URL: null,
}, 'CourseAuthoringConfig');
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
// only outgoing request should be for studio settings
expect(axiosMock.history.get.length).toBe(1);
// (1) for studio settings
// (2) for course details
expect(axiosMock.history.get.length).toBe(2);
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
});
@@ -527,7 +518,7 @@ describe('ProctoredExamSettings', () => {
).reply(200, {
provider: 'test_lti',
});
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByText('Proctoring provider');
});
@@ -540,14 +531,14 @@ describe('ProctoredExamSettings', () => {
describe('Toggles field visibility based on user permissions', () => {
it('Hides opting out and zendesk tickets for non edX staff', async () => {
setupApp(false);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
});
it('Shows opting out and zendesk tickets for edX staff', async () => {
setupApp(true);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
});
@@ -555,7 +546,7 @@ describe('ProctoredExamSettings', () => {
describe('Connection states', () => {
it('Shows the spinner before the connection is complete', async () => {
render(intlWrapper(<ProctoredExamSettings {...defaultProps} />));
render(renderComponent(<ProctoredExamSettings {...defaultProps} />));
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
@@ -565,7 +556,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'),
@@ -577,7 +568,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
).reply(500);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'),
@@ -589,7 +580,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(403);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const permissionError = screen.getByTestId('permissionDeniedAlert');
expect(permissionError.textContent).toEqual(
expect.stringContaining('You are not authorized to view this page'),
@@ -608,7 +599,7 @@ describe('ProctoredExamSettings', () => {
});
it('Disable button while submitting', async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
let submitButton = screen.getByTestId('submissionButton');
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
fireEvent.click(submitButton);
@@ -634,7 +625,7 @@ describe('ProctoredExamSettings', () => {
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to test_lti and set the email
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
@@ -670,7 +661,7 @@ describe('ProctoredExamSettings', () => {
});
it('Makes API call successfully without proctoring_escalation_email if not requiring escalation email', async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
// make sure we have not selected a provider requiring escalation email
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
@@ -697,7 +688,7 @@ describe('ProctoredExamSettings', () => {
});
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to test_lti and set the email
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
@@ -739,7 +730,7 @@ describe('ProctoredExamSettings', () => {
});
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
// update exam service config
@@ -784,7 +775,7 @@ describe('ProctoredExamSettings', () => {
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
// does not update exam service config
@@ -814,7 +805,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -832,7 +823,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(500, 'error');
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -850,7 +841,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(403, 'error');
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -869,7 +860,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -902,7 +893,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = false;
setupApp(isAdmin);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
// Make a change to the proctoring provider
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'software_secure' } });

View File

@@ -0,0 +1,66 @@
import { createContext, useContext, useMemo } from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { CourseDetailsData } from './data/api';
import { useCourseDetails } from './data/apiHooks';
import { RequestStatusType } from './data/constants';
export type CourseAuthoringContextData = {
/** The ID of the current course */
courseId: string;
courseDetails?: CourseDetailsData;
courseDetailStatus: RequestStatusType;
canChangeProviders: boolean;
};
/**
* Course Authoring Context.
* Always available when we're in the context of a single course.
*
* Get this using `useCourseAuthoringContext()`
*
*/
const CourseAuthoringContext = createContext<CourseAuthoringContextData | undefined>(undefined);
type CourseAuthoringProviderProps = {
children?: React.ReactNode;
courseId: string;
};
export const CourseAuthoringProvider = ({
children,
courseId,
}: CourseAuthoringProviderProps) => {
const { data: courseDetails, status: courseDetailStatus } = useCourseDetails(courseId);
const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date();
const context = useMemo<CourseAuthoringContextData>(() => {
const contextValue = {
courseId,
courseDetails,
courseDetailStatus,
canChangeProviders,
};
return contextValue;
}, [
courseId,
courseDetails,
courseDetailStatus,
canChangeProviders,
]);
return (
<CourseAuthoringContext.Provider value={context}>
{children}
</CourseAuthoringContext.Provider>
);
};
export function useCourseAuthoringContext(): CourseAuthoringContextData {
const ctx = useContext(CourseAuthoringContext);
if (ctx === undefined) {
/* istanbul ignore next */
throw new Error('useCourseAuthoringContext() was used in a component without a <CourseAuthoringProvider> ancestor.');
}
return ctx;
}

View File

@@ -4,9 +4,9 @@ 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';
import { getApiWaffleFlagsUrl } from './data/api';
import { initializeMocks, render } from './testUtils';
import { CourseAuthoringProvider } from './CourseAuthoringContext';
const courseId = 'course-v1:edX+TestX+Test_Course';
let mockPathname = '/evilguy/';
@@ -19,6 +19,12 @@ jest.mock('react-router-dom', () => ({
let axiosMock;
let store;
const renderComponent = children => render(
<CourseAuthoringProvider courseId={courseId}>
{children}
</CourseAuthoringProvider>,
);
beforeEach(async () => {
const mocks = initializeMocks();
store = mocks.reduxStore;
@@ -35,14 +41,13 @@ describe('Editor Pages Load no header', () => {
axiosMock.onGet(`${courseAppsApiUrl}/${courseId}`).reply(200, {
response: { status: 200 },
});
await executeThunk(fetchCourseApps(courseId), store.dispatch);
};
test('renders no loading wheel on editor pages', async () => {
mockPathname = '/editor/';
await mockStoreSuccess();
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
const wrapper = renderComponent(
<CourseAuthoringPage>
<PagesAndResources />
</CourseAuthoringPage>
,
);
@@ -51,9 +56,9 @@ describe('Editor Pages Load no header', () => {
test('renders loading wheel on non editor pages', async () => {
mockPathname = '/evilguy/';
await mockStoreSuccess();
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
const wrapper = renderComponent(
<CourseAuthoringPage>
<PagesAndResources />
</CourseAuthoringPage>
,
);
@@ -70,7 +75,6 @@ describe('Course authoring page', () => {
).reply(404, {
response: { status: 404 },
});
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
};
const mockStoreError = async () => {
axiosMock.onGet(
@@ -78,11 +82,10 @@ describe('Course authoring page', () => {
).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(<CourseAuthoringPage courseId={courseId} />);
const wrapper = renderComponent(<CourseAuthoringPage />);
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
});
test('does not render not found page on other kinds of error', async () => {
@@ -92,8 +95,8 @@ describe('Course authoring page', () => {
// 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(
<CourseAuthoringPage courseId={courseId}>
const wrapper = renderComponent(
<CourseAuthoringPage>
<div data-testid={contentTestId} />
</CourseAuthoringPage>
,
@@ -114,7 +117,7 @@ describe('Course authoring page', () => {
mockPathname = '/editor/';
await mockStoreDenied();
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
const wrapper = renderComponent(<CourseAuthoringPage />);
expect(await wrapper.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
});
});

View File

@@ -1,5 +1,4 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import {
@@ -7,34 +6,31 @@ import {
} from 'react-router-dom';
import { StudioFooterSlot } 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 { fetchOnlyStudioHomeData } from './studio-home/data/thunks';
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants';
import Loading from './generic/Loading';
import { useCourseAuthoringContext } from './CourseAuthoringContext';
const CourseAuthoringPage = ({ courseId, children }) => {
interface Props {
children?: React.ReactNode;
}
const CourseAuthoringPage = ({ children }: Props) => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchCourseDetail(courseId));
}, [courseId]);
useEffect(() => {
dispatch(fetchOnlyStudioHomeData());
}, []);
const courseDetail = useModel('courseDetails', courseId);
const courseNumber = courseDetail ? courseDetail.number : null;
const courseOrg = courseDetail ? courseDetail.org : null;
const courseTitle = courseDetail ? courseDetail.name : courseId;
const { courseId, courseDetails, courseDetailStatus } = useCourseAuthoringContext();
const courseNumber = courseDetails?.number;
const courseOrg = courseDetails?.org;
const courseTitle = courseDetails?.name;
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS || courseDetailStatus === RequestStatus.PENDING;
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
const courseDetailStatus = useSelector(state => state.courseDetail.status);
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS;
const { pathname } = useLocation();
const isEditor = pathname.includes('/editor');
@@ -70,13 +66,4 @@ const CourseAuthoringPage = ({ courseId, children }) => {
);
};
CourseAuthoringPage.propTypes = {
children: PropTypes.node,
courseId: PropTypes.string.isRequired,
};
CourseAuthoringPage.defaultProps = {
children: null,
};
export default CourseAuthoringPage;

View File

@@ -1,153 +0,0 @@
import React from 'react';
import {
Navigate, Routes, Route, useParams,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { PageWrap } from '@edx/frontend-platform/react';
import { Textbooks } from './textbooks';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import EditorContainer from './editors/EditorContainer';
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
import CustomPages from './custom-pages';
import { FilesPage, VideosPage } from './files-and-videos';
import { AdvancedSettings } from './advanced-settings';
import { CourseOutline } from './course-outline';
import ScheduleAndDetails from './schedule-and-details';
import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
import { CourseUnit, SubsectionUnitRedirect } from './course-unit';
import { Certificates } from './certificates';
import CourseExportPage from './export-page/CourseExportPage';
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
import GroupConfigurations from './group-configurations';
import { CourseLibraries } from './course-libraries';
import { IframeProvider } from './generic/hooks/context/iFrameContext';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
*
* /course/:courseId
*
* Meaning that their absolute paths look like:
*
* /course/:courseId/course-pages
* /course/:courseId/proctored-exam-settings
* /course/:courseId/editor/:blockType/:blockId
*
* This component and CourseAuthoringPage should maybe be combined once we no longer need to have
* CourseAuthoringPage split out for use in LegacyProctoringRoute. Once that route is removed, we
* can move the Header/Footer rendering to this component and likely pull the course detail loading
* in as well, and it'd feel a bit better-factored and the roles would feel more clear.
*/
const CourseAuthoringRoutes = () => {
const { courseId } = useParams();
return (
<CourseAuthoringPage courseId={courseId}>
<Routes>
<Route
path="/"
element={<PageWrap><CourseOutline courseId={courseId} /></PageWrap>}
/>
<Route
path="course_info"
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
/>
<Route
path="libraries"
element={<PageWrap><CourseLibraries courseId={courseId} /></PageWrap>}
/>
<Route
path="assets"
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
/>
<Route
path="videos"
element={getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? <PageWrap><VideosPage courseId={courseId} /></PageWrap> : null}
/>
<Route
path="pages-and-resources/*"
element={<PageWrap><PagesAndResources courseId={courseId} /></PageWrap>}
/>
<Route
path="proctored-exam-settings"
element={<Navigate replace to={`/course/${courseId}/pages-and-resources`} />}
/>
<Route
path="custom-pages/*"
element={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
/>
<Route
path="/subsection/:subsectionId"
element={<PageWrap><SubsectionUnitRedirect courseId={courseId} /></PageWrap>}
/>
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
<Route
key={path}
path={path}
element={<PageWrap><IframeProvider><CourseUnit courseId={courseId} /></IframeProvider></PageWrap>}
/>
))}
<Route
path="editor/course-videos/:blockId"
element={<PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap>}
/>
<Route
path="editor/:blockType/:blockId?"
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
/>
<Route
path="settings/details"
element={<PageWrap><ScheduleAndDetails courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/grading"
element={<PageWrap><GradingSettings courseId={courseId} /></PageWrap>}
/>
<Route
path="course_team"
element={<PageWrap><CourseTeam courseId={courseId} /></PageWrap>}
/>
<Route
path="group_configurations"
element={<PageWrap><GroupConfigurations courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/advanced"
element={<PageWrap><AdvancedSettings courseId={courseId} /></PageWrap>}
/>
<Route
path="import"
element={<PageWrap><CourseImportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="export"
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="optimizer"
element={<PageWrap><CourseOptimizerPage courseId={courseId} /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
/>
<Route
path="certificates"
element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true' ? <PageWrap><Certificates courseId={courseId} /></PageWrap> : null}
/>
<Route
path="textbooks"
element={<PageWrap><Textbooks courseId={courseId} /></PageWrap>}
/>
</Routes>
</CourseAuthoringPage>
);
};
export default CourseAuthoringRoutes;

View File

@@ -48,7 +48,11 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
describe('<CourseAuthoringRoutes>', () => {
beforeEach(async () => {
const { axiosMock } = initializeMocks();
const user = {
userId: 1,
username: 'username',
};
const { axiosMock } = initializeMocks({ user });
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {});
@@ -61,11 +65,7 @@ describe('<CourseAuthoringRoutes>', () => {
);
await waitFor(() => {
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
expect(mockComponentFn).toHaveBeenCalled();
});
});
@@ -93,11 +93,7 @@ describe('<CourseAuthoringRoutes>', () => {
await waitFor(() => {
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
expect(mockComponentFn).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,160 @@
import {
Navigate, Routes, Route, useParams,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { PageWrap } from '@edx/frontend-platform/react';
import { Textbooks } from './textbooks';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import EditorContainer from './editors/EditorContainer';
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
import CustomPages from './custom-pages';
import { FilesPage, VideosPage } from './files-and-videos';
import { AdvancedSettings } from './advanced-settings';
import { CourseOutline } from './course-outline';
import ScheduleAndDetails from './schedule-and-details';
import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
import { CourseUnit, SubsectionUnitRedirect } from './course-unit';
import { Certificates } from './certificates';
import CourseExportPage from './export-page/CourseExportPage';
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
import GroupConfigurations from './group-configurations';
import { CourseLibraries } from './course-libraries';
import { IframeProvider } from './generic/hooks/context/iFrameContext';
import { CourseAuthoringProvider } from './CourseAuthoringContext';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
*
* /course/:courseId
*
* Meaning that their absolute paths look like:
*
* /course/:courseId/course-pages
* /course/:courseId/proctored-exam-settings
* /course/:courseId/editor/:blockType/:blockId
*
* This component and CourseAuthoringPage should maybe be combined once we no longer need to have
* CourseAuthoringPage split out for use in LegacyProctoringRoute. Once that route is removed, we
* can move the Header/Footer rendering to this component and likely pull the course detail loading
* in as well, and it'd feel a bit better-factored and the roles would feel more clear.
*/
const CourseAuthoringRoutes = () => {
const { courseId } = useParams();
if (courseId === undefined) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
throw new Error('Error: route is missing courseId.');
}
return (
<CourseAuthoringProvider courseId={courseId}>
<CourseAuthoringPage>
<Routes>
<Route
path="/"
element={<PageWrap><CourseOutline /></PageWrap>}
/>
<Route
path="course_info"
element={<PageWrap><CourseUpdates /></PageWrap>}
/>
<Route
path="libraries"
element={<PageWrap><CourseLibraries /></PageWrap>}
/>
<Route
path="assets"
element={<PageWrap><FilesPage /></PageWrap>}
/>
<Route
path="videos"
element={getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? <PageWrap><VideosPage /></PageWrap> : null}
/>
<Route
path="pages-and-resources/*"
element={<PageWrap><PagesAndResources /></PageWrap>}
/>
<Route
path="proctored-exam-settings"
element={<Navigate replace to={`/course/${courseId}/pages-and-resources`} />}
/>
<Route
path="custom-pages/*"
element={<PageWrap><CustomPages /></PageWrap>}
/>
<Route
path="/subsection/:subsectionId"
element={<PageWrap><SubsectionUnitRedirect /></PageWrap>}
/>
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
<Route
key={path}
path={path}
element={<PageWrap><IframeProvider><CourseUnit /></IframeProvider></PageWrap>}
/>
))}
<Route
path="editor/course-videos/:blockId"
element={<PageWrap><VideoSelectorContainer /></PageWrap>}
/>
<Route
path="editor/:blockType/:blockId?"
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
/>
<Route
path="settings/details"
element={<PageWrap><ScheduleAndDetails /></PageWrap>}
/>
<Route
path="settings/grading"
element={<PageWrap><GradingSettings /></PageWrap>}
/>
<Route
path="course_team"
element={<PageWrap><CourseTeam /></PageWrap>}
/>
<Route
path="group_configurations"
element={<PageWrap><GroupConfigurations /></PageWrap>}
/>
<Route
path="settings/advanced"
element={<PageWrap><AdvancedSettings /></PageWrap>}
/>
<Route
path="import"
element={<PageWrap><CourseImportPage /></PageWrap>}
/>
<Route
path="export"
element={<PageWrap><CourseExportPage /></PageWrap>}
/>
<Route
path="optimizer"
element={<PageWrap><CourseOptimizerPage /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist /></PageWrap>}
/>
<Route
path="certificates"
element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true' ? <PageWrap><Certificates /></PageWrap> : null}
/>
<Route
path="textbooks"
element={<PageWrap><Textbooks /></PageWrap>}
/>
</Routes>
</CourseAuthoringPage>
</CourseAuthoringProvider>
);
};
export default CourseAuthoringRoutes;

View File

@@ -1,15 +1,14 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Container, Button, Layout, StatefulButton, TransitionReplace,
} from '@openedx/paragon';
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import Placeholder from '../editors/Placeholder';
import AlertProctoringError from '../generic/AlertProctoringError';
import { useModel } from '../generic/model-store';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import { parseArrayOrObjectValues } from '../utils';
import { RequestStatus } from '../data/constants';
@@ -26,7 +25,7 @@ import messages from './messages';
import ModalError from './modal-error/ModalError';
import getPageHeadTitle from '../generic/utils';
const AdvancedSettings = ({ courseId }) => {
const AdvancedSettings = () => {
const intl = useIntl();
const dispatch = useDispatch();
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
@@ -39,7 +38,7 @@ const AdvancedSettings = ({ courseId }) => {
const [isEditableState, setIsEditableState] = useState(false);
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
const courseDetails = useModel('courseDetails', courseId);
const { courseId, courseDetails } = useCourseAuthoringContext();
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
useEffect(() => {
@@ -278,8 +277,4 @@ const AdvancedSettings = ({ courseId }) => {
);
};
AdvancedSettings.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default AdvancedSettings;

View File

@@ -1,3 +1,4 @@
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import {
render as baseRender,
fireEvent,
@@ -26,7 +27,9 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
)));
const render = () => baseRender(
<AdvancedSettings courseId={courseId} />,
<CourseAuthoringProvider courseId={courseId}>
<AdvancedSettings />
</CourseAuthoringProvider>,
{ path: mockPathname },
);

View File

@@ -1,6 +1,7 @@
// @ts-check
import userEvent from '@testing-library/user-event';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { initializeMocks, render, waitFor } from '../testUtils';
import { RequestStatus } from '../data/constants';
import { executeThunk } from '../utils';
@@ -14,7 +15,11 @@ let axiosMock;
let store;
const courseId = 'course-123';
const renderComponent = (props) => render(<Certificates courseId={courseId} {...props} />);
const renderComponent = (props) => render(
<CourseAuthoringProvider courseId={courseId}>
<Certificates {...props} />
</CourseAuthoringProvider>,
);
describe('Certificates', () => {
beforeEach(async () => {

View File

@@ -1,6 +1,6 @@
import { Helmet } from 'react-helmet';
import PropTypes from 'prop-types';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import Placeholder from '../editors/Placeholder';
import { RequestStatus } from '../data/constants';
import Loading from '../generic/Loading';
@@ -21,7 +21,8 @@ const MODE_COMPONENTS = {
[MODE_STATES.editAll]: CertificateEditForm,
};
const Certificates = ({ courseId }) => {
const Certificates = () => {
const { courseId } = useCourseAuthoringContext();
const {
certificates, componentMode, isLoading, loadingStatus, pageHeadTitle, hasCertificateModes,
} = useCertificates({ courseId });
@@ -50,8 +51,4 @@ const Certificates = ({ courseId }) => {
);
};
Certificates.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default Certificates;

View File

@@ -2,22 +2,17 @@ import {
render,
waitFor,
screen,
} from '@testing-library/react';
initializeMocks,
} from '@src/testUtils';
import '@testing-library/jest-dom';
import { getConfig, setConfig, initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../store';
import { getConfig, setConfig } from '@edx/frontend-platform';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { RequestStatus } from '../data/constants';
import { executeThunk } from '../utils';
import { getCourseLaunchApiUrl, getCourseBestPracticesApiUrl } from './data/api';
import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks';
import {
courseId,
initialState,
generateCourseLaunchData,
generateCourseBestPracticesData,
} from './factories/mockApiResponses';
@@ -29,11 +24,9 @@ let store;
const renderComponent = () => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<CourseChecklist {...{ courseId }} />
</AppProvider>
</IntlProvider>,
<CourseAuthoringProvider courseId={courseId}>
<CourseChecklist />
</CourseAuthoringProvider>,
);
};
@@ -47,16 +40,9 @@ const mockStore = async (status) => {
describe('CourseChecklistPage', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
});
describe('renders', () => {
describe('if enable_quality prop is true', () => {

View File

@@ -1,12 +1,12 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useEffect } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { useDispatch, useSelector } from 'react-redux';
import { Container, Stack } from '@openedx/paragon';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { DeprecatedReduxState } from '@src/store';
import { useModel } from '../generic/model-store';
import SubHeader from '../generic/sub-header/SubHeader';
import messages from './messages';
import AriaLiveRegion from './AriaLiveRegion';
@@ -15,12 +15,10 @@ import ChecklistSection from './ChecklistSection';
import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
const CourseChecklist = ({
courseId,
}) => {
const CourseChecklist = () => {
const intl = useIntl();
const dispatch = useDispatch();
const courseDetails = useModel('courseDetails', courseId);
const { courseId, courseDetails } = useCourseAuthoringContext();
const enableQuality = getConfig().ENABLE_CHECKLIST_QUALITY === 'true';
useEffect(() => {
@@ -32,7 +30,7 @@ const CourseChecklist = ({
loadingStatus,
launchData,
bestPracticeData,
} = useSelector(state => state.courseChecklist);
} = useSelector((state: DeprecatedReduxState) => state.courseChecklist);
const { bestPracticeChecklistLoadingStatus, launchChecklistLoadingStatus, launchChecklistStatus } = loadingStatus;
@@ -94,8 +92,4 @@ const CourseChecklist = ({
);
};
CourseChecklist.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default CourseChecklist;

View File

@@ -9,8 +9,11 @@ import {
screen,
waitFor,
within,
} from '../testUtils';
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
} from '@src/testUtils';
import { mockContentSearchConfig } from '@src/search-manager/data/api.mock';
import { type ToastActionData } from '@src/generic/toast-context';
import { libraryBlockChangesUrl } from '@src/course-unit/data/api';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { CourseLibraries } from './CourseLibraries';
import {
mockGetEntityLinks,
@@ -18,8 +21,6 @@ import {
mockFetchIndexDocuments,
mockUseLibBlockMetadata,
} from './data/api.mocks';
import { libraryBlockChangesUrl } from '../course-unit/data/api';
import { type ToastActionData } from '../generic/toast-context';
mockContentSearchConfig.applyMock();
mockGetEntityLinks.applyMock();
@@ -58,7 +59,11 @@ describe('<CourseLibraries />', () => {
const renderCourseLibrariesPage = async (courseKey?: string) => {
const courseId = courseKey || mockGetEntityLinks.courseKey;
render(<CourseLibraries courseId={courseId} />);
render(
<CourseAuthoringProvider courseId={courseId}>
<CourseLibraries />
</CourseAuthoringProvider>,
);
};
it('shows the spinner before the query is complete', async () => {
@@ -176,7 +181,11 @@ describe('<CourseLibraries ReviewTab />', () => {
const renderCourseLibrariesReviewPage = async (courseKey?: string) => {
const courseId = courseKey || mockGetEntityLinks.courseKey;
render(<CourseLibraries courseId={courseId} />);
render(
<CourseAuthoringProvider courseId={courseId}>
<CourseLibraries />
</CourseAuthoringProvider>,
);
};
it('shows the spinner before the query is complete', async () => {

View File

@@ -22,8 +22,8 @@ import {
import sumBy from 'lodash/sumBy';
import { useSearchParams } from 'react-router-dom';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import getPageHeadTitle from '../generic/utils';
import { useModel } from '../generic/model-store';
import messages from './messages';
import SubHeader from '../generic/sub-header/SubHeader';
import { useEntityLinksSummaryByDownstreamContext } from './data/apiHooks';
@@ -34,10 +34,6 @@ import NewsstandIcon from '../generic/NewsstandIcon';
import ReviewTabContent from './ReviewTabContent';
import { OutOfSyncAlert } from './OutOfSyncAlert';
interface Props {
courseId: string;
}
interface LibraryCardProps {
linkSummary: PublishableEntityLinkSummary;
}
@@ -100,9 +96,9 @@ const LibraryCard = ({ linkSummary }: LibraryCardProps) => {
);
};
export const CourseLibraries: React.FC<Props> = ({ courseId }) => {
export const CourseLibraries = () => {
const intl = useIntl();
const courseDetails = useModel('courseDetails', courseId);
const { courseId, courseDetails } = useCourseAuthoringContext();
const [searchParams] = useSearchParams();
const [tabKey, setTabKey] = useState<CourseLibraryTabs>(
() => searchParams.get('tab') as CourseLibraryTabs,
@@ -189,7 +185,7 @@ export const CourseLibraries: React.FC<Props> = ({ courseId }) => {
<>
<Helmet>
<title>
{getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle))}
{getPageHeadTitle(courseDetails?.name || '', intl.formatMessage(messages.headingTitle))}
</title>
</Helmet>
<Container size="xl" className="px-4 pt-4 mt-3">

View File

@@ -12,6 +12,7 @@ import { getApiBaseUrl, getClipboardUrl } from '@src/generic/data/api';
import { postXBlockBaseApiUrl } from '@src/course-unit/data/api';
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
import { getDownstreamApiUrl } from '@src/generic/unlink-modal/data/api';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import {
act, fireEvent, initializeMocks, render, screen, waitFor, within,
} from '@src/testUtils';
@@ -136,7 +137,9 @@ jest.mock('@src/studio-home/data/selectors', () => ({
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const renderComponent = () => render(
<CourseOutline courseId={courseId} />,
<CourseAuthoringProvider courseId={courseId}>
<CourseOutline />
</CourseAuthoringProvider>,
);
describe('<CourseOutline />', () => {

View File

@@ -37,6 +37,7 @@ import { ContentType } from '@src/library-authoring/routes';
import { NOTIFICATION_MESSAGES } from '@src/constants';
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
import { XBlock } from '@src/data/types';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import {
getCurrentItem,
getProctoredExamsFlag,
@@ -63,13 +64,10 @@ import messages from './messages';
import { getTagsExportFile } from './data/api';
import OutlineAddChildButtons from './OutlineAddChildButtons';
interface CourseOutlineProps {
courseId: string,
}
const CourseOutline = ({ courseId }: CourseOutlineProps) => {
const CourseOutline = () => {
const intl = useIntl();
const location = useLocation();
const { courseId } = useCourseAuthoringContext();
const {
courseUsageKey,

View File

@@ -1,5 +1,3 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
@@ -7,9 +5,9 @@ import {
Layout,
} from '@openedx/paragon';
import { Add as IconAdd } from '@openedx/paragon/icons';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import { useModel } from '../generic/model-store';
import SubHeader from '../generic/sub-header/SubHeader';
import { USER_ROLES } from '../constants';
import messages from './messages';
@@ -22,11 +20,10 @@ import { useCourseTeam } from './hooks';
import getPageHeadTitle from '../generic/utils';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
const CourseTeam = ({ courseId }) => {
const CourseTeam = () => {
const intl = useIntl();
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
const { courseId } = useCourseAuthoringContext();
const {
modalType,
@@ -57,6 +54,8 @@ const CourseTeam = ({ courseId }) => {
handleInternetConnectionFailed,
} = useCourseTeam({ intl, courseId });
document.title = getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle));
if (isLoadingDenied) {
return (
<Container size="xl" className="course-unit px-4 mt-4">
@@ -171,8 +170,4 @@ const CourseTeam = ({ courseId }) => {
);
};
CourseTeam.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default CourseTeam;

View File

@@ -1,4 +1,5 @@
// @ts-check
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { courseTeamMock, courseTeamWithOneUser, courseTeamWithoutUsers } from './__mocks__';
import { getCourseTeamApiUrl, updateCourseTeamUserApiUrl } from './data/api';
import CourseTeam from './CourseTeam';
@@ -19,7 +20,12 @@ let store;
const mockPathname = '/foo-bar';
const courseId = '123';
const render = () => baseRender(<CourseTeam courseId={courseId} />, { path: mockPathname });
const render = () => baseRender(
<CourseAuthoringProvider courseId={courseId}>
<CourseTeam />
</CourseAuthoringProvider>,
{ path: mockPathname },
);
describe('<CourseTeam />', () => {
beforeEach(() => {

View File

@@ -3,9 +3,9 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useEffect, useState } from 'react';
import { useToggle } from '@openedx/paragon';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { USER_ROLES } from '../constants';
import { RequestStatus } from '../data/constants';
import { useModel } from '../generic/model-store';
import {
changeRoleTeamUserQuery,
createCourseTeamQuery,
@@ -26,7 +26,7 @@ const useCourseTeam = ({ courseId }) => {
const dispatch = useDispatch();
const { email: currentUserEmail } = getAuthenticatedUser();
const courseDetails = useModel('courseDetails', courseId);
const { courseDetails } = useCourseAuthoringContext();
const [modalType, setModalType] = useState(MODAL_TYPES.delete);
const [isInfoModalOpen, openInfoModal, closeInfoModal] = useToggle(false);

View File

@@ -1,5 +1,4 @@
import { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import {
@@ -10,6 +9,7 @@ import {
Warning as WarningIcon,
CheckCircle as CheckCircleIcon,
} from '@openedx/paragon/icons';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { CourseAuthoringUnitSidebarSlot } from '../plugin-slots/CourseAuthoringUnitSidebarSlot';
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
@@ -34,9 +34,10 @@ import MoveModal from './move-modal';
import IframePreviewLibraryXBlockChanges from './preview-changes';
import CourseUnitHeaderActionsSlot from '../plugin-slots/CourseUnitHeaderActionsSlot';
const CourseUnit = ({ courseId }) => {
const CourseUnit = () => {
const { blockId } = useParams();
const intl = useIntl();
const { courseId } = useCourseAuthoringContext();
const {
courseUnit,
isLoading,
@@ -270,8 +271,4 @@ const CourseUnit = ({ courseId }) => {
);
};
CourseUnit.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default CourseUnit;

View File

@@ -1,26 +1,21 @@
import MockAdapter from 'axios-mock-adapter';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
act, fireEvent, render, waitFor, within, screen,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import {
camelCaseObject,
getConfig,
initializeMockApp,
setConfig,
} from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { cloneDeep, set } from 'lodash';
import {
act, fireEvent, render, waitFor, within, screen, initializeMocks,
} from '@src/testUtils';
import { IFRAME_FEATURE_POLICY } from '@src/constants';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import pasteComponentMessages from '@src/generic/clipboard/paste-component/messages';
import { getClipboardUrl } from '@src/generic/data/api';
import { IframeProvider } from '@src/generic/hooks/context/iFrameContext';
import { getDownstreamApiUrl } from '@src/generic/unlink-modal/data/api';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import {
getCourseSectionVerticalApiUrl,
@@ -38,7 +33,6 @@ import {
getCourseOutlineInfoQuery,
patchUnitItemQuery,
} from './data/thunk';
import initializeStore from '../store';
import {
courseCreateXblockMock,
courseSectionVerticalMock,
@@ -68,7 +62,6 @@ import messages from './messages';
let axiosMock;
let store;
let queryClient;
const courseId = '123';
const blockId = '567890';
const sequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5';
@@ -111,38 +104,20 @@ function simulatePostMessageEvent(type, payload) {
}
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en">
<IframeProvider>
<QueryClientProvider client={queryClient}>
<CourseUnit courseId={courseId} />
</QueryClientProvider>
</IframeProvider>
</IntlProvider>
</AppProvider>
<IframeProvider>
<CourseAuthoringProvider courseId={courseId}>
<CourseUnit />
</CourseAuthoringProvider>
</IframeProvider>
);
describe('<CourseUnit />', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
const mocks = initializeMocks();
window.scrollTo = jest.fn();
global.localStorage.clear();
store = initializeStore();
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
axiosMock
.onGet(getClipboardUrl())
.reply(200, clipboardUnit);

View File

@@ -1,3 +1,4 @@
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import {
initializeMocks, waitFor, render, screen,
} from '../testUtils';
@@ -26,12 +27,17 @@ const expectedCourseItemDataWithoutUnit = [{
}];
const renderSubsectionRedirectPage = () => {
render(<SubsectionUnitRedirect courseId={courseId} />, {
path,
routerProps: {
initialEntries: [`/subsection/${subsectionId}`],
render(
<CourseAuthoringProvider courseId={courseId}>
<SubsectionUnitRedirect />
</CourseAuthoringProvider>,
{
path,
routerProps: {
initialEntries: [`/subsection/${subsectionId}`],
},
},
});
);
};
jest.mock('react-router-dom', () => {

View File

@@ -1,8 +1,11 @@
import { LoadingSpinner } from '@src/generic/Loading';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useParams, Navigate } from 'react-router-dom';
import { useCourseItemData } from '../course-outline/data/apiHooks';
const SubsectionUnitRedirect = ({ courseId }: { courseId: string }) => {
const SubsectionUnitRedirect = () => {
const { courseId } = useCourseAuthoringContext();
let { subsectionId } = useParams();
// if the call is made via the click on breadcrumbs the re won't be courseId available
// in such cases the page should redirect to the 1st unit of he subsection

View File

@@ -5,8 +5,9 @@ import { Button } from '@openedx/paragon';
import { Plus as PlusIcon, ContentPasteGo as ContentPasteGoIcon } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { changeEditTitleFormOpen, updateQueryPendingStatus } from '../../data/slice';
import { getCourseUnitData, getCourseId, getSequenceId } from '../../data/selectors';
import { getCourseUnitData, getSequenceId } from '../../data/selectors';
import messages from '../messages';
import { useIndexOfLastVisibleChild } from '../hooks';
import SequenceNavigationDropdown from './SequenceNavigationDropdown';
@@ -19,7 +20,7 @@ const SequenceNavigationTabs = ({
const dispatch = useDispatch();
const navigate = useNavigate();
const sequenceId = useSelector(getSequenceId);
const courseId = useSelector(getCourseId);
const { courseId } = useCourseAuthoringContext();
const courseUnit = useSelector(getCourseUnitData);
const sequenceChildAddable = courseUnit?.ancestorInfo?.ancestors?.[0]?.actions?.childAddable;

View File

@@ -4,7 +4,9 @@ import { Button } from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { DeprecatedReduxState } from '@src/store';
import { getCourseId, getSequenceId } from '@src/course-unit/data/selectors';
import { getSequenceId } from '@src/course-unit/data/selectors';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import UnitIcon from './UnitIcon';
interface Props {
@@ -20,7 +22,7 @@ const UnitButton: FC<Props> = ({
isActive, // passed from parent (SequenceNavigationTabs)
showTitle = false,
}) => {
const courseId = useSelector(getCourseId);
const { courseId } = useCourseAuthoringContext();
const sequenceId = useSelector(getSequenceId);
const unit = useSelector((state: DeprecatedReduxState) => state.models.units[unitId]);

View File

@@ -10,7 +10,6 @@ export const getErrorMessage = (state) => state.courseUnit.errorMessage;
export const getSequenceStatus = (state) => state.courseUnit.sequenceStatus;
export const getSequenceIds = (state) => state.courseUnit.courseSectionVertical.courseSequenceIds;
export const getCourseSectionVertical = (state) => state.courseUnit.courseSectionVertical;
export const getCourseId = (state) => state.courseDetail.courseId;
export const getSequenceId = (state) => state.courseUnit.sequenceId;
export const getCourseVerticalChildren = (state) => state.courseUnit.courseVerticalChildren;
export const getCourseOutlineInfo = (state) => state.courseUnit.courseOutlineInfo;

View File

@@ -1,393 +0,0 @@
import {
render, waitFor, fireEvent,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
getCourseUpdatesApiUrl,
getCourseHandoutApiUrl,
updateCourseUpdatesApiUrl,
} from './data/api';
import {
createCourseUpdateQuery,
deleteCourseUpdateQuery,
editCourseHandoutsQuery,
editCourseUpdateQuery,
} from './data/thunk';
import initializeStore from '../store';
import { executeThunk } from '../utils';
import { RequestStatus } from '../data/constants';
import { courseUpdatesMock, courseHandoutsMock } from './__mocks__';
import CourseUpdates from './CourseUpdates';
import messages from './messages';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
jest.mock('@tinymce/tinymce-react', () => {
const originalModule = jest.requireActual('@tinymce/tinymce-react');
return {
__esModule: true,
...originalModule,
Editor: () => 'foo bar',
};
});
jest.mock('../editors/sharedComponents/TinyMceWidget', () => ({
__esModule: true, // Required to mock a default export
default: () => <div>Widget</div>,
prepareEditorRef: jest.fn(() => ({
refReady: true,
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
})),
}));
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="es">
<CourseUpdates courseId={courseId} />
</IntlProvider>
</AppProvider>
);
describe('<CourseUpdates />', () => {
describe('Successful API responses', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseUpdatesApiUrl(courseId))
.reply(200, courseUpdatesMock);
axiosMock
.onGet(getCourseHandoutApiUrl(courseId))
.reply(200, courseHandoutsMock);
});
it('render CourseUpdates component correctly', async () => {
const {
getByText, getAllByTestId, getByTestId, getByRole,
} = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.sectionInfo.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeInTheDocument();
expect(getAllByTestId('course-update')).toHaveLength(3);
expect(getByTestId('course-handouts')).toBeInTheDocument();
});
});
it('should create course update', async () => {
const { getByText } = render(<RootWrapper />);
const data = {
content: '<p>Some text</p>',
date: 'August 29, 2023',
};
axiosMock
.onPost(getCourseUpdatesApiUrl(courseId))
.reply(200, data);
await executeThunk(createCourseUpdateQuery(courseId, data), store.dispatch);
expect(getByText('Some text')).toBeInTheDocument();
expect(getByText(data.date)).toBeInTheDocument();
});
it('should edit course update', async () => {
const { getByText, queryByText } = render(<RootWrapper />);
const data = {
id: courseUpdatesMock[0].id,
content: '<p>Some text</p>',
date: 'August 29, 2023',
};
axiosMock
.onPut(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
.reply(200, data);
await executeThunk(editCourseUpdateQuery(courseId, data), store.dispatch);
expect(getByText('Some text')).toBeInTheDocument();
expect(getByText(data.date)).toBeInTheDocument();
expect(queryByText(courseUpdatesMock[0].date)).not.toBeInTheDocument();
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
});
it('should delete course update', async () => {
const { queryByText } = render(<RootWrapper />);
axiosMock
.onDelete(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
.reply(200);
await executeThunk(deleteCourseUpdateQuery(courseId, courseUpdatesMock[0].id), store.dispatch);
expect(queryByText(courseUpdatesMock[0].date)).not.toBeInTheDocument();
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
});
it('should edit course handouts', async () => {
const { getByText, queryByText } = render(<RootWrapper />);
const data = {
...courseHandoutsMock,
data: '<p>Some handouts 1</p>',
};
axiosMock
.onPut(getCourseHandoutApiUrl(courseId))
.reply(200, data);
await executeThunk(editCourseHandoutsQuery(courseId, data), store.dispatch);
expect(getByText('Some handouts 1')).toBeInTheDocument();
expect(queryByText(courseHandoutsMock.data)).not.toBeInTheDocument();
});
it('Add new update form is visible after clicking "New update" button', async () => {
const { getByText, getByRole, getAllByTestId } = render(<RootWrapper />);
await waitFor(() => {
const editUpdateButtons = getAllByTestId('course-update-edit-button');
const deleteButtons = getAllByTestId('course-update-delete-button');
const editHandoutsButtons = getAllByTestId('course-handouts-edit-button');
const newUpdateButton = getByRole('button', { name: messages.newUpdateButton.defaultMessage });
fireEvent.click(newUpdateButton);
expect(newUpdateButton).toBeDisabled();
editUpdateButtons.forEach((button) => expect(button).toBeDisabled());
editHandoutsButtons.forEach((button) => expect(button).toBeDisabled());
deleteButtons.forEach((button) => expect(button).toBeDisabled());
expect(getByText('Add new update')).toBeInTheDocument();
});
});
it('Edit handouts form is visible after clicking "Edit" button', async () => {
const { getByText, getByRole, getAllByTestId } = render(<RootWrapper />);
await waitFor(() => {
const editUpdateButtons = getAllByTestId('course-update-edit-button');
const deleteButtons = getAllByTestId('course-update-delete-button');
const editHandoutsButtons = getAllByTestId('course-handouts-edit-button');
const editHandoutsButton = editHandoutsButtons[0];
fireEvent.click(editHandoutsButton);
expect(editHandoutsButton).toBeDisabled();
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled();
editUpdateButtons.forEach((button) => expect(button).toBeDisabled());
editHandoutsButtons.forEach((button) => expect(button).toBeDisabled());
deleteButtons.forEach((button) => expect(button).toBeDisabled());
expect(getByText('Edit handouts')).toBeInTheDocument();
});
});
it('Edit update form is visible after clicking "Edit" button', async () => {
const {
getByText, getByRole, getAllByTestId, queryByText,
} = render(<RootWrapper />);
await waitFor(() => {
const editUpdateButtons = getAllByTestId('course-update-edit-button');
const deleteButtons = getAllByTestId('course-update-delete-button');
const editHandoutsButtons = getAllByTestId('course-handouts-edit-button');
const editUpdateFirstButton = editUpdateButtons[0];
fireEvent.click(editUpdateFirstButton);
expect(getByText('Edit update')).toBeInTheDocument();
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled();
editUpdateButtons.forEach((button) => expect(button).toBeDisabled());
editHandoutsButtons.forEach((button) => expect(button).toBeDisabled());
deleteButtons.forEach((button) => expect(button).toBeDisabled());
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
});
});
});
describe('page load failure API responses', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('Course updates fetch should show updates loading error', async () => {
axiosMock
.onGet(getCourseUpdatesApiUrl(courseId))
.reply(404);
axiosMock
.onGet(getCourseHandoutApiUrl(courseId))
.reply(200, courseHandoutsMock);
const {
getByText, queryByTestId, getByRole,
} = render(<RootWrapper />);
await waitFor(() => {
const newButton = getByRole('button', { name: messages.newUpdateButton.defaultMessage });
expect(getByText(messages.loadingUpdatesErrorTitle.defaultMessage));
expect(newButton).toBeDisabled();
expect(getByText(messages.noCourseUpdates.defaultMessage)).toBeVisible();
expect(queryByTestId('course-update')).toBeNull();
});
});
it('Course handouts fetch should show handouts loading error', async () => {
axiosMock
.onGet(getCourseUpdatesApiUrl(courseId))
.reply(200, courseUpdatesMock);
axiosMock
.onGet(getCourseHandoutApiUrl(courseId))
.reply(404);
const {
getByText, getByTestId,
} = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(messages.loadingHandoutsErrorTitle.defaultMessage));
expect(getByTestId('course-handouts-edit-button')).toBeDisabled();
});
});
it('displays an alert and sets status to DENIED when API responds with 403', async () => {
axiosMock
.onGet(getCourseUpdatesApiUrl(courseId))
.reply(403, courseUpdatesMock);
axiosMock
.onGet(getCourseHandoutApiUrl(courseId))
.reply(403);
const { getByTestId } = render(<RootWrapper />);
await waitFor(() => {
expect(getByTestId('connectionErrorAlert')).toBeInTheDocument();
const { loadingStatuses } = store.getState().courseUpdates;
Object.values(loadingStatuses)
.some(status => expect(status).toEqual(RequestStatus.DENIED));
});
});
});
describe('saving failure API responses', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseUpdatesApiUrl(courseId))
.reply(200, courseUpdatesMock);
axiosMock
.onGet(getCourseHandoutApiUrl(courseId))
.reply(200, courseHandoutsMock);
});
it('creating new update should show saving error alert', async () => {
const { getByText, queryByText } = render(<RootWrapper />);
const data = {
content: '<p>Some text</p>',
date: 'August 29, 2023',
};
axiosMock
.onPost(getCourseUpdatesApiUrl(courseId), data)
.reply(404);
await executeThunk(createCourseUpdateQuery(courseId, data), store.dispatch);
expect(getByText(messages.savingNewUpdateErrorAlertDescription.defaultMessage)).toBeVisible();
expect(queryByText('Some text')).toBeNull();
expect(queryByText(data.date)).toBeNull();
});
it('editing course update should show saving error alert', async () => {
const { getByText, queryByText } = render(<RootWrapper />);
const data = {
id: courseUpdatesMock[0].id,
content: '<p>Some text</p>',
date: 'August 29, 2023',
};
axiosMock
.onPut(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
.reply(404);
await executeThunk(editCourseUpdateQuery(courseId, data), store.dispatch);
expect(queryByText('Some text')).toBeNull();
expect(queryByText(data.date)).toBeNull();
expect(getByText(courseUpdatesMock[0].date)).toBeVisible();
expect(getByText(courseUpdatesMock[0].content)).toBeVisible();
expect(getByText(messages.savingUpdatesErrorDescription.defaultMessage)).toBeVisible();
});
it('deleting course update should show delete saving error alert', async () => {
const { getByText } = render(<RootWrapper />);
axiosMock
.onDelete(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
.reply(404);
await executeThunk(deleteCourseUpdateQuery(courseId, courseUpdatesMock[0].id), store.dispatch);
expect(getByText(courseUpdatesMock[0].date)).toBeVisible();
expect(getByText(courseUpdatesMock[0].content)).toBeVisible();
expect(getByText(messages.deletingUpdatesErrorDescription.defaultMessage)).toBeVisible();
});
it('editing course handouts should show saving error alert', async () => {
const { getByText, queryByText } = render(<RootWrapper />);
const data = {
...courseHandoutsMock,
data: '<p>Some handouts 1</p>',
};
axiosMock
.onPut(getCourseHandoutApiUrl(courseId))
.reply(404);
await executeThunk(editCourseHandoutsQuery(courseId, data), store.dispatch);
expect(queryByText('Some handouts 1')).toBeNull();
expect(getByText(courseHandoutsMock.data)).toBeVisible();
expect(getByText(messages.savingHandoutsErrorDescription.defaultMessage));
});
});
});

View File

@@ -0,0 +1,345 @@
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { executeThunk } from '@src/utils';
import { RequestStatus } from '@src/data/constants';
import {
initializeMocks, render, waitFor, fireEvent, screen,
} from '@src/testUtils';
import {
getCourseUpdatesApiUrl,
getCourseHandoutApiUrl,
updateCourseUpdatesApiUrl,
} from './data/api';
import {
createCourseUpdateQuery,
deleteCourseUpdateQuery,
editCourseHandoutsQuery,
editCourseUpdateQuery,
} from './data/thunk';
import { courseUpdatesMock, courseHandoutsMock } from './__mocks__';
import CourseUpdates from './CourseUpdates';
import messages from './messages';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
jest.mock('@tinymce/tinymce-react', () => {
const originalModule = jest.requireActual('@tinymce/tinymce-react');
return {
__esModule: true,
...originalModule,
Editor: () => 'foo bar',
};
});
jest.mock('../editors/sharedComponents/TinyMceWidget', () => ({
__esModule: true, // Required to mock a default export
default: () => <div>Widget</div>,
prepareEditorRef: jest.fn(() => ({
refReady: true,
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
})),
}));
const RootWrapper = () => (
<CourseAuthoringProvider courseId={courseId}>
<CourseUpdates />
</CourseAuthoringProvider>
);
describe('<CourseUpdates />', () => {
describe('Successful API responses', () => {
beforeEach(() => {
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
axiosMock
.onGet(getCourseUpdatesApiUrl(courseId))
.reply(200, courseUpdatesMock);
axiosMock
.onGet(getCourseHandoutApiUrl(courseId))
.reply(200, courseHandoutsMock);
});
it('render CourseUpdates component correctly', async () => {
render(<RootWrapper />);
expect(await screen.findByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.sectionInfo.defaultMessage)).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeInTheDocument();
expect(screen.getAllByTestId('course-update')).toHaveLength(3);
expect(screen.getByTestId('course-handouts')).toBeInTheDocument();
});
it('should create course update', async () => {
const data = {
content: '<p>Some text</p>',
date: 'August 29, 2023',
};
axiosMock
.onPost(getCourseUpdatesApiUrl(courseId))
.reply(200, data);
render(<RootWrapper />);
await executeThunk(createCourseUpdateQuery(courseId, data), store.dispatch);
expect(screen.getByText('Some text')).toBeInTheDocument();
expect(screen.getByText(data.date)).toBeInTheDocument();
});
it('should edit course update', async () => {
const data = {
id: courseUpdatesMock[0].id,
content: '<p>Some text</p>',
date: 'August 29, 2023',
};
axiosMock
.onPut(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
.reply(200, data);
render(<RootWrapper />);
await executeThunk(editCourseUpdateQuery(courseId, data), store.dispatch);
expect(screen.getByText('Some text')).toBeInTheDocument();
expect(screen.getByText(data.date)).toBeInTheDocument();
expect(screen.queryByText(courseUpdatesMock[0].date)).not.toBeInTheDocument();
expect(screen.queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
});
it('should delete course update', async () => {
axiosMock
.onDelete(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
.reply(200);
render(<RootWrapper />);
await executeThunk(deleteCourseUpdateQuery(courseId, courseUpdatesMock[0].id), store.dispatch);
expect(screen.queryByText(courseUpdatesMock[0].date)).not.toBeInTheDocument();
expect(screen.queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
});
it('should edit course handouts', async () => {
const data = {
...courseHandoutsMock,
data: '<p>Some handouts 1</p>',
};
axiosMock
.onPut(getCourseHandoutApiUrl(courseId))
.reply(200, data);
render(<RootWrapper />);
await executeThunk(editCourseHandoutsQuery(courseId, data), store.dispatch);
expect(screen.getByText('Some handouts 1')).toBeInTheDocument();
expect(screen.queryByText(courseHandoutsMock.data)).not.toBeInTheDocument();
});
it('Add new update form is visible after clicking "New update" button', async () => {
render(<RootWrapper />);
const editUpdateButtons = await screen.findAllByTestId('course-update-edit-button');
const deleteButtons = await screen.findAllByTestId('course-update-delete-button');
const editHandoutsButtons = await screen.findAllByTestId('course-handouts-edit-button');
const newUpdateButton = await screen.findByRole('button', { name: messages.newUpdateButton.defaultMessage });
fireEvent.click(newUpdateButton);
expect(newUpdateButton).toBeDisabled();
editUpdateButtons.forEach((button) => expect(button).toBeDisabled());
editHandoutsButtons.forEach((button) => expect(button).toBeDisabled());
deleteButtons.forEach((button) => expect(button).toBeDisabled());
expect(screen.getByText('Add new update')).toBeInTheDocument();
});
it('Edit handouts form is visible after clicking "Edit" button', async () => {
render(<RootWrapper />);
const editUpdateButtons = await screen.findAllByTestId('course-update-edit-button');
const deleteButtons = await screen.findAllByTestId('course-update-delete-button');
const editHandoutsButtons = await screen.findAllByTestId('course-handouts-edit-button');
const editHandoutsButton = editHandoutsButtons[0];
fireEvent.click(editHandoutsButton);
expect(editHandoutsButton).toBeDisabled();
expect(screen.getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled();
editUpdateButtons.forEach((button) => expect(button).toBeDisabled());
editHandoutsButtons.forEach((button) => expect(button).toBeDisabled());
deleteButtons.forEach((button) => expect(button).toBeDisabled());
expect(screen.getByText('Edit handouts')).toBeInTheDocument();
});
it('Edit update form is visible after clicking "Edit" button', async () => {
render(<RootWrapper />);
let editUpdateButtons = await screen.findAllByTestId('course-update-edit-button');
const editUpdateFirstButton = editUpdateButtons[0];
fireEvent.click(editUpdateFirstButton);
const deleteButtons = await screen.findAllByTestId('course-update-delete-button');
const editHandoutsButtons = await screen.findAllByTestId('course-handouts-edit-button');
editUpdateButtons = await screen.findAllByTestId('course-update-edit-button');
expect(screen.getByText('Edit update')).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled();
editUpdateButtons.forEach((button) => expect(button).toBeDisabled());
editHandoutsButtons.forEach((button) => expect(button).toBeDisabled());
deleteButtons.forEach((button) => expect(button).toBeDisabled());
expect(screen.queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
});
});
describe('page load failure API responses', () => {
beforeEach(() => {
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
});
it('Course updates fetch should show updates loading error', async () => {
axiosMock
.onGet(getCourseUpdatesApiUrl(courseId))
.reply(404);
axiosMock
.onGet(getCourseHandoutApiUrl(courseId))
.reply(200, courseHandoutsMock);
render(<RootWrapper />);
const newButton = await screen.findByRole('button', { name: messages.newUpdateButton.defaultMessage });
expect(screen.getByText(messages.loadingUpdatesErrorTitle.defaultMessage));
expect(newButton).toBeDisabled();
expect(screen.getByText(messages.noCourseUpdates.defaultMessage)).toBeVisible();
expect(screen.queryByTestId('course-update')).toBeNull();
});
it('Course handouts fetch should show handouts loading error', async () => {
axiosMock
.onGet(getCourseUpdatesApiUrl(courseId))
.reply(200, courseUpdatesMock);
axiosMock
.onGet(getCourseHandoutApiUrl(courseId))
.reply(404);
render(<RootWrapper />);
await waitFor(() => {
expect(screen.getByText(messages.loadingHandoutsErrorTitle.defaultMessage));
});
expect(screen.getByTestId('course-handouts-edit-button')).toBeDisabled();
});
it('displays an alert and sets status to DENIED when API responds with 403', async () => {
axiosMock
.onGet(getCourseUpdatesApiUrl(courseId))
.reply(403, courseUpdatesMock);
axiosMock
.onGet(getCourseHandoutApiUrl(courseId))
.reply(403);
render(<RootWrapper />);
await waitFor(() => {
const { loadingStatuses } = store.getState().courseUpdates;
Object.values(loadingStatuses)
.some(status => expect(status).toEqual(RequestStatus.DENIED));
});
expect(screen.getByTestId('connectionErrorAlert')).toBeInTheDocument();
});
});
describe('saving failure API responses', () => {
beforeEach(() => {
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
axiosMock
.onGet(getCourseUpdatesApiUrl(courseId))
.reply(200, courseUpdatesMock);
axiosMock
.onGet(getCourseHandoutApiUrl(courseId))
.reply(200, courseHandoutsMock);
});
it('creating new update should show saving error alert', async () => {
render(<RootWrapper />);
const data = {
content: '<p>Some text</p>',
date: 'August 29, 2023',
};
axiosMock
.onPost(getCourseUpdatesApiUrl(courseId), data)
.reply(404);
await executeThunk(createCourseUpdateQuery(courseId, data), store.dispatch);
expect(screen.getByText(messages.savingNewUpdateErrorAlertDescription.defaultMessage)).toBeVisible();
expect(screen.queryByText('Some text')).toBeNull();
expect(screen.queryByText(data.date)).toBeNull();
});
it('editing course update should show saving error alert', async () => {
render(<RootWrapper />);
const data = {
id: courseUpdatesMock[0].id,
content: '<p>Some text</p>',
date: 'August 29, 2023',
};
axiosMock
.onPut(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
.reply(404);
await executeThunk(editCourseUpdateQuery(courseId, data), store.dispatch);
expect(screen.queryByText('Some text')).toBeNull();
expect(screen.queryByText(data.date)).toBeNull();
expect(screen.getByText(courseUpdatesMock[0].date)).toBeVisible();
expect(screen.getByText(courseUpdatesMock[0].content)).toBeVisible();
expect(screen.getByText(messages.savingUpdatesErrorDescription.defaultMessage)).toBeVisible();
});
it('deleting course update should show delete saving error alert', async () => {
render(<RootWrapper />);
axiosMock
.onDelete(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
.reply(404);
await executeThunk(deleteCourseUpdateQuery(courseId, courseUpdatesMock[0].id), store.dispatch);
expect(screen.getByText(courseUpdatesMock[0].date)).toBeVisible();
expect(screen.getByText(courseUpdatesMock[0].content)).toBeVisible();
expect(screen.getByText(messages.deletingUpdatesErrorDescription.defaultMessage)).toBeVisible();
});
it('editing course handouts should show saving error alert', async () => {
render(<RootWrapper />);
const data = {
...courseHandoutsMock,
data: '<p>Some handouts 1</p>',
};
axiosMock
.onPut(getCourseHandoutApiUrl(courseId))
.reply(404);
await executeThunk(editCourseHandoutsQuery(courseId, data), store.dispatch);
expect(screen.queryByText('Some handouts 1')).toBeNull();
expect(screen.getByText(courseHandoutsMock.data)).toBeVisible();
expect(await screen.findByText(messages.savingHandoutsErrorDescription.defaultMessage));
});
});
});

View File

@@ -1,5 +1,3 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
@@ -11,13 +9,13 @@ import {
import { Add as AddIcon, ErrorOutline as ErrorIcon } from '@openedx/paragon/icons';
import { useSelector } from 'react-redux';
import { useModel } from '../generic/model-store';
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
import ProcessingNotification from '../generic/processing-notification';
import SubHeader from '../generic/sub-header/SubHeader';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
import { RequestStatus } from '../data/constants';
import { getProcessingNotification } from '@src/generic/processing-notification/data/selectors';
import ProcessingNotification from '@src/generic/processing-notification';
import SubHeader from '@src/generic/sub-header/SubHeader';
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert';
import { RequestStatus } from '@src/data/constants';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import CourseHandouts from './course-handouts/CourseHandouts';
import CourseUpdate from './course-update/CourseUpdate';
import DeleteModal from './delete-modal/DeleteModal';
@@ -34,9 +32,9 @@ import { matchesAnyStatus } from './utils';
import getPageHeadTitle from '../generic/utils';
import AlertMessage from '../generic/alert-message';
const CourseUpdates = ({ courseId }) => {
const CourseUpdates = () => {
const intl = useIntl();
const courseDetails = useModel('courseDetails', courseId);
const { courseId, courseDetails } = useCourseAuthoringContext();
const {
requestType,
@@ -81,7 +79,7 @@ const CourseUpdates = ({ courseId }) => {
<>
<Helmet>
<title>
{getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle))}
{getPageHeadTitle(courseDetails?.name || '', intl.formatMessage(messages.headingTitle))}
</title>
</Helmet>
<Container size="xl" className="px-4 pt-4">
@@ -163,7 +161,6 @@ const CourseUpdates = ({ courseId }) => {
<section className="updates-section">
{isMainFormOpen && (
<UpdateForm
isOpen={isUpdateFormOpen}
close={closeUpdateForm}
requestType={requestType}
onSubmit={handleUpdatesSubmit}
@@ -176,7 +173,6 @@ const CourseUpdates = ({ courseId }) => {
{courseUpdates.map((courseUpdate, index) => (
isInnerFormOpen(courseUpdate.id) ? (
<UpdateForm
isOpen={isUpdateFormOpen}
close={closeUpdateForm}
requestType={requestType}
isInnerForm
@@ -251,8 +247,4 @@ const CourseUpdates = ({ courseId }) => {
);
};
CourseUpdates.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default CourseUpdates;

View File

@@ -1,22 +1,18 @@
import React from 'react';
import {
render,
fireEvent,
waitFor,
act,
} from '@testing-library/react';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
initializeMocks,
} from '@src/testUtils';
import moment from 'moment/moment';
import initializeStore from '../../store';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { REQUEST_TYPES } from '../constants';
import { courseHandoutsMock, courseUpdatesMock } from '../__mocks__';
import UpdateForm from './UpdateForm';
import messages from './messages';
let store;
const closeMock = jest.fn();
const onSubmitMock = jest.fn();
const addNewUpdateMock = { id: 0, date: moment().utc().toDate(), content: 'Some content' };
@@ -53,31 +49,20 @@ const courseUpdatesInitialValues = (requestType) => {
};
const renderComponent = ({ requestType }) => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<UpdateForm
isOpen
close={closeMock}
requestType={requestType}
onSubmit={onSubmitMock}
courseUpdatesInitialValues={courseUpdatesInitialValues(requestType)}
/>
</IntlProvider>
</AppProvider>,
<CourseAuthoringProvider courseId="1">
<UpdateForm
isOpen
close={closeMock}
requestType={requestType}
onSubmit={onSubmitMock}
courseUpdatesInitialValues={courseUpdatesInitialValues(requestType)}
/>,
</CourseAuthoringProvider>,
);
describe('<UpdateForm />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
initializeMocks();
});
it('render Add new update form correctly', async () => {
const { getByText, getByDisplayValue, getByRole } = renderComponent({ requestType: REQUEST_TYPES.add_new_update });

View File

@@ -6,10 +6,11 @@ import {
screen,
act,
render,
} from '../testUtils';
import { executeThunk } from '../utils';
import { RequestStatus } from '../data/constants';
import { getApiWaffleFlagsUrl } from '../data/api';
} from '@src/testUtils';
import { executeThunk } from '@src/utils';
import { RequestStatus } from '@src/data/constants';
import { getApiWaffleFlagsUrl } from '@src/data/api';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import CustomPages from './CustomPages';
import {
generateFetchPageApiResponse,
@@ -28,10 +29,15 @@ import messages from './messages';
let axiosMock;
let store;
// @ts-ignore
ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
render(<CustomPages courseId={courseId} />);
render(
<CourseAuthoringProvider courseId={courseId}>
<CustomPages />
</CourseAuthoringProvider>,
);
};
const mockStore = async (status) => {

View File

@@ -1,5 +1,4 @@
import { useEffect, useContext, useState } from 'react';
import PropTypes from 'prop-types';
import {
Routes, Route, useNavigate, Link,
} from 'react-router-dom';
@@ -20,12 +19,17 @@ import {
Container,
} from '@openedx/paragon';
import { Add, SpinnerSimple } from '@openedx/paragon/icons';
import Placeholder from '../editors/Placeholder';
import DraggableList, { SortableItem } from '../generic/DraggableList';
import ErrorAlert from '../editors/sharedComponents/ErrorAlerts/ErrorAlert';
import Placeholder from '@src/editors/Placeholder';
import DraggableList, { SortableItem } from '@src/generic/DraggableList';
import ErrorAlert from '@src/editors/sharedComponents/ErrorAlerts/ErrorAlert';
import { RequestStatus } from '@src/data/constants';
import { useModels } from '@src/generic/model-store';
import { useWaffleFlags } from '@src/data/apiHooks';
import getPageHeadTitle from '@src/generic/utils';
import { getPagePath } from '@src/utils';
import { DeprecatedReduxState } from '@src/store';
import { RequestStatus } from '../data/constants';
import { useModels, useModel } from '../generic/model-store';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { getLoadingStatus, getSavingStatus } from './data/selectors';
import {
addSingleCustomPage,
@@ -33,29 +37,24 @@ import {
updatePageOrder,
updateSingleCustomPage,
} from './data/thunks';
import previewLmsStaticPages from './data/images/previewLmsStaticPages.png';
import CustomPageCard from './CustomPageCard';
import messages from './messages';
import CustomPagesProvider from './CustomPagesProvider';
import EditModal from './EditModal';
import { useWaffleFlags } from '../data/apiHooks';
import getPageHeadTitle from '../generic/utils';
import { getPagePath } from '../utils';
const CustomPages = ({
courseId,
}) => {
const CustomPages = () => {
const intl = useIntl();
const navigate = useNavigate();
const dispatch = useDispatch();
const [orderedPages, setOrderedPages] = useState([]);
const [currentPage, setCurrentPage] = useState();
const [currentPage, setCurrentPage] = useState<any>();
const [isOpen, open, close] = useToggle(false);
const { courseId, courseDetails } = useCourseAuthoringContext();
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading));
document.title = getPageHeadTitle(courseDetails?.name || '', intl.formatMessage(messages.heading));
// @ts-expect-error - frontend-platform doesn't have type information
const { config } = useContext(AppContext);
const learningCourseURL = `${config.LEARNING_BASE_URL}/course/${courseId}`;
@@ -63,9 +62,9 @@ const CustomPages = ({
dispatch(fetchCustomPages(courseId));
}, [courseId]);
const customPagesIds = useSelector(state => state.customPages.customPagesIds);
const addPageStatus = useSelector(state => state.customPages.addingStatus);
const deletePageStatus = useSelector(state => state.customPages.deletingStatus);
const customPagesIds = useSelector((state: DeprecatedReduxState) => state.customPages.customPagesIds);
const addPageStatus = useSelector((state: DeprecatedReduxState) => state.customPages.addingStatus);
const deletePageStatus = useSelector((state: DeprecatedReduxState) => state.customPages.deletingStatus);
const savingStatus = useSelector(getSavingStatus);
const loadingStatus = useSelector(getLoadingStatus);
const waffleFlags = useWaffleFlags(courseId);
@@ -174,7 +173,7 @@ const CustomPages = ({
<FormattedMessage {...messages.note} />
</div>
<DraggableList itemList={orderedPages} setState={setOrderedPages} updateOrder={handleReorder}>
{orderedPages.map((page) => (
{orderedPages.map((page: any) => (
<SortableItem
id={page.id}
key={page.id}
@@ -243,6 +242,7 @@ const CustomPages = ({
onClose={close}
size="lg"
title={intl.formatMessage(messages.studentViewModalTitle)}
isOverflowVisible={false}
>
<ModalDialog.Header>
<ModalDialog.Title>
@@ -275,8 +275,4 @@ const CustomPages = ({
);
};
CustomPages.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default CustomPages;

View File

@@ -2,6 +2,36 @@ import { camelCaseObject, getConfig, snakeCaseObject } from '@edx/frontend-platf
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL as string;
export const getCourseDetailsUrl = (courseId: string, username: string) => (
`${getConfig().LMS_BASE_URL}/api/courses/v1/courses/${courseId}?username=${username}`
);
export type CourseDetailsData = {
blocksUrl: string;
courseId: string;
effort?: string;
end?: string;
enrollmentEnd?: string;
enrollmentStart?: string;
hidden: boolean;
id: string;
invitationOnly: boolean;
isEnrolled: boolean;
media: Record<
'image' | 'course_image' | 'banner_image' | 'course_video',
Record<string, string | null>
>;
mobileAvailable: boolean;
name: string;
number: string;
org: string;
overview: string;
pacing: string;
shortDescription?: string;
start?: string;
startDisplay?: string;
startType?: string;
};
/**
* Get the URL to check the migration task status
@@ -20,20 +50,15 @@ export const getApiWaffleFlagsUrl = (courseId?: string): string => {
return courseId ? `${baseUrl}${apiPath}/${courseId}` : `${baseUrl}${apiPath}`;
};
function normalizeCourseDetail(data) {
export async function getCourseDetails(courseId: string, username: string): Promise<CourseDetailsData> {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseDetailsUrl(courseId, username));
return {
id: data.course_id,
...camelCaseObject(data),
};
}
export async function getCourseDetail(courseId: string, username: string) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/courses/v1/courses/${courseId}?username=${username}`);
return normalizeCourseDetail(data);
}
/**
* The default values of waffle flags, used while we're loading the "real"
* values from Studio's REST API, and/or if we fail to load them.
@@ -80,7 +105,10 @@ export type WaffleFlagsStatus = { id: string | undefined } & Record<WaffleFlagNa
export async function getWaffleFlags(courseId?: string): Promise<WaffleFlagsStatus> {
const { data } = await getAuthenticatedHttpClient()
.get(getApiWaffleFlagsUrl(courseId));
return normalizeCourseDetail(data);
return {
id: data.course_id,
...camelCaseObject(data),
};
}
export interface MigrateParameters {

View File

@@ -1,6 +1,7 @@
import {
skipToken, useMutation, useQuery, useQueryClient,
} from '@tanstack/react-query';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { libraryAuthoringQueryKeys } from '@src/library-authoring/data/apiHooks';
import {
getWaffleFlags,
@@ -8,7 +9,9 @@ import {
bulkModulestoreMigrate,
getModulestoreMigrationStatus,
BulkMigrateRequestData,
getCourseDetails,
} from './api';
import { RequestStatus, RequestStatusType } from './constants';
export const migrationQueryKeys = {
all: ['contentLibrary'],
@@ -18,6 +21,14 @@ export const migrationQueryKeys = {
migrationTask: (migrationId?: string | null) => [...migrationQueryKeys.all, migrationId],
};
export const courseDetailsKey = {
all: ['courseDetails'],
/**
* Base key for get course details data.
*/
courseDetails: (courseId: string) => [...courseDetailsKey.all, courseId],
};
/**
* Get the waffle flags (which enable/disable specific features). They may
* depend on which course we're in.
@@ -72,3 +83,38 @@ export const useModulestoreMigrationStatus = (migrationId: string | null, refetc
refetchInterval,
})
);
/**
* Get details of a course
*/
export const useCourseDetails = (courseId: string) => {
const query = useQuery({
queryKey: courseDetailsKey.courseDetails(courseId),
queryFn: () => getCourseDetails(courseId, getAuthenticatedUser().username),
retry: false,
});
/**
* Include a status summary field for now, to better match the old redux data
* loading status that other components expect. This could be changed/removed in the future.
*/
let status: RequestStatusType = RequestStatus.PENDING;
if (query.isLoading) {
status = RequestStatus.IN_PROGRESS;
} else if (query.isSuccess) {
status = RequestStatus.SUCCESSFUL;
} else if (query.error) {
const errorStatus = (query.error as any)?.response?.status;
if (errorStatus === 404) {
status = RequestStatus.NOT_FOUND;
} else {
status = RequestStatus.FAILED;
}
}
return {
...query,
status,
};
};

View File

@@ -1,32 +0,0 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { type RequestStatusType } from './constants';
export const LOADED = 'LOADED';
const slice = createSlice({
name: 'courseDetail',
initialState: {
courseId: null as string | null,
status: null as RequestStatusType | null,
canChangeProviders: null as null | boolean,
},
reducers: {
updateStatus: (state, { payload }) => {
state.courseId = payload.courseId;
state.status = payload.status;
},
updateCanChangeProviders: (state, { payload }) => {
state.canChangeProviders = payload.canChangeProviders;
},
},
});
export const {
updateStatus,
updateCanChangeProviders,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -1,30 +0,0 @@
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { addModel } from '../generic/model-store';
import { getCourseDetail } from './api';
import {
updateStatus,
updateCanChangeProviders,
} from './slice';
import { RequestStatus } from './constants';
export function fetchCourseDetail(courseId) {
return async (dispatch) => {
dispatch(updateStatus({ courseId, status: RequestStatus.IN_PROGRESS }));
try {
const courseDetail = await getCourseDetail(courseId, getAuthenticatedUser().username);
dispatch(updateStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
dispatch(addModel({ modelType: 'courseDetails', model: courseDetail }));
dispatch(updateCanChangeProviders({
canChangeProviders: getAuthenticatedUser().administrator || new Date(courseDetail.start) > new Date(),
}));
} catch (error) {
if ((error as any).response && (error as any).response.status === 404) {
dispatch(updateStatus({ courseId, status: RequestStatus.NOT_FOUND }));
} else {
dispatch(updateStatus({ courseId, status: RequestStatus.FAILED }));
}
}
};
}

View File

@@ -2,6 +2,8 @@ import { getConfig } from '@edx/frontend-platform';
import { Helmet } from 'react-helmet';
import Cookies from 'universal-cookie';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { getCourseDetailsUrl } from '@src/data/api';
import {
initializeMocks,
fireEvent,
@@ -23,12 +25,6 @@ let cookies;
const courseId = '123';
const courseName = 'About Node JS';
jest.mock('../generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({
name: courseName,
}),
}));
jest.mock('universal-cookie', () => {
const mCookie = {
get: jest.fn(),
@@ -37,16 +33,27 @@ jest.mock('universal-cookie', () => {
return jest.fn(() => mCookie);
});
const renderComponent = () => render(<CourseExportPage courseId={courseId} />);
const renderComponent = () => render(
<CourseAuthoringProvider courseId={courseId}>
<CourseExportPage />
</CourseAuthoringProvider>,
);
describe('<CourseExportPage />', () => {
beforeEach(() => {
const mocks = initializeMocks();
const user = {
userId: 1,
username: 'username',
};
const mocks = initializeMocks({ user });
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
axiosMock
.onGet(postExportCourseApiUrl(courseId))
.reply(200, exportPageMock);
axiosMock
.onGet(getCourseDetailsUrl(courseId, user.username))
.reply(200, { courseId, name: courseName });
cookies = new Cookies();
cookies.get.mockReturnValue(null);
});

View File

@@ -9,11 +9,11 @@ import Cookies from 'universal-cookie';
import { getConfig } from '@edx/frontend-platform';
import { Helmet } from 'react-helmet';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
import SubHeader from '../generic/sub-header/SubHeader';
import { RequestStatus } from '../data/constants';
import { useModel } from '../generic/model-store';
import messages from './messages';
import ExportSidebar from './export-sidebar/ExportSidebar';
import {
@@ -26,11 +26,11 @@ import ExportModalError from './export-modal-error/ExportModalError';
import ExportFooter from './export-footer/ExportFooter';
import ExportStepper from './export-stepper/ExportStepper';
const CourseExportPage = ({ courseId }: { courseId: string }) => {
const CourseExportPage = () => {
const intl = useIntl();
const dispatch = useDispatch();
const exportTriggered = useSelector(getExportTriggered);
const courseDetails = useModel('courseDetails', courseId);
const { courseId, courseDetails } = useCourseAuthoringContext();
const currentStage = useSelector(getCurrentStage);
const { msg: errorMessage } = useSelector(getError);
const loadingStatus = useSelector(getLoadingStatus);

View File

@@ -1,28 +1,27 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Container } from '@openedx/paragon';
import PropTypes from 'prop-types';
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import CourseFilesSlot from '../../plugin-slots/CourseFilesSlot';
import Placeholder from '../../editors/Placeholder';
import { RequestStatus } from '../../data/constants';
import { useModel } from '../../generic/model-store';
import getPageHeadTitle from '../../generic/utils';
import EditFileAlertsSlot from '../../plugin-slots/EditFileAlertsSlot';
import { Container } from '@openedx/paragon';
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import CourseFilesSlot from '@src/plugin-slots/CourseFilesSlot';
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 { EditFileErrors } from '../generic';
import { fetchAssets, resetErrors } from './data/thunks';
import FilesPageProvider from './FilesPageProvider';
import messages from './messages';
import './FilesPage.scss';
const FilesPage = ({
courseId,
}) => {
const FilesPage = () => {
const intl = useIntl();
const dispatch = useDispatch();
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading));
const { courseId, courseDetails } = useCourseAuthoringContext();
document.title = getPageHeadTitle(courseDetails?.name || '', intl.formatMessage(messages.heading));
const {
loadingStatus,
addingStatus: addAssetStatus,
@@ -68,8 +67,4 @@ const FilesPage = ({
);
};
FilesPage.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default FilesPage;

View File

@@ -1,24 +1,20 @@
import userEvent from '@testing-library/user-event';
import ReactDOM from 'react-dom';
import { saveAs } from 'file-saver';
import { camelCaseObject } from '@edx/frontend-platform';
import {
render,
fireEvent,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ReactDOM from 'react-dom';
import { saveAs } from 'file-saver';
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import { RequestStatus } from '../../data/constants';
initializeMocks,
} from '@src/testUtils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { executeThunk } from '@src/utils';
import { RequestStatus } from '@src/data/constants';
import FilesPage from './FilesPage';
import {
generateFetchAssetApiResponse,
@@ -51,20 +47,16 @@ jest.mock('file-saver');
const renderComponent = () => {
render(
<IntlProvider locale="en">
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={[`/course/${courseId}/videos`]}>
<Routes>
<Route
path="/course/:courseId/*"
element={
<FilesPage courseId={courseId} />
}
/>
</Routes>
</MemoryRouter>
</AppProvider>
</IntlProvider>,
<CourseAuthoringProvider courseId={courseId}>
<FilesPage />,
</CourseAuthoringProvider>,
{
path: '/course/:courseId/*',
routerProps: {
initialEntries: [`/course/${courseId}/videos`],
},
params: { courseId },
},
);
};
@@ -91,24 +83,19 @@ const emptyMockStore = async (status) => {
describe('FilesAndUploads', () => {
describe('empty state', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
beforeEach(() => {
const mocks = initializeMocks({
initialState: {
...initialState,
assets: {
...initialState.assets,
assetIds: [],
},
models: {},
},
});
store = initializeStore({
...initialState,
assets: {
...initialState.assets,
assetIds: [],
},
models: {},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' });
});
@@ -152,17 +139,19 @@ describe('FilesAndUploads', () => {
});
describe('valid assets', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
beforeEach(() => {
const mocks = initializeMocks({
initialState: {
...initialState,
assets: {
...initialState.assets,
assetIds: [],
},
models: {},
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' });
global.localStorage.clear();
});

View File

@@ -1,3 +1,6 @@
import { userEvent } from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
import {
render,
act,
@@ -5,20 +8,13 @@ import {
screen,
waitFor,
within,
} from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
initializeMocks,
} from '@src/testUtils';
import { getHttpClient } from '@edx/frontend-platform/auth';
import { executeThunk } from '@src/utils';
import { RequestStatus } from '@src/data/constants';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient, getHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import React from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import { RequestStatus } from '../../data/constants';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import VideosPage from './VideosPage';
import {
generateFetchVideosApiResponse,
@@ -51,20 +47,16 @@ jest.mock('file-saver');
const renderComponent = () => {
render(
<IntlProvider locale="en">
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={[`/course/${courseId}/videos`]}>
<Routes>
<Route
path="/course/:courseId/*"
element={
<VideosPage courseId={courseId} />
}
/>
</Routes>
</MemoryRouter>
</AppProvider>
</IntlProvider>,
<CourseAuthoringProvider courseId={courseId}>
<VideosPage />
</CourseAuthoringProvider>,
{
path: '/course/:courseId/*',
routerProps: {
initialEntries: [`/course/${courseId}/videos`],
},
params: { courseId },
},
);
};
@@ -102,23 +94,19 @@ const emptyMockStore = async (status) => {
describe('Videos page', () => {
describe('empty state', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
const mocks = initializeMocks({
// @ts-ignore
initialState: {
...initialState,
videos: {
...initialState.videos,
videoIds: [],
},
models: {},
},
});
store = initializeStore({
...initialState,
videos: {
...initialState.videos,
videoIds: [],
},
models: {},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
axiosUnauthenticateMock = new MockAdapter(getHttpClient());
file = new File(['(⌐□_□)'], 'download.mp4', { type: 'video/mp4' });
global.localStorage.clear();
@@ -166,16 +154,15 @@ describe('Videos page', () => {
describe('valid videos', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
const mocks = initializeMocks({
// @ts-ignore
initialState: {
...initialState,
},
});
store = initializeStore({ ...initialState });
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
axiosUnauthenticateMock = new MockAdapter(getHttpClient());
file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' });
global.localStorage.clear();
@@ -271,6 +258,7 @@ describe('Videos page', () => {
axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
const uploadSpy = jest.spyOn(api, 'uploadVideo');
// @ts-ignore
const setFailedSpy = jest.spyOn(api, 'sendVideoUploadStatus').mockImplementation(() => {});
uploadSpy.mockResolvedValue(new Promise(() => {}));
@@ -301,6 +289,7 @@ describe('Videos page', () => {
axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
const uploadSpy = jest.spyOn(api, 'uploadVideo');
// @ts-ignore
const setFailedSpy = jest.spyOn(api, 'sendVideoUploadStatus').mockImplementation(() => {});
uploadSpy.mockResolvedValue(new Promise(() => {}));
@@ -356,13 +345,14 @@ describe('Videos page', () => {
fireEvent.click(actionsButton);
const deleteButton = screen.getByText(messages.deleteTitle.defaultMessage).closest('a');
expect(deleteButton).not.toBeNull();
expect(deleteButton).not.toHaveClass('disabled');
axiosMock.onDelete(`${getCourseVideosApiUrl(courseId)}/mOckID1`).reply(204);
fireEvent.click(deleteButton);
fireEvent.click(deleteButton!);
expect(screen.getByText('Delete mOckID1.mp4')).toBeVisible();
fireEvent.click(deleteButton);
fireEvent.click(deleteButton!);
// Wait for the delete confirmation button to appear
const confirmDeleteButton = await screen.findByRole('button', {
@@ -391,10 +381,11 @@ describe('Videos page', () => {
axiosMock.onPut(`${getVideosUrl(courseId)}/download`).reply(200, null);
const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a');
expect(downloadButton).not.toBeNull();
expect(downloadButton).not.toHaveClass('disabled');
await act(async () => {
fireEvent.click(downloadButton);
fireEvent.click(downloadButton!);
});
});
@@ -594,7 +585,7 @@ describe('Videos page', () => {
await waitFor(() => {
expect(screen.queryByText('Delete mOckID1.mp4')).toBeNull();
});
await executeThunk(deleteVideoFile(courseId, 'mOckID1', 5), store.dispatch);
await executeThunk(deleteVideoFile(courseId, 'mOckID1'), store.dispatch);
const deleteStatus = store.getState().videos.deletingStatus;
expect(deleteStatus).toEqual(RequestStatus.SUCCESSFUL);
@@ -733,10 +724,12 @@ describe('Videos page', () => {
fireEvent.click(actionsButton);
const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a');
expect(downloadButton).not.toBeNull();
expect(downloadButton).not.toHaveClass('disabled');
axiosMock.onPut(`${getVideosUrl(courseId)}/download`).reply(404);
fireEvent.click(downloadButton);
fireEvent.click(downloadButton!);
// @ts-ignore
await executeThunk(fetchVideoDownload([{ original: { displayName: 'mOckID1', id: '2', downloadLink: 'test' } }]), store.dispatch);
const updateStatus = store.getState().videos.updatingStatus;

View File

@@ -1,34 +1,33 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Container } from '@openedx/paragon';
import PropTypes from 'prop-types';
import React, { useEffect } from 'react';
import { useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { useDispatch, useSelector } from 'react-redux';
import CourseVideosSlot from '../../plugin-slots/CourseVideosSlot';
import { RequestStatus } from '../../data/constants';
import Placeholder from '../../editors/Placeholder';
import { useModel } from '../../generic/model-store';
import getPageHeadTitle from '../../generic/utils';
import EditVideoAlertsSlot from '../../plugin-slots/EditVideoAlertsSlot';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Container } from '@openedx/paragon';
import CourseVideosSlot from '@src/plugin-slots/CourseVideosSlot';
import { RequestStatus } from '@src/data/constants';
import Placeholder from '@src/editors/Placeholder';
import getPageHeadTitle from '@src/generic/utils';
import EditVideoAlertsSlot from '@src/plugin-slots/EditVideoAlertsSlot';
import { DeprecatedReduxState } from '@src/store';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { EditFileErrors } from '../generic';
import { fetchVideos, resetErrors } from './data/thunks';
import messages from './messages';
import VideosPageProvider from './VideosPageProvider';
const VideosPage = ({
courseId,
}) => {
const VideosPage = () => {
const intl = useIntl();
const dispatch = useDispatch();
const courseDetails = useModel('courseDetails', courseId);
const { courseId, courseDetails } = useCourseAuthoringContext();
const {
loadingStatus,
addingStatus: addVideoStatus,
deletingStatus: deleteVideoStatus,
updatingStatus: updateVideoStatus,
errors: errorMessages,
} = useSelector((state) => state.videos);
} = useSelector((state: DeprecatedReduxState) => state.videos);
const handleErrorReset = (error) => dispatch(resetErrors(error));
@@ -47,7 +46,7 @@ const VideosPage = ({
return (
<VideosPageProvider courseId={courseId}>
<Helmet>
<title>{getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading))}</title>
<title>{getPageHeadTitle(courseDetails?.name || '', intl.formatMessage(messages.heading))}</title>
</Helmet>
<Container size="xl" className="p-4 pt-4.5">
<EditFileErrors
@@ -66,8 +65,4 @@ const VideosPage = ({
);
};
VideosPage.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default VideosPage;

View File

@@ -1,6 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import TinyMceWidget, { prepareEditorRef } from '../editors/sharedComponents/TinyMceWidget';
import { DEFAULT_EMPTY_WYSIWYG_VALUE } from '../constants';
@@ -14,7 +13,7 @@ export const WysiwygEditor = ({
initialValue, editorType, onChange, minHeight,
}) => {
const { editorRef, refReady, setEditorRef } = prepareEditorRef();
const { courseId } = useSelector((state) => state.courseDetail);
const { courseId } = useCourseAuthoringContext();
const isEquivalentCodeExtraSpaces = (first, second) => {
// Utils allows to compare code extra spaces
const removeWhitespace = (str) => str.replace(/\s/g, '');

View File

@@ -8,14 +8,13 @@ import {
useGradingSettings,
useGradingSettingUpdater,
} from 'CourseAuthoring/grading-settings/data/apiHooks';
import PropTypes from 'prop-types';
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { STATEFUL_BUTTON_STATES } from '../constants';
import AlertMessage from '../generic/alert-message';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import { useModel } from '../generic/model-store';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
import SectionSubHeader from '../generic/section-sub-header';
import SubHeader from '../generic/sub-header/SubHeader';
@@ -28,8 +27,9 @@ import GradingSidebar from './grading-sidebar';
import { useConvertGradeCutoffs, useUpdateGradingData } from './hooks';
import messages from './messages';
const GradingSettings = ({ courseId }) => {
const GradingSettings = () => {
const intl = useIntl();
const { courseId, courseDetails } = useCourseAuthoringContext();
const {
data: gradingSettings,
isLoading: isGradingSettingsLoading,
@@ -56,7 +56,7 @@ const GradingSettings = ({ courseId }) => {
const [showOverrideInternetConnectionAlert, setOverrideInternetConnectionAlert] = useState(false);
const [eligibleGrade, setEligibleGrade] = useState(null);
const courseName = useModel('courseDetails', courseId)?.name;
const courseName = courseDetails?.name || '';
const {
graders,
@@ -279,8 +279,4 @@ const GradingSettings = ({ courseId }) => {
);
};
GradingSettings.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default GradingSettings;

View File

@@ -1,14 +1,8 @@
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
act, fireEvent, render, screen,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
act, fireEvent, render, screen, initializeMocks,
} from '@src/testUtils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import initializeStore from '../store';
import gradingSettings from './__mocks__/gradingSettings';
import { getCourseSettingsApiUrl, getGradingSettingsApiUrl } from './data/api';
import * as apiHooks from './data/apiHooks';
@@ -17,33 +11,21 @@ import messages from './messages';
const courseId = '123';
let axiosMock;
let store;
const queryClient = new QueryClient();
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<GradingSettings courseId={courseId} />
</QueryClientProvider>
</IntlProvider>
</AppProvider>
<CourseAuthoringProvider courseId={courseId}>
<GradingSettings />
</CourseAuthoringProvider>
);
describe('<GradingSettings />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3, username: 'abc123', administrator: true, roles: [],
},
});
const mocks = initializeMocks();
// jsdom doesn't implement scrollTo; mock to avoid noisy console errors.
Object.defineProperty(window, 'scrollTo', { value: jest.fn(), writable: true });
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock = mocks.axiosMock;
axiosMock
.onGet(getGradingSettingsApiUrl(courseId))
.reply(200, gradingSettings);

View File

@@ -1,3 +1,4 @@
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import {
initializeMocks,
render,
@@ -20,7 +21,11 @@ const courseId = 'course-v1:org+101+101';
const enrollmentTrackGroups = groupConfigurationResponseMock.allGroupConfigurations[0];
const contentGroups = groupConfigurationResponseMock.allGroupConfigurations[1];
const renderComponent = () => render(<GroupConfigurations courseId={courseId} />);
const renderComponent = () => render(
<CourseAuthoringProvider courseId={courseId}>
<GroupConfigurations />
</CourseAuthoringProvider>,
);
describe('<GroupConfigurations />', () => {
beforeEach(async () => {

View File

@@ -1,11 +1,10 @@
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Container, Layout, Stack, Row,
} from '@openedx/paragon';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { LoadingSpinner } from '../generic/Loading';
import { useModel } from '../generic/model-store';
import SubHeader from '../generic/sub-header/SubHeader';
import getPageHeadTitle from '../generic/utils';
import ProcessingNotification from '../generic/processing-notification';
@@ -18,9 +17,9 @@ import GroupConfigurationSidebar from './group-configuration-sidebar';
import { useGroupConfigurations } from './hooks';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
const GroupConfigurations = ({ courseId }) => {
const GroupConfigurations = () => {
const { formatMessage } = useIntl();
const courseDetails = useModel('courseDetails', courseId);
const { courseId, courseDetails } = useCourseAuthoringContext();
const {
isLoading,
savingStatus,
@@ -128,8 +127,4 @@ const GroupConfigurations = ({ courseId }) => {
);
};
GroupConfigurations.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default GroupConfigurations;

View File

@@ -1,6 +1,8 @@
import { Helmet } from 'react-helmet';
import Cookies from 'universal-cookie';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { getCourseDetailsUrl } from '@src/data/api';
import { initializeMocks, render, waitFor } from '../testUtils';
import { RequestStatus } from '../data/constants';
import messages from './messages';
@@ -15,12 +17,6 @@ let cookies;
const courseId = '123';
const courseName = 'About Node JS';
jest.mock('../generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({
name: courseName,
}),
}));
jest.mock('universal-cookie', () => {
const Cookie = {
get: jest.fn(),
@@ -29,16 +25,27 @@ jest.mock('universal-cookie', () => {
return jest.fn(() => Cookie);
});
const renderComponent = () => render(<CourseImportPage courseId={courseId} />);
const renderComponent = () => render(
<CourseAuthoringProvider courseId={courseId}>
<CourseImportPage />
</CourseAuthoringProvider>,
);
describe('<CourseImportPage />', () => {
beforeEach(() => {
const mocks = initializeMocks();
const user = {
userId: 1,
username: 'username',
};
const mocks = initializeMocks({ user });
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
axiosMock
.onGet(getImportStatusApiUrl(courseId, 'testFileName.test'))
.reply(200, { importStatus: 1, message: '' });
axiosMock
.onGet(getCourseDetailsUrl(courseId, user.username))
.reply(200, { courseId, name: courseName });
cookies = new Cookies();
cookies.get.mockReturnValue(null);
});

View File

@@ -1,5 +1,5 @@
/* eslint-disable max-len */
import React, { useEffect } from 'react';
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
@@ -8,11 +8,12 @@ import {
import Cookies from 'universal-cookie';
import { Helmet } from 'react-helmet';
import SubHeader from '../generic/sub-header/SubHeader';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import { RequestStatus } from '../data/constants';
import { useModel } from '../generic/model-store';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
import SubHeader from '@src/generic/sub-header/SubHeader';
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
import { RequestStatus } from '@src/data/constants';
import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import {
updateFileName, updateImportTriggered, updateSavingStatus, updateSuccessDate,
} from './data/slice';
@@ -23,11 +24,11 @@ import ImportSidebar from './import-sidebar/ImportSidebar';
import FileSection from './file-section/FileSection';
import messages from './messages';
const CourseImportPage = ({ courseId }: { courseId: string }) => {
const CourseImportPage = () => {
const intl = useIntl();
const dispatch = useDispatch();
const cookies = new Cookies();
const courseDetails = useModel('courseDetails', courseId);
const { courseId, courseDetails } = useCourseAuthoringContext();
const importTriggered = useSelector(getImportTriggered);
const savingStatus = useSelector(getSavingStatus);
const loadingStatus = useSelector(getLoadingStatus);

View File

@@ -2,14 +2,9 @@
/* eslint-disable react/jsx-filename-extension */
import {
fireEvent, render, waitFor, screen,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
initializeMocks,
} from '@src/testUtils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import messages from './messages';
import generalMessages from '../messages';
import scanResultsMessages from './scan-results/messages';
@@ -24,36 +19,21 @@ import {
import * as thunks from './data/thunks';
import { useWaffleFlags } from '../data/apiHooks';
let store;
let axiosMock;
const courseId = '123';
const courseName = 'About Node JS';
jest.mock('../generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({
name: courseName,
}),
}));
// Mock the waffle flags hook
jest.mock('../data/apiHooks', () => ({
...jest.requireActual('../data/apiHooks'),
useWaffleFlags: jest.fn(() => ({
enableCourseOptimizerCheckPrevRunLinks: false,
})),
}));
jest.mock('../generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({
name: 'About Node JS',
}),
}));
const OptimizerPage = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<CourseOptimizerPage courseId={courseId} />
</IntlProvider>
</AppProvider>
<CourseAuthoringProvider courseId={courseId}>
<CourseOptimizerPage />
</CourseAuthoringProvider>
);
const setupOptimizerPage = async (apiResponse = mockApiResponse) => {
@@ -132,16 +112,8 @@ describe('CourseOptimizerPage', () => {
beforeEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
axiosMock
.onPost(postLinkCheckCourseApiUrl(courseId))
.reply(200, { LinkCheckStatus: 'In-Progress' });

View File

@@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */
import {
useEffect, useState, useRef, FC, MutableRefObject,
useEffect, useState, useRef, MutableRefObject,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -10,6 +10,7 @@ import {
import { SpinnerSimple } from '@openedx/paragon/icons';
import { Helmet } from 'react-helmet';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import CourseStepper from '../generic/course-stepper';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
import AlertMessage from '../generic/alert-message';
@@ -22,7 +23,6 @@ import {
getLastScannedAt, getRerunLinkUpdateInProgress, getRerunLinkUpdateResult,
} from './data/selectors';
import { startLinkCheck, fetchLinkCheckStatus, fetchRerunLinkUpdateStatus } from './data/thunks';
import { useModel } from '../generic/model-store';
import ScanResults from './scan-results';
const pollLinkCheckStatus = (dispatch: any, courseId: string, delay: number): number => {
@@ -74,7 +74,7 @@ export function pollLinkCheckDuringScan(
}
}
const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
const CourseOptimizerPage = () => {
const dispatch = useDispatch();
const linkCheckInProgress = useSelector(getLinkCheckInProgress);
const rerunLinkUpdateInProgress = useSelector(getRerunLinkUpdateInProgress);
@@ -88,7 +88,7 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
const isLoadingDenied = (RequestFailureStatuses as string[]).includes(loadingStatus);
const interval = useRef<number | undefined>(undefined);
const rerunUpdateInterval = useRef<number | undefined>(undefined);
const courseDetails = useModel('courseDetails', courseId);
const { courseId, courseDetails } = useCourseAuthoringContext();
const linkCheckPresent = currentStage != null ? currentStage >= 0 : !!currentStage;
const [showStepper, setShowStepper] = useState(false);
const [scanResultsError, setScanResultsError] = useState<string | null>(null);

View File

@@ -1,10 +1,11 @@
// @ts-check
import { screen, waitFor } from '@testing-library/react';
import {
screen, waitFor, initializeMocks, render,
} from '@src/testUtils';
import { getConfig, setConfig } from '@edx/frontend-platform';
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { PagesAndResources } from '.';
import { initializeMocks, render } from '../testUtils';
const mockPlugin = (identifier) => ({
plugins: [
@@ -22,6 +23,12 @@ const mockPlugin = (identifier) => ({
const courseId = 'course-v1:edX+TestX+Test_Course';
const renderComponent = () => render(
<CourseAuthoringProvider courseId={courseId}>
<PagesAndResources />
</CourseAuthoringProvider>,
);
describe('PagesAndResources', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -45,7 +52,7 @@ describe('PagesAndResources', () => {
};
initializeMocks({ initialState });
render(<PagesAndResources courseId={courseId} />);
renderComponent();
await waitFor(() => expect(screen.queryByRole('heading', { name: 'Content permissions' })).not.toBeInTheDocument());
await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument());
@@ -75,7 +82,7 @@ describe('PagesAndResources', () => {
};
initializeMocks({ initialState });
render(<PagesAndResources courseId={courseId} />);
renderComponent();
await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Learning Assistant')).toBeInTheDocument());
@@ -108,7 +115,7 @@ describe('PagesAndResources', () => {
};
initializeMocks({ initialState });
render(<PagesAndResources courseId={courseId} />);
renderComponent();
await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Xpert unit summaries')).toBeInTheDocument());

View File

@@ -1,42 +1,43 @@
import React, { useContext, useEffect } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { PageWrap, AppContext } from '@edx/frontend-platform/react';
import { useContext, useEffect } from 'react';
import { Routes, Route } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { PageWrap, AppContext } from '@edx/frontend-platform/react';
import { Button, Hyperlink } from '@openedx/paragon';
import { useModels } from '@src/generic/model-store';
import { RequestStatus } from '@src/data/constants';
import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert';
import getPageHeadTitle from '@src/generic/utils';
import { AdditionalCoursePluginSlot } from '@src/plugin-slots/AdditionalCoursePluginSlot';
import { AdditionalCourseContentPluginSlot } from '@src/plugin-slots/AdditionalCourseContentPluginSlot';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { DeprecatedReduxState } from '@src/store';
import messages from './messages';
import DiscussionsSettings from './discussions';
import PageGrid from './pages/PageGrid';
import { fetchCourseApps } from './data/thunks';
import { useModels, useModel } from '../generic/model-store';
import { getCourseAppsApiStatus, getLoadingStatus } from './data/selectors';
import PagesAndResourcesProvider from './PagesAndResourcesProvider';
import { RequestStatus } from '../data/constants';
import SettingsComponent from './SettingsComponent';
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
import getPageHeadTitle from '../generic/utils';
import { AdditionalCoursePluginSlot } from '../plugin-slots/AdditionalCoursePluginSlot';
import { AdditionalCourseContentPluginSlot } from '../plugin-slots/AdditionalCourseContentPluginSlot';
const PagesAndResources = ({ courseId }) => {
const PagesAndResources = () => {
const intl = useIntl();
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading));
const { courseId, courseDetails } = useCourseAuthoringContext();
document.title = getPageHeadTitle(courseDetails?.name || '', intl.formatMessage(messages.heading));
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchCourseApps(courseId));
}, [courseId]);
const courseAppIds = useSelector(state => state.pagesAndResources.courseAppIds);
const courseAppIds = useSelector((state: DeprecatedReduxState) => state.pagesAndResources.courseAppIds);
const loadingStatus = useSelector(getLoadingStatus);
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
// @ts-ignore
const { config } = useContext(AppContext);
const learningCourseURL = `${config.LEARNING_BASE_URL}/course/${courseId}`;
const redirectUrl = `/course/${courseId}/pages-and-resources`;
@@ -47,7 +48,7 @@ const PagesAndResources = ({ courseId }) => {
// We want the Xpert learning assistant and unit summaries to appear in the "Content Permissions" section instead,
// so we remove them from pages and add them to contentPermissionsPages.
const contentPermissionsPages = [];
const contentPermissionsPages: any[] = [];
['xpert_unit_summary', 'learning_assistant'].forEach(separateAppId => {
const index = pages.findIndex(app => app.id === separateAppId);
@@ -86,9 +87,9 @@ const PagesAndResources = ({ courseId }) => {
</div>
<Routes>
<Route path="discussion/configure/:appId" element={<PageWrap><DiscussionsSettings courseId={courseId} /></PageWrap>} />
<Route path="discussion" element={<PageWrap><DiscussionsSettings courseId={courseId} /></PageWrap>} />
<Route path="discussion/settings" element={<PageWrap><DiscussionsSettings courseId={courseId} /></PageWrap>} />
<Route path="discussion/configure/:appId" element={<PageWrap><DiscussionsSettings /></PageWrap>} />
<Route path="discussion" element={<PageWrap><DiscussionsSettings /></PageWrap>} />
<Route path="discussion/settings" element={<PageWrap><DiscussionsSettings /></PageWrap>} />
<Route path=":appId/settings" element={<PageWrap><SettingsComponent url={redirectUrl} /></PageWrap>} />
</Routes>
@@ -109,8 +110,4 @@ const PagesAndResources = ({ courseId }) => {
);
};
PagesAndResources.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default PagesAndResources;

View File

@@ -1,7 +1,6 @@
import React, {
useCallback, useContext, useEffect, useState,
} from 'react';
import PropTypes from 'prop-types';
import { useNavigate, useParams } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -9,7 +8,10 @@ import {
Alert, Button, FullscreenModal, Stepper,
} from '@openedx/paragon';
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert';
import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert';
import Loading from '@src/generic/Loading';
import messages from './messages';
import DiscussionsProvider from './DiscussionsProvider';
@@ -17,22 +19,18 @@ import { fetchProviders } from './data/thunks';
import AppList from './app-list';
import AppConfigForm from './app-config-form';
import { DENIED, FAILED } from './data/slice';
import ConnectionErrorAlert from '../../generic/ConnectionErrorAlert';
import { useModel } from '../../generic/model-store';
import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert';
import Loading from '../../generic/Loading';
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
const SELECTION_STEP = 'selection';
const SETTINGS_STEP = 'settings';
const DiscussionsSettings = ({ courseId }) => {
const DiscussionsSettings = () => {
const intl = useIntl();
const navigate = useNavigate();
const dispatch = useDispatch();
const { path: pagesAndResourcesPath } = useContext(PagesAndResourcesContext);
const { status, hasValidationError } = useSelector(state => state.discussions);
const { canChangeProviders } = useSelector(state => state.courseDetail);
const courseDetail = useModel('courseDetails', courseId);
const { courseId, courseDetails, canChangeProviders } = useCourseAuthoringContext();
useEffect(() => {
dispatch(fetchProviders(courseId));
@@ -56,7 +54,7 @@ const DiscussionsSettings = ({ courseId }) => {
navigate(discussionsPath);
}, [discussionsPath]);
if (!courseDetail) {
if (!courseDetails) {
return <Loading />;
}
@@ -147,8 +145,4 @@ const DiscussionsSettings = ({ courseId }) => {
);
};
DiscussionsSettings.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default DiscussionsSettings;

View File

@@ -1,4 +1,5 @@
import ReactDOM from 'react-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
getConfig, initializeMockApp, setConfig,
} from '@edx/frontend-platform';
@@ -10,16 +11,15 @@ import {
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
import React from 'react';
import {
Routes,
Route,
MemoryRouter,
useLocation,
} from 'react-router-dom';
import { fetchCourseDetail } from '../../data/thunks';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import initializeStore from '@src/store';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { getCourseDetailsUrl } from '@src/data/api';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
import ltiMessages from './app-config-form/apps/lti/messages';
import appMessages from './app-config-form/messages';
@@ -38,6 +38,13 @@ const courseId = 'course-v1:edX+TestX+Test_Course';
let axiosMock;
let store;
let container;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = jest.fn(node => node);
@@ -50,21 +57,25 @@ const LocationDisplay = () => {
function renderComponent(route) {
const wrapper = render(
<AppProvider store={store} wrapWithRouter={false}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={[`${route}`]}>
<Routes>
<Route
path={`/course/${courseId}/pages-and-resources/discussion/configure/:appId`}
element={<PageWrap><DiscussionsSettings courseId={courseId} /></PageWrap>}
/>
<Route
path={`/course/${courseId}/pages-and-resources/discussion`}
element={<PageWrap><DiscussionsSettings courseId={courseId} /></PageWrap>}
/>
</Routes>
<LocationDisplay />
</MemoryRouter>
</PagesAndResourcesProvider>
<QueryClientProvider client={queryClient}>
<CourseAuthoringProvider courseId={courseId}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={[`${route}`]}>
<Routes>
<Route
path={`/course/${courseId}/pages-and-resources/discussion/configure/:appId`}
element={<PageWrap><DiscussionsSettings /></PageWrap>}
/>
<Route
path={`/course/${courseId}/pages-and-resources/discussion`}
element={<PageWrap><DiscussionsSettings /></PageWrap>}
/>
</Routes>
<LocationDisplay />
</MemoryRouter>
</PagesAndResourcesProvider>
</CourseAuthoringProvider>
</QueryClientProvider>
</AppProvider>,
);
container = wrapper.container;
@@ -74,25 +85,21 @@ describe('DiscussionsSettings', () => {
let user;
beforeEach(() => {
user = userEvent.setup();
const username = 'abc123';
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
username,
administrator: true,
roles: [],
},
});
store = initializeStore({
models: {
courseDetails: {
[courseId]: {
start: Date(),
},
},
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseDetailsUrl(courseId, username))
.reply(200, { courseId, name: 'Course Test' });
});
describe('with successful network connections', () => {
@@ -217,7 +224,6 @@ describe('DiscussionsSettings', () => {
test('requires confirmation if changing provider', async () => {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courses/v1/courses/${courseId}?username=abc123`).reply(200, courseDetailResponse);
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
renderComponent(`/course/${courseId}/pages-and-resources/discussion`);
// This is an important line that ensures the spinner has been removed - and thus our main
@@ -238,7 +244,6 @@ describe('DiscussionsSettings', () => {
test('can cancel confirmation', async () => {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courses/v1/courses/${courseId}?username=abc123`).reply(200, courseDetailResponse);
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
renderComponent(`/course/${courseId}/pages-and-resources/discussion`);
// This is an important line that ensures the spinner has been removed - and thus our main
@@ -445,28 +450,26 @@ describe.each([
const enablePIISharing = false;
beforeEach(() => {
const username = 'abc123';
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
username,
administrator: true,
roles: [],
},
});
store = initializeStore({
models: {
courseDetails: {
[courseId]: {},
},
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(getDiscussionsProvidersUrl(courseId))
.reply(200, generateProvidersApiResponse(false));
axiosMock.onGet(getDiscussionsSettingsUrl(courseId))
.reply(200, generatePiazzaApiResponse(piiSharingAllowed));
axiosMock
.onGet(getCourseDetailsUrl(courseId, username))
.reply(200, { courseId, name: 'Course Test' });
});
test(`${piiSharingAllowed ? 'shows PII share username/email field when piiSharingAllowed is true'

View File

@@ -1,12 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import Responsive from 'react-responsive';
import {
Card, CheckboxControl, breakpoints,
} from '@openedx/paragon';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import messages from './messages';
import appMessages from '../app-config-form/messages';
import FeaturesList from './FeaturesList';
@@ -15,7 +14,7 @@ const AppCard = ({
app, onClick, selected, features,
}) => {
const intl = useIntl();
const { canChangeProviders } = useSelector(state => state.courseDetail);
const { canChangeProviders } = useCourseAuthoringContext();
const supportText = app.hasFullSupport
? intl.formatMessage(messages.appFullSupport)
: intl.formatMessage(messages.appBasicSupport);

View File

@@ -1,16 +1,12 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import { render, queryByLabelText, queryByTestId } from '@testing-library/react';
import {
render, queryByLabelText, queryByTestId, initializeMocks,
} from '@src/testUtils';
import { executeThunk } from '@src/utils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import AppCard from './AppCard';
import messages from './messages';
import appMessages from '../app-config-form/messages';
import initializeStore from '../../../store';
import { executeThunk } from '../../../utils';
import { getDiscussionsProvidersUrl } from '../data/api';
import { fetchProviders } from '../data/thunks';
import { legacyApiResponse } from '../factories/mockApiResponses';
@@ -29,17 +25,9 @@ describe('AppCard', () => {
let container;
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
});
const mockStore = async (mockResponse) => {
@@ -49,16 +37,14 @@ describe('AppCard', () => {
const createComponent = (data) => {
const wrapper = render(
<AppProvider store={store}>
<IntlProvider locale="en">
<AppCard
app={data}
onClick={() => jest.fn()}
selected={selected}
features={[]}
/>
</IntlProvider>
</AppProvider>,
<CourseAuthoringProvider courseId={courseId}>
<AppCard
app={data}
onClick={() => jest.fn()}
selected={selected}
features={[]}
/>
</CourseAuthoringProvider>,
);
container = wrapper.container;
return container;

View File

@@ -1,20 +1,15 @@
/* eslint-disable react/jsx-no-constructed-context-values */
import React from 'react';
import {
render, screen, within, queryAllByRole, waitFor, fireEvent,
} from '@testing-library/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
initializeMocks,
} from '@src/testUtils';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { breakpoints } from '@openedx/paragon';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
import { Context as ResponsiveContext } from 'react-responsive';
import initializeStore from '../../../store';
import { executeThunk } from '../../../utils';
import { executeThunk } from '@src/utils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { getDiscussionsProvidersUrl, getDiscussionsSettingsUrl } from '../data/api';
import { fetchDiscussionSettings, fetchProviders } from '../data/thunks';
import {
@@ -40,13 +35,11 @@ const mockStore = async (mockResponse, provider) => {
function renderComponent(screenWidth = breakpoints.extraLarge.minWidth) {
const wrapper = render(
<AppProvider store={store}>
<CourseAuthoringProvider courseId={courseId}>
<ResponsiveContext.Provider value={{ width: screenWidth }}>
<IntlProvider locale="en">
<AppList />
</IntlProvider>
<AppList />
</ResponsiveContext.Provider>
</AppProvider>,
</CourseAuthoringProvider>,
);
container = wrapper.container;
}
@@ -54,17 +47,10 @@ function renderComponent(screenWidth = breakpoints.extraLarge.minWidth) {
describe('AppList', () => {
describe('AppList for Admin role', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
const mocks = initializeMocks();
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
await mockStore(piazzaApiResponse);
});
@@ -154,8 +140,8 @@ describe('AppList', () => {
describe('AppList for Non Admin role', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
const mocks = initializeMocks({
user: {
userId: 3,
username: 'abc123',
administrator: false,
@@ -163,8 +149,8 @@ describe('AppList', () => {
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
await mockStore(legacyApiResponse, 'legacy');
});

View File

@@ -3,13 +3,12 @@ import { initializeMockApp } from '@edx/frontend-platform/testing';
import MockAdapter from 'axios-mock-adapter';
import { waitFor } from '@testing-library/react';
import { DivisionSchemes } from '../../../data/constants';
import { LOADED } from '../../../data/slice';
import initializeStore from '../../../store';
import { executeThunk } from '../../../utils';
import { generateProvidersApiResponse, legacyApiResponse, piazzaApiResponse } from '../factories/mockApiResponses';
import { getDiscussionsProvidersUrl, getDiscussionsSettingsUrl } from './api';
import {
DENIED, FAILED, SAVED, selectApp, updateValidationStatus,
DENIED, FAILED, SAVED, LOADED, selectApp, updateValidationStatus,
} from './slice';
import { fetchDiscussionSettings, fetchProviders, saveProviderConfig } from './thunks';

View File

@@ -5,17 +5,19 @@ import {
render,
waitFor,
fireEvent,
} from '../testUtils';
import { executeThunk } from '../utils';
} from '@src/testUtils';
import { executeThunk } from '@src/utils';
import genericMessages from '@src/generic/help-sidebar/messages';
import { DATE_FORMAT } from '@src/constants';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { courseDetailsMock, courseSettingsMock } from './__mocks__';
import { getCourseDetailsApiUrl, getCourseSettingsApiUrl } from './data/api';
import { updateCourseDetailsQuery } from './data/thunks';
import { DATE_FORMAT } from '../constants';
import creditMessages from './credit-section/messages';
import pacingMessages from './pacing-section/messages';
import basicMessages from './basic-section/messages';
import scheduleMessages from './schedule-section/messages';
import genericMessages from '../generic/help-sidebar/messages';
import messages from './messages';
import ScheduleAndDetails from '.';
@@ -47,6 +49,12 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
<textarea {...props} onFocus={() => {}} onBlur={() => {}} />
)));
const renderComponent = () => render(
<CourseAuthoringProvider courseId={courseId}>
<ScheduleAndDetails />
</CourseAuthoringProvider>,
);
describe('<ScheduleAndDetails />', () => {
beforeEach(() => {
const mocks = initializeMocks();
@@ -64,7 +72,7 @@ describe('<ScheduleAndDetails />', () => {
});
it('should render without errors', async () => {
const { getByText, getByRole, getAllByText } = render(<ScheduleAndDetails courseId={courseId} />);
const { getByText, getByRole, getAllByText } = renderComponent();
await waitFor(() => {
const scheduleAndDetailElements = getAllByText(messages.headingTitle.defaultMessage);
const scheduleAndDetailTitle = scheduleAndDetailElements[0];
@@ -99,7 +107,7 @@ describe('<ScheduleAndDetails />', () => {
.onGet(getCourseSettingsApiUrl(courseId))
.reply(200, updatedResponse);
const { queryAllByText } = render(<ScheduleAndDetails courseId={courseId} />);
const { queryAllByText } = renderComponent();
await waitFor(() => {
expect(
queryAllByText(creditMessages.creditTitle.defaultMessage).length,
@@ -108,9 +116,7 @@ describe('<ScheduleAndDetails />', () => {
});
it('should show save alert onChange ', async () => {
const { getAllByPlaceholderText, getByText } = render(
<ScheduleAndDetails courseId={courseId} />,
);
const { getAllByPlaceholderText, getByText } = renderComponent();
let inputs;
await waitFor(() => {
inputs = getAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase());
@@ -124,7 +130,7 @@ describe('<ScheduleAndDetails />', () => {
});
it('should display a success message when course details saves', async () => {
const { getByText } = render(<ScheduleAndDetails courseId={courseId} />);
const { getByText } = renderComponent();
await executeThunk(updateCourseDetailsQuery(courseId, 'DaTa'), store.dispatch);
expect(getByText(messages.alertSuccess.defaultMessage)).toBeInTheDocument();
});
@@ -133,7 +139,7 @@ describe('<ScheduleAndDetails />', () => {
axiosMock
.onGet(getCourseDetailsApiUrl(courseId))
.reply(404, 'error');
const { getByText } = render(<ScheduleAndDetails courseId={courseId} />);
const { getByText } = renderComponent();
await waitFor(() => {
expect(getByText(messages.alertLoadFail.defaultMessage)).toBeInTheDocument();
});
@@ -143,7 +149,7 @@ describe('<ScheduleAndDetails />', () => {
axiosMock
.onGet(getCourseSettingsApiUrl(courseId))
.reply(404, 'error');
const { getByText } = render(<ScheduleAndDetails courseId={courseId} />);
const { getByText } = renderComponent();
await waitFor(() => {
expect(getByText(messages.alertLoadFail.defaultMessage)).toBeInTheDocument();
});
@@ -153,7 +159,7 @@ describe('<ScheduleAndDetails />', () => {
axiosMock
.onPut(getCourseDetailsApiUrl(courseId))
.reply(404, 'error');
const { getByText } = render(<ScheduleAndDetails courseId={courseId} />);
const { getByText } = renderComponent();
await act(async () => {
await executeThunk(updateCourseDetailsQuery(courseId, 'DaTa'), store.dispatch);
});

View File

@@ -1,5 +1,3 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import {
Container, Button, Layout, StatefulButton,
@@ -11,14 +9,15 @@ import {
} from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import Placeholder from '../editors/Placeholder';
import { RequestStatus } from '../data/constants';
import { useModel } from '../generic/model-store';
import AlertMessage from '../generic/alert-message';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import { STATEFUL_BUTTON_STATES } from '../constants';
import getPageHeadTitle from '../generic/utils';
import { useScrollToHashElement } from '../hooks';
import Placeholder from '@src/editors/Placeholder';
import { RequestStatus } from '@src/data/constants';
import AlertMessage from '@src/generic/alert-message';
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
import { STATEFUL_BUTTON_STATES } from '@src/constants';
import getPageHeadTitle from '@src/generic/utils';
import { useScrollToHashElement } from '@src/hooks';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import {
fetchCourseSettingsQuery,
fetchCourseDetailsQuery,
@@ -44,7 +43,7 @@ import ScheduleSidebar from './schedule-sidebar';
import messages from './messages';
import { useLoadValuesPrompt, useSaveValuesPrompt } from './hooks';
const ScheduleAndDetails = ({ courseId }) => {
const ScheduleAndDetails = () => {
const intl = useIntl();
const courseSettings = useSelector(getCourseSettings);
const courseDetails = useSelector(getCourseDetails);
@@ -53,8 +52,8 @@ const ScheduleAndDetails = ({ courseId }) => {
const isLoading = loadingDetailsStatus === RequestStatus.IN_PROGRESS
|| loadingSettingsStatus === RequestStatus.IN_PROGRESS;
const course = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(course?.name, intl.formatMessage(messages.headingTitle));
const { courseId, courseDetails: course } = useCourseAuthoringContext();
document.title = getPageHeadTitle(course?.name || '', intl.formatMessage(messages.headingTitle));
const {
platformName,
@@ -395,8 +394,4 @@ const ScheduleAndDetails = ({ courseId }) => {
);
};
ScheduleAndDetails.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default ScheduleAndDetails;

View File

@@ -1,10 +1,6 @@
import React from 'react';
import { AppProvider } from '@edx/frontend-platform/react';
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { initializeMocks, render } from '@src/testUtils';
import initializeStore from '../../store';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { courseSettingsMock, courseDetailsMock } from '../__mocks__';
import messages from './messages';
import IntroducingSection from '.';
@@ -29,14 +25,11 @@ jest.mock('../../editors/sharedComponents/TinyMceWidget', () => ({
})),
}));
let store;
const onChangeMock = jest.fn();
const RootWrapper = (props) => (
<IntlProvider locale="en">
<AppProvider store={store}>
<IntroducingSection {...props} />
</AppProvider>
</IntlProvider>
<CourseAuthoringProvider courseId="1">
<IntroducingSection {...props} />
</CourseAuthoringProvider>
);
const {
@@ -68,16 +61,7 @@ const props = {
describe('<IntroducingSection />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
initializeMocks();
});
it('renders successfully', () => {

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { render, initializeMocks } from '../testUtils';
import VideoSelectorContainer from './VideoSelectorContainer';
@@ -8,7 +9,11 @@ describe('VideoSelectorContainer', () => {
});
it('renders the wrapper div with correct class', () => {
const { container } = render(<VideoSelectorContainer courseId="course-v1:edX+Test+2024" />);
const { container } = render(
<CourseAuthoringProvider courseId="course-v1:edX+Test+2024">
<VideoSelectorContainer />
</CourseAuthoringProvider>,
);
expect(container.querySelector('.selector-page')).toBeInTheDocument();
});
});

View File

@@ -1,13 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useParams } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import VideoSelectorPage from '../editors/VideoSelectorPage';
const VideoSelectorContainer = ({
courseId,
}) => {
const VideoSelectorContainer = () => {
const { blockId } = useParams();
const { courseId } = useCourseAuthoringContext();
return (
<div className="selector-page">
<VideoSelectorPage
@@ -20,8 +18,4 @@ const VideoSelectorContainer = ({
);
};
VideoSelectorContainer.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default VideoSelectorContainer;

View File

@@ -5,7 +5,6 @@ import { configureStore, Reducer } from '@reduxjs/toolkit';
import { reducer as liveReducer } from '@openedx-plugins/course-app-live/data/slice';
import { reducer as modelsReducer } from './generic/model-store';
import { reducer as courseDetailReducer } from './data/slice';
import { reducer as discussionsReducer } from './pages-and-resources/discussions/data/slice';
import { reducer as pagesAndResourcesReducer } from './pages-and-resources/data/slice';
import { reducer as customPagesReducer } from './custom-pages/data/slice';
@@ -36,7 +35,6 @@ type InferState<ReducerType> = ReducerType extends Reducer<infer T> ? T : never;
* TODO: refactor each part to use React Context and React Query instead.
*/
export interface DeprecatedReduxState {
courseDetail: InferState<typeof courseDetailReducer>;
customPages: Record<string, any>;
discussions: Record<string, any>;
assets: Record<string, any>;
@@ -66,7 +64,6 @@ export interface DeprecatedReduxState {
export default function initializeStore(preloadedState: Partial<DeprecatedReduxState> | undefined = undefined) {
return configureStore<DeprecatedReduxState>({
reducer: {
courseDetail: courseDetailReducer,
customPages: customPagesReducer,
discussions: discussionsReducer,
assets: filesReducer,

View File

@@ -1,6 +1,7 @@
// @ts-check
import userEvent from '@testing-library/user-event';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { initializeMocks, render, waitFor } from '../testUtils';
import { RequestStatus } from '../data/constants';
import { executeThunk } from '../utils';
@@ -15,7 +16,11 @@ let store;
const courseId = 'course-v1:org+101+101';
const emptyTextbooksMock = { textbooks: [] };
const renderComponent = () => render(<Textbooks courseId={courseId} />);
const renderComponent = () => render(
<CourseAuthoringProvider courseId={courseId}>
<Textbooks />
</CourseAuthoringProvider>,
);
describe('<Textbooks />', () => {
beforeEach(async () => {

View File

@@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Breadcrumb,
@@ -11,11 +10,11 @@ import { Add as AddIcon } from '@openedx/paragon/icons';
import { useSelector } from 'react-redux';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useWaffleFlags } from '../data/apiHooks';
import { SavingErrorAlert } from '../generic/saving-error-alert';
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
import { useModel } from '../generic/model-store';
import { LoadingSpinner } from '../generic/Loading';
import SubHeader from '../generic/sub-header/SubHeader';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
@@ -28,12 +27,11 @@ import { useTextbooks } from './hooks';
import { getTextbookFormInitialValues } from './utils';
import messages from './messages';
const Textbooks = ({ courseId }) => {
const Textbooks = () => {
const intl = useIntl();
const { courseId, courseDetails } = useCourseAuthoringContext();
const waffleFlags = useWaffleFlags(courseId);
const courseDetails = useModel('courseDetails', courseId);
const {
textbooks,
isLoading,
@@ -129,7 +127,6 @@ const Textbooks = ({ courseId }) => {
initialFormValues={getTextbookFormInitialValues()}
onSubmit={handleTextbookFormSubmit}
onSavingStatus={handleSavingStatusDispatch}
courseId={courseId}
/>
)}
</div>
@@ -156,8 +153,4 @@ const Textbooks = ({ courseId }) => {
);
};
Textbooks.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default Textbooks;

View File

@@ -66,7 +66,6 @@ const TextbookCard = ({
initialFormValues={getTextbookFormInitialValues(true, { tab_title: tabTitle, chapters, id })}
onSubmit={onEditSubmit}
onSavingStatus={handleSavingStatusDispatch}
courseId={courseId}
/>
) : (
(

View File

@@ -1,16 +1,12 @@
import React from 'react';
import { AppProvider } from '@edx/frontend-platform/react';
import { render, waitFor, within } from '@testing-library/react';
import {
render, waitFor, within, initializeMocks,
} from '@src/testUtils';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { initializeMockApp } from '@edx/frontend-platform';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { getEditTextbooksApiUrl } from '../data/api';
import { deleteTextbookQuery, editTextbookQuery } from '../data/thunk';
import { textbooksMock } from '../__mocks__';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import TextbookCard from './TextbooksCard';
import messages from '../textbook-form/messages';
@@ -26,34 +22,25 @@ const onDeleteSubmitMock = jest.fn();
const handleSavingStatusDispatchMock = jest.fn();
const renderComponent = () => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<TextbookCard
textbook={textbook}
courseId={courseId}
onEditSubmit={onEditSubmitMock}
onDeleteSubmit={onDeleteSubmitMock}
handleSavingStatusDispatch={handleSavingStatusDispatchMock}
/>
</IntlProvider>
</AppProvider>,
<CourseAuthoringProvider courseId={courseId}>
<TextbookCard
textbook={textbook}
courseId={courseId}
onEditSubmit={onEditSubmitMock}
onDeleteSubmit={onDeleteSubmitMock}
handleSavingStatusDispatch={handleSavingStatusDispatchMock}
/>,
</CourseAuthoringProvider>,
);
describe('<TextbookCard />', () => {
let user;
beforeEach(async () => {
user = userEvent.setup();
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
const mocks = initializeMocks();
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
});
it('render TextbookCard component correctly', async () => {

View File

@@ -17,11 +17,11 @@ import {
useToggle,
} from '@openedx/paragon';
import FormikControl from '../../generic/FormikControl';
import PromptIfDirty from '../../generic/prompt-if-dirty/PromptIfDirty';
import ModalDropzone from '../../generic/modal-dropzone/ModalDropzone';
import { useModel } from '../../generic/model-store';
import { UPLOAD_FILE_MAX_SIZE } from '../../constants';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import FormikControl from '@src/generic/FormikControl';
import PromptIfDirty from '@src/generic/prompt-if-dirty/PromptIfDirty';
import ModalDropzone from '@src/generic/modal-dropzone/ModalDropzone';
import { UPLOAD_FILE_MAX_SIZE } from '@src/constants';
import textbookFormValidationSchema from './validations';
import messages from './messages';
@@ -30,11 +30,10 @@ const TextbookForm = ({
initialFormValues,
onSubmit,
onSavingStatus,
courseId,
}) => {
const intl = useIntl();
const courseDetail = useModel('courseDetails', courseId);
const { courseDetail } = useCourseAuthoringContext();
const courseTitle = courseDetail ? courseDetail?.name : '';
const [currentTextbookIndex, setCurrentTextbookIndex] = useState(0);
@@ -195,7 +194,6 @@ TextbookForm.propTypes = {
initialFormValues: PropTypes.shape({}).isRequired,
onSubmit: PropTypes.func.isRequired,
onSavingStatus: PropTypes.func.isRequired,
courseId: PropTypes.string.isRequired,
};
export default TextbookForm;

View File

@@ -1,15 +1,11 @@
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
initializeMocks,
render, waitFor, within,
} from '@testing-library/react';
} from '@src/testUtils';
import userEvent from '@testing-library/user-event';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import { executeThunk } from '@src/utils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { getTextbookFormInitialValues } from '../utils';
import { getUpdateTextbooksApiUrl } from '../data/api';
import { createTextbookQuery } from '../data/thunk';
@@ -26,32 +22,22 @@ const onSubmitMock = jest.fn();
const onSavingStatus = jest.fn();
const renderComponent = () => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<TextbookForm
closeTextbookForm={closeTextbookFormMock}
initialFormValues={initialFormValuesMock}
onSubmit={onSubmitMock}
onSavingStatus={onSavingStatus}
courseId={courseId}
/>
</IntlProvider>
</AppProvider>,
<CourseAuthoringProvider>
<TextbookForm
closeTextbookForm={closeTextbookFormMock}
initialFormValues={initialFormValuesMock}
onSubmit={onSubmitMock}
onSavingStatus={onSavingStatus}
/>
</CourseAuthoringProvider>,
);
describe('<TextbookForm />', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
const mocks = initializeMocks();
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
});
it('renders TextbooksForm component correctly', async () => {