From dad736f9d106149b7a238cbc1e206ac1d1012d47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Fri, 5 Dec 2025 19:14:32 -0500 Subject: [PATCH] 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 `` to use the newly created context. - Migrates some files to Typescript - Migrates some tests to use `src/testUtils.tsx` --- plugins/course-apps/live/BbbSettings.test.jsx | 49 +-- plugins/course-apps/live/Settings.jsx | 3 +- plugins/course-apps/live/Settings.test.jsx | 50 +-- .../course-apps/live/ZoomSettings.test.jsx | 49 +-- plugins/course-apps/proctoring/Settings.jsx | 3 +- .../course-apps/proctoring/Settings.test.jsx | 147 +++---- src/CourseAuthoringContext.tsx | 66 +++ ....test.jsx => CourseAuthoringPage.test.tsx} | 31 +- ...horingPage.jsx => CourseAuthoringPage.tsx} | 35 +- src/CourseAuthoringRoutes.jsx | 153 ------- ...est.jsx => CourseAuthoringRoutes.test.tsx} | 18 +- src/CourseAuthoringRoutes.tsx | 160 +++++++ src/advanced-settings/AdvancedSettings.jsx | 13 +- .../AdvancedSettings.test.jsx | 5 +- src/certificates/Certificates.test.jsx | 7 +- .../{Certificates.jsx => Certificates.tsx} | 9 +- src/course-checklist/CourseChecklist.test.jsx | 34 +- ...ourseChecklist.jsx => CourseChecklist.tsx} | 18 +- src/course-libraries/CourseLibraries.test.tsx | 21 +- src/course-libraries/CourseLibraries.tsx | 12 +- src/course-outline/CourseOutline.test.tsx | 5 +- src/course-outline/CourseOutline.tsx | 8 +- src/course-team/CourseTeam.jsx | 15 +- src/course-team/CourseTeam.test.jsx | 8 +- src/course-team/hooks.jsx | 4 +- src/course-unit/CourseUnit.jsx | 9 +- src/course-unit/CourseUnit.test.jsx | 49 +-- .../SubsectionUnitRedirect.test.tsx | 16 +- src/course-unit/SubsectionUnitRedirect.tsx | 5 +- .../SequenceNavigationTabs.jsx | 5 +- .../sequence-navigation/UnitButton.tsx | 6 +- src/course-unit/data/selectors.js | 1 - src/course-updates/CourseUpdates.test.jsx | 393 ------------------ src/course-updates/CourseUpdates.test.tsx | 345 +++++++++++++++ .../{CourseUpdates.jsx => CourseUpdates.tsx} | 28 +- .../update-form/UpdateForm.test.jsx | 41 +- ...tomPages.test.jsx => CustomPages.test.tsx} | 16 +- .../{CustomPages.jsx => CustomPages.tsx} | 44 +- src/data/api.ts | 46 +- src/data/apiHooks.ts | 46 ++ src/data/slice.ts | 32 -- src/data/thunks.ts | 30 -- src/export-page/CourseExportPage.test.jsx | 23 +- src/export-page/CourseExportPage.tsx | 6 +- src/files-and-videos/files-page/FilesPage.jsx | 33 +- .../files-page/FilesPage.test.jsx | 97 ++--- ...ideosPage.test.jsx => VideosPage.test.tsx} | 103 +++-- .../{VideosPage.jsx => VideosPage.tsx} | 35 +- src/generic/WysiwygEditor.jsx | 5 +- src/grading-settings/GradingSettings.jsx | 14 +- src/grading-settings/GradingSettings.test.jsx | 34 +- .../GroupConfigurations.test.jsx | 7 +- src/group-configurations/index.jsx | 11 +- ...age.test.jsx => CourseImportPage.test.tsx} | 23 +- src/import-page/CourseImportPage.tsx | 17 +- .../CourseOptimizerPage.test.js | 46 +- src/optimizer-page/CourseOptimizerPage.tsx | 8 +- ...es.test.jsx => PagesAndResources.test.tsx} | 19 +- ...AndResources.jsx => PagesAndResources.tsx} | 47 +-- .../discussions/DiscussionsSettings.jsx | 22 +- .../discussions/DiscussionsSettings.test.jsx | 81 ++-- .../discussions/app-list/AppCard.jsx | 5 +- .../discussions/app-list/AppCard.test.jsx | 46 +- .../discussions/app-list/AppList.test.jsx | 42 +- .../discussions/data/redux.test.js | 3 +- .../ScheduleAndDetails.test.jsx | 32 +- src/schedule-and-details/index.jsx | 29 +- .../IntroducingSection.test.jsx | 28 +- src/selectors/VideoSelectorContainer.test.tsx | 7 +- ...ntainer.jsx => VideoSelectorContainer.tsx} | 12 +- src/store.ts | 3 - src/textbooks/Textbook.test.jsx | 7 +- src/textbooks/Textbooks.jsx | 13 +- src/textbooks/textbook-card/TextbooksCard.jsx | 1 - .../textbook-card/TextbooksCard.test.jsx | 45 +- src/textbooks/textbook-form/TextbookForm.jsx | 14 +- .../textbook-form/TextbookForm.test.jsx | 44 +- 77 files changed, 1396 insertions(+), 1601 deletions(-) create mode 100644 src/CourseAuthoringContext.tsx rename src/{CourseAuthoringPage.test.jsx => CourseAuthoringPage.test.tsx} (83%) rename src/{CourseAuthoringPage.jsx => CourseAuthoringPage.tsx} (69%) delete mode 100644 src/CourseAuthoringRoutes.jsx rename src/{CourseAuthoringRoutes.test.jsx => CourseAuthoringRoutes.test.tsx} (91%) create mode 100644 src/CourseAuthoringRoutes.tsx rename src/certificates/{Certificates.jsx => Certificates.tsx} (92%) rename src/course-checklist/{CourseChecklist.jsx => CourseChecklist.tsx} (89%) delete mode 100644 src/course-updates/CourseUpdates.test.jsx create mode 100644 src/course-updates/CourseUpdates.test.tsx rename src/course-updates/{CourseUpdates.jsx => CourseUpdates.tsx} (91%) rename src/custom-pages/{CustomPages.test.jsx => CustomPages.test.tsx} (91%) rename src/custom-pages/{CustomPages.jsx => CustomPages.tsx} (87%) delete mode 100644 src/data/slice.ts delete mode 100644 src/data/thunks.ts rename src/files-and-videos/videos-page/{VideosPage.test.jsx => VideosPage.test.tsx} (93%) rename src/files-and-videos/videos-page/{VideosPage.jsx => VideosPage.tsx} (68%) rename src/import-page/{CourseImportPage.test.jsx => CourseImportPage.test.tsx} (89%) rename src/pages-and-resources/{PagesAndResources.test.jsx => PagesAndResources.test.tsx} (91%) rename src/pages-and-resources/{PagesAndResources.jsx => PagesAndResources.tsx} (78%) rename src/selectors/{VideoSelectorContainer.jsx => VideoSelectorContainer.tsx} (71%) diff --git a/plugins/course-apps/live/BbbSettings.test.jsx b/plugins/course-apps/live/BbbSettings.test.jsx index aaf74286e..133651313 100644 --- a/plugins/course-apps/live/BbbSettings.test.jsx +++ b/plugins/course-apps/live/BbbSettings.test.jsx @@ -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( - - - - - - {}} />} /> - - - - - , + + + {}} /> + + , + { + 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 () => { diff --git a/plugins/course-apps/live/Settings.jsx b/plugins/course-apps/live/Settings.jsx index af46c4070..1e1017086 100644 --- a/plugins/course-apps/live/Settings.jsx +++ b/plugins/course-apps/live/Settings.jsx @@ -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, diff --git a/plugins/course-apps/live/Settings.test.jsx b/plugins/course-apps/live/Settings.test.jsx index 71bfc509f..c4c33dc96 100644 --- a/plugins/course-apps/live/Settings.test.jsx +++ b/plugins/course-apps/live/Settings.test.jsx @@ -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( - - - - - - {}} />} /> - - - - - , + + + {}} /> + + , + { + 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 () => { diff --git a/plugins/course-apps/live/ZoomSettings.test.jsx b/plugins/course-apps/live/ZoomSettings.test.jsx index a0e083613..ef772cbd7 100644 --- a/plugins/course-apps/live/ZoomSettings.test.jsx +++ b/plugins/course-apps/live/ZoomSettings.test.jsx @@ -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( - - - - - - {}} />} /> - - - - - , + + + {}} /> + + , + { + 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 () => { diff --git a/plugins/course-apps/proctoring/Settings.jsx b/plugins/course-apps/proctoring/Settings.jsx index 40a8ac9c4..73da08b47 100644 --- a/plugins/course-apps/proctoring/Settings.jsx +++ b/plugins/course-apps/proctoring/Settings.jsx @@ -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(); diff --git a/plugins/course-apps/proctoring/Settings.test.jsx b/plugins/course-apps/proctoring/Settings.test.jsx index b50e80922..d18cb47a3 100644 --- a/plugins/course-apps/proctoring/Settings.test.jsx +++ b/plugins/course-apps/proctoring/Settings.test.jsx @@ -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 => ( - +const renderComponent = children => ( + - - {children} - + {children} - + ); 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())); + await act(async () => render(renderComponent())); }); 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())); + await act(async () => render(renderComponent())); await waitFor(() => { screen.getByText('Proctored exams'); }); @@ -234,7 +224,7 @@ describe('ProctoredExamSettings', () => { StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), ).reply(200, {}); - await act(async () => render(intlWrapper())); + await act(async () => render(renderComponent())); }); proctoringProvidersRequiringEscalationEmail.forEach(provider => { @@ -420,7 +410,7 @@ describe('ProctoredExamSettings', () => { const isAdmin = false; setupApp(isAdmin); mockCourseData(mockGetPastCourseData); - await act(async () => render(intlWrapper())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); await waitFor(() => { screen.getByDisplayValue('mockproc'); }); @@ -481,7 +471,7 @@ describe('ProctoredExamSettings', () => { available_proctoring_providers: ['lti_external', 'mockproc'], }; mockCourseData(courseData); - await act(async () => render(intlWrapper())); + await act(async () => render(renderComponent())); await waitFor(() => { screen.getByDisplayValue('mockproc'); }); @@ -494,7 +484,7 @@ describe('ProctoredExamSettings', () => { const isAdmin = true; setupApp(isAdmin); mockCourseData(mockGetFutureCourseData); - await act(async () => render(intlWrapper())); + await act(async () => render(renderComponent())); await waitFor(() => { screen.getByDisplayValue('mockproc'); }); @@ -508,12 +498,13 @@ describe('ProctoredExamSettings', () => { EXAMS_BASE_URL: null, }, 'CourseAuthoringConfig'); - await act(async () => render(intlWrapper())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); 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()); + render(renderComponent()); 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())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); // 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())); + await act(async () => render(renderComponent())); // 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())); + await act(async () => render(renderComponent())); // 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())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); 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())); + await act(async () => render(renderComponent())); // Make a change to the proctoring provider const selectElement = screen.getByDisplayValue('mockproc'); fireEvent.change(selectElement, { target: { value: 'software_secure' } }); diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx new file mode 100644 index 000000000..d49774950 --- /dev/null +++ b/src/CourseAuthoringContext.tsx @@ -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(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(() => { + const contextValue = { + courseId, + courseDetails, + courseDetailStatus, + canChangeProviders, + }; + + return contextValue; + }, [ + courseId, + courseDetails, + courseDetailStatus, + canChangeProviders, + ]); + + return ( + + {children} + + ); +}; + +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 ancestor.'); + } + return ctx; +} diff --git a/src/CourseAuthoringPage.test.jsx b/src/CourseAuthoringPage.test.tsx similarity index 83% rename from src/CourseAuthoringPage.test.jsx rename to src/CourseAuthoringPage.test.tsx index 6122056c2..a4ef4fc7e 100644 --- a/src/CourseAuthoringPage.test.jsx +++ b/src/CourseAuthoringPage.test.tsx @@ -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( + + {children} + , +); + 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( - - + const wrapper = renderComponent( + + , ); @@ -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( - - + const wrapper = renderComponent( + + , ); @@ -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(); + const wrapper = renderComponent(); 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( - + const wrapper = renderComponent( +
, @@ -114,7 +117,7 @@ describe('Course authoring page', () => { mockPathname = '/editor/'; await mockStoreDenied(); - const wrapper = render(); + const wrapper = renderComponent(); expect(await wrapper.findByTestId('permissionDeniedAlert')).toBeInTheDocument(); }); }); diff --git a/src/CourseAuthoringPage.jsx b/src/CourseAuthoringPage.tsx similarity index 69% rename from src/CourseAuthoringPage.jsx rename to src/CourseAuthoringPage.tsx index 1a170b8dd..c8668ad90 100644 --- a/src/CourseAuthoringPage.jsx +++ b/src/CourseAuthoringPage.tsx @@ -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; diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx deleted file mode 100644 index deb6f7bb7..000000000 --- a/src/CourseAuthoringRoutes.jsx +++ /dev/null @@ -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 ( - - - } - /> - } - /> - } - /> - } - /> - : null} - /> - } - /> - } - /> - } - /> - } - /> - {DECODED_ROUTES.COURSE_UNIT.map((path) => ( - } - /> - ))} - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - : null} - /> - } - /> - - - ); -}; - -export default CourseAuthoringRoutes; diff --git a/src/CourseAuthoringRoutes.test.jsx b/src/CourseAuthoringRoutes.test.tsx similarity index 91% rename from src/CourseAuthoringRoutes.test.jsx rename to src/CourseAuthoringRoutes.test.tsx index 472862e80..f7aabc829 100644 --- a/src/CourseAuthoringRoutes.test.jsx +++ b/src/CourseAuthoringRoutes.test.tsx @@ -48,7 +48,11 @@ jest.mock('./custom-pages/CustomPages', () => (props) => { describe('', () => { 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('', () => { ); await waitFor(() => { expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible(); - expect(mockComponentFn).toHaveBeenCalledWith( - expect.objectContaining({ - courseId, - }), - ); + expect(mockComponentFn).toHaveBeenCalled(); }); }); @@ -93,11 +93,7 @@ describe('', () => { await waitFor(() => { expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument(); expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument(); - expect(mockComponentFn).toHaveBeenCalledWith( - expect.objectContaining({ - courseId, - }), - ); + expect(mockComponentFn).toHaveBeenCalled(); }); }); }); diff --git a/src/CourseAuthoringRoutes.tsx b/src/CourseAuthoringRoutes.tsx new file mode 100644 index 000000000..8f11ca3a2 --- /dev/null +++ b/src/CourseAuthoringRoutes.tsx @@ -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 ( + + + + } + /> + } + /> + } + /> + } + /> + : null} + /> + } + /> + } + /> + } + /> + } + /> + {DECODED_ROUTES.COURSE_UNIT.map((path) => ( + } + /> + ))} + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + : null} + /> + } + /> + + + + ); +}; + +export default CourseAuthoringRoutes; diff --git a/src/advanced-settings/AdvancedSettings.jsx b/src/advanced-settings/AdvancedSettings.jsx index 74c1a2674..9ac41f479 100644 --- a/src/advanced-settings/AdvancedSettings.jsx +++ b/src/advanced-settings/AdvancedSettings.jsx @@ -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; diff --git a/src/advanced-settings/AdvancedSettings.test.jsx b/src/advanced-settings/AdvancedSettings.test.jsx index cc76c8e8f..c66f41193 100644 --- a/src/advanced-settings/AdvancedSettings.test.jsx +++ b/src/advanced-settings/AdvancedSettings.test.jsx @@ -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( - , + + + , { path: mockPathname }, ); diff --git a/src/certificates/Certificates.test.jsx b/src/certificates/Certificates.test.jsx index 6bcd14fb3..0aebc62d8 100644 --- a/src/certificates/Certificates.test.jsx +++ b/src/certificates/Certificates.test.jsx @@ -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(); +const renderComponent = (props) => render( + + + , +); describe('Certificates', () => { beforeEach(async () => { diff --git a/src/certificates/Certificates.jsx b/src/certificates/Certificates.tsx similarity index 92% rename from src/certificates/Certificates.jsx rename to src/certificates/Certificates.tsx index ef199f099..ae21a64c6 100644 --- a/src/certificates/Certificates.jsx +++ b/src/certificates/Certificates.tsx @@ -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; diff --git a/src/course-checklist/CourseChecklist.test.jsx b/src/course-checklist/CourseChecklist.test.jsx index 69057abf7..e5bf6957b 100644 --- a/src/course-checklist/CourseChecklist.test.jsx +++ b/src/course-checklist/CourseChecklist.test.jsx @@ -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( - - - - - , + + + , ); }; @@ -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', () => { diff --git a/src/course-checklist/CourseChecklist.jsx b/src/course-checklist/CourseChecklist.tsx similarity index 89% rename from src/course-checklist/CourseChecklist.jsx rename to src/course-checklist/CourseChecklist.tsx index 335af5e1c..a8f17d780 100644 --- a/src/course-checklist/CourseChecklist.jsx +++ b/src/course-checklist/CourseChecklist.tsx @@ -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; diff --git a/src/course-libraries/CourseLibraries.test.tsx b/src/course-libraries/CourseLibraries.test.tsx index c4480a9c3..718be4fb0 100644 --- a/src/course-libraries/CourseLibraries.test.tsx +++ b/src/course-libraries/CourseLibraries.test.tsx @@ -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('', () => { const renderCourseLibrariesPage = async (courseKey?: string) => { const courseId = courseKey || mockGetEntityLinks.courseKey; - render(); + render( + + + , + ); }; it('shows the spinner before the query is complete', async () => { @@ -176,7 +181,11 @@ describe('', () => { const renderCourseLibrariesReviewPage = async (courseKey?: string) => { const courseId = courseKey || mockGetEntityLinks.courseKey; - render(); + render( + + + , + ); }; it('shows the spinner before the query is complete', async () => { diff --git a/src/course-libraries/CourseLibraries.tsx b/src/course-libraries/CourseLibraries.tsx index 2d0cfadf0..e939ec5c7 100644 --- a/src/course-libraries/CourseLibraries.tsx +++ b/src/course-libraries/CourseLibraries.tsx @@ -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 = ({ courseId }) => { +export const CourseLibraries = () => { const intl = useIntl(); - const courseDetails = useModel('courseDetails', courseId); + const { courseId, courseDetails } = useCourseAuthoringContext(); const [searchParams] = useSearchParams(); const [tabKey, setTabKey] = useState( () => searchParams.get('tab') as CourseLibraryTabs, @@ -189,7 +185,7 @@ export const CourseLibraries: React.FC = ({ courseId }) => { <> - {getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle))} + {getPageHeadTitle(courseDetails?.name || '', intl.formatMessage(messages.headingTitle))} diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index d5003f9ea..0177900f8 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -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( - , + + + , ); describe('', () => { diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 76d5b0d9b..eeeb46657 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -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, diff --git a/src/course-team/CourseTeam.jsx b/src/course-team/CourseTeam.jsx index 20b594af2..9fd1d04de 100644 --- a/src/course-team/CourseTeam.jsx +++ b/src/course-team/CourseTeam.jsx @@ -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 ( @@ -171,8 +170,4 @@ const CourseTeam = ({ courseId }) => { ); }; -CourseTeam.propTypes = { - courseId: PropTypes.string.isRequired, -}; - export default CourseTeam; diff --git a/src/course-team/CourseTeam.test.jsx b/src/course-team/CourseTeam.test.jsx index 71239a198..bc1ccad1f 100644 --- a/src/course-team/CourseTeam.test.jsx +++ b/src/course-team/CourseTeam.test.jsx @@ -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(, { path: mockPathname }); +const render = () => baseRender( + + + , + { path: mockPathname }, +); describe('', () => { beforeEach(() => { diff --git a/src/course-team/hooks.jsx b/src/course-team/hooks.jsx index f5b5dd5a1..6376d625a 100644 --- a/src/course-team/hooks.jsx +++ b/src/course-team/hooks.jsx @@ -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); diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index ee03c1bd0..ab6855abf 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -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; diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index aa993e34b..f957f7472 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -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 = () => ( - - - - - - - - - + + + + + ); describe('', () => { 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); diff --git a/src/course-unit/SubsectionUnitRedirect.test.tsx b/src/course-unit/SubsectionUnitRedirect.test.tsx index 386bee489..db5fe8a8c 100644 --- a/src/course-unit/SubsectionUnitRedirect.test.tsx +++ b/src/course-unit/SubsectionUnitRedirect.test.tsx @@ -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(, { - path, - routerProps: { - initialEntries: [`/subsection/${subsectionId}`], + render( + + + , + { + path, + routerProps: { + initialEntries: [`/subsection/${subsectionId}`], + }, }, - }); + ); }; jest.mock('react-router-dom', () => { diff --git a/src/course-unit/SubsectionUnitRedirect.tsx b/src/course-unit/SubsectionUnitRedirect.tsx index 403656cde..90549887f 100644 --- a/src/course-unit/SubsectionUnitRedirect.tsx +++ b/src/course-unit/SubsectionUnitRedirect.tsx @@ -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 diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx index 0307c2f55..12fad29b9 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx @@ -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; diff --git a/src/course-unit/course-sequence/sequence-navigation/UnitButton.tsx b/src/course-unit/course-sequence/sequence-navigation/UnitButton.tsx index 76aa728ed..865eb41aa 100644 --- a/src/course-unit/course-sequence/sequence-navigation/UnitButton.tsx +++ b/src/course-unit/course-sequence/sequence-navigation/UnitButton.tsx @@ -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 = ({ 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]); diff --git a/src/course-unit/data/selectors.js b/src/course-unit/data/selectors.js index 163232d9d..00861c60c 100644 --- a/src/course-unit/data/selectors.js +++ b/src/course-unit/data/selectors.js @@ -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; diff --git a/src/course-updates/CourseUpdates.test.jsx b/src/course-updates/CourseUpdates.test.jsx deleted file mode 100644 index ab8e1a802..000000000 --- a/src/course-updates/CourseUpdates.test.jsx +++ /dev/null @@ -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: () =>
Widget
, - prepareEditorRef: jest.fn(() => ({ - refReady: true, - setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'), - })), -})); - -const RootWrapper = () => ( - - - - - -); - -describe('', () => { - 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(); - - 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(); - - const data = { - content: '

Some text

', - 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(); - - const data = { - id: courseUpdatesMock[0].id, - content: '

Some text

', - 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(); - - 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(); - - const data = { - ...courseHandoutsMock, - data: '

Some handouts 1

', - }; - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - const data = { - content: '

Some text

', - 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(); - - const data = { - id: courseUpdatesMock[0].id, - content: '

Some text

', - 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(); - - 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(); - - const data = { - ...courseHandoutsMock, - data: '

Some handouts 1

', - }; - - 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)); - }); - }); -}); diff --git a/src/course-updates/CourseUpdates.test.tsx b/src/course-updates/CourseUpdates.test.tsx new file mode 100644 index 000000000..0a84e3611 --- /dev/null +++ b/src/course-updates/CourseUpdates.test.tsx @@ -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: () =>
Widget
, + prepareEditorRef: jest.fn(() => ({ + refReady: true, + setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'), + })), +})); + +const RootWrapper = () => ( + + + +); + +describe('', () => { + 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(); + 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: '

Some text

', + date: 'August 29, 2023', + }; + + axiosMock + .onPost(getCourseUpdatesApiUrl(courseId)) + .reply(200, data); + + render(); + + 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: '

Some text

', + date: 'August 29, 2023', + }; + + axiosMock + .onPut(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id)) + .reply(200, data); + + render(); + + 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(); + + 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: '

Some handouts 1

', + }; + + axiosMock + .onPut(getCourseHandoutApiUrl(courseId)) + .reply(200, data); + + render(); + + 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(); + 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(); + 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(); + 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(); + + 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(); + + 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(); + + 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(); + + const data = { + content: '

Some text

', + 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(); + + const data = { + id: courseUpdatesMock[0].id, + content: '

Some text

', + 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(); + + 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(); + + const data = { + ...courseHandoutsMock, + data: '

Some handouts 1

', + }; + + 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)); + }); + }); +}); diff --git a/src/course-updates/CourseUpdates.jsx b/src/course-updates/CourseUpdates.tsx similarity index 91% rename from src/course-updates/CourseUpdates.jsx rename to src/course-updates/CourseUpdates.tsx index 0d82f3bf8..10745fdd9 100644 --- a/src/course-updates/CourseUpdates.jsx +++ b/src/course-updates/CourseUpdates.tsx @@ -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 }) => { <> - {getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle))} + {getPageHeadTitle(courseDetails?.name || '', intl.formatMessage(messages.headingTitle))} @@ -163,7 +161,6 @@ const CourseUpdates = ({ courseId }) => {
{isMainFormOpen && ( { {courseUpdates.map((courseUpdate, index) => ( isInnerFormOpen(courseUpdate.id) ? ( { ); }; -CourseUpdates.propTypes = { - courseId: PropTypes.string.isRequired, -}; - export default CourseUpdates; diff --git a/src/course-updates/update-form/UpdateForm.test.jsx b/src/course-updates/update-form/UpdateForm.test.jsx index b48e07374..67b4624e0 100644 --- a/src/course-updates/update-form/UpdateForm.test.jsx +++ b/src/course-updates/update-form/UpdateForm.test.jsx @@ -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( - - - - - , + + , + , ); describe('', () => { 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 }); diff --git a/src/custom-pages/CustomPages.test.jsx b/src/custom-pages/CustomPages.test.tsx similarity index 91% rename from src/custom-pages/CustomPages.test.jsx rename to src/custom-pages/CustomPages.test.tsx index 02d179549..fec0ebc22 100644 --- a/src/custom-pages/CustomPages.test.jsx +++ b/src/custom-pages/CustomPages.test.tsx @@ -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(); + render( + + + , + ); }; const mockStore = async (status) => { diff --git a/src/custom-pages/CustomPages.jsx b/src/custom-pages/CustomPages.tsx similarity index 87% rename from src/custom-pages/CustomPages.jsx rename to src/custom-pages/CustomPages.tsx index 40f90cd0d..0f0fee187 100644 --- a/src/custom-pages/CustomPages.jsx +++ b/src/custom-pages/CustomPages.tsx @@ -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(); 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 = ({
- {orderedPages.map((page) => ( + {orderedPages.map((page: any) => ( @@ -275,8 +275,4 @@ const CustomPages = ({ ); }; -CustomPages.propTypes = { - courseId: PropTypes.string.isRequired, -}; - export default CustomPages; diff --git a/src/data/api.ts b/src/data/api.ts index 7f2502a92..e8c6f2e65 100644 --- a/src/data/api.ts +++ b/src/data/api.ts @@ -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 + >; + 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 { + 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 { const { data } = await getAuthenticatedHttpClient() .get(getApiWaffleFlagsUrl(courseId)); - return normalizeCourseDetail(data); + return { + id: data.course_id, + ...camelCaseObject(data), + }; } export interface MigrateParameters { diff --git a/src/data/apiHooks.ts b/src/data/apiHooks.ts index 51fc1c248..8ba2e60e9 100644 --- a/src/data/apiHooks.ts +++ b/src/data/apiHooks.ts @@ -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, + }; +}; diff --git a/src/data/slice.ts b/src/data/slice.ts deleted file mode 100644 index 539c6bdd7..000000000 --- a/src/data/slice.ts +++ /dev/null @@ -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; diff --git a/src/data/thunks.ts b/src/data/thunks.ts deleted file mode 100644 index bbfc86340..000000000 --- a/src/data/thunks.ts +++ /dev/null @@ -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 })); - } - } - }; -} diff --git a/src/export-page/CourseExportPage.test.jsx b/src/export-page/CourseExportPage.test.jsx index 466a5434f..2abb1700f 100644 --- a/src/export-page/CourseExportPage.test.jsx +++ b/src/export-page/CourseExportPage.test.jsx @@ -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(); +const renderComponent = () => render( + + + , +); describe('', () => { 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); }); diff --git a/src/export-page/CourseExportPage.tsx b/src/export-page/CourseExportPage.tsx index 870f6d151..acc23ac04 100644 --- a/src/export-page/CourseExportPage.tsx +++ b/src/export-page/CourseExportPage.tsx @@ -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); diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx index 71476ce0b..41af98f34 100644 --- a/src/files-and-videos/files-page/FilesPage.jsx +++ b/src/files-and-videos/files-page/FilesPage.jsx @@ -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; diff --git a/src/files-and-videos/files-page/FilesPage.test.jsx b/src/files-and-videos/files-page/FilesPage.test.jsx index 7d4c24fc2..7dbe06474 100644 --- a/src/files-and-videos/files-page/FilesPage.test.jsx +++ b/src/files-and-videos/files-page/FilesPage.test.jsx @@ -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( - - - - - - } - /> - - - - , + + , + , + { + 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(); }); diff --git a/src/files-and-videos/videos-page/VideosPage.test.jsx b/src/files-and-videos/videos-page/VideosPage.test.tsx similarity index 93% rename from src/files-and-videos/videos-page/VideosPage.test.jsx rename to src/files-and-videos/videos-page/VideosPage.test.tsx index d3bfc19d9..761cd3d5b 100644 --- a/src/files-and-videos/videos-page/VideosPage.test.jsx +++ b/src/files-and-videos/videos-page/VideosPage.test.tsx @@ -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( - - - - - - } - /> - - - - , + + + , + { + 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; diff --git a/src/files-and-videos/videos-page/VideosPage.jsx b/src/files-and-videos/videos-page/VideosPage.tsx similarity index 68% rename from src/files-and-videos/videos-page/VideosPage.jsx rename to src/files-and-videos/videos-page/VideosPage.tsx index 1b164afdf..53d32322c 100644 --- a/src/files-and-videos/videos-page/VideosPage.jsx +++ b/src/files-and-videos/videos-page/VideosPage.tsx @@ -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 ( - {getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading))} + {getPageHeadTitle(courseDetails?.name || '', intl.formatMessage(messages.heading))} { 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, ''); diff --git a/src/grading-settings/GradingSettings.jsx b/src/grading-settings/GradingSettings.jsx index 5ac8a345c..56c0da662 100644 --- a/src/grading-settings/GradingSettings.jsx +++ b/src/grading-settings/GradingSettings.jsx @@ -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; diff --git a/src/grading-settings/GradingSettings.test.jsx b/src/grading-settings/GradingSettings.test.jsx index ecb5efcba..c524845ac 100644 --- a/src/grading-settings/GradingSettings.test.jsx +++ b/src/grading-settings/GradingSettings.test.jsx @@ -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 = () => ( - - - - - - - + + + ); describe('', () => { 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); diff --git a/src/group-configurations/GroupConfigurations.test.jsx b/src/group-configurations/GroupConfigurations.test.jsx index b35784ae8..a6efd4b13 100644 --- a/src/group-configurations/GroupConfigurations.test.jsx +++ b/src/group-configurations/GroupConfigurations.test.jsx @@ -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(); +const renderComponent = () => render( + + + , +); describe('', () => { beforeEach(async () => { diff --git a/src/group-configurations/index.jsx b/src/group-configurations/index.jsx index 1f150c828..b1e3a859e 100644 --- a/src/group-configurations/index.jsx +++ b/src/group-configurations/index.jsx @@ -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; diff --git a/src/import-page/CourseImportPage.test.jsx b/src/import-page/CourseImportPage.test.tsx similarity index 89% rename from src/import-page/CourseImportPage.test.jsx rename to src/import-page/CourseImportPage.test.tsx index 14727728b..dab7c0bf0 100644 --- a/src/import-page/CourseImportPage.test.jsx +++ b/src/import-page/CourseImportPage.test.tsx @@ -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(); +const renderComponent = () => render( + + + , +); describe('', () => { 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); }); diff --git a/src/import-page/CourseImportPage.tsx b/src/import-page/CourseImportPage.tsx index 388ea8060..1c8f2adb0 100644 --- a/src/import-page/CourseImportPage.tsx +++ b/src/import-page/CourseImportPage.tsx @@ -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); diff --git a/src/optimizer-page/CourseOptimizerPage.test.js b/src/optimizer-page/CourseOptimizerPage.test.js index ed76b0aa2..80a95d2cb 100644 --- a/src/optimizer-page/CourseOptimizerPage.test.js +++ b/src/optimizer-page/CourseOptimizerPage.test.js @@ -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 = () => ( - - - - - + + + ); 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' }); diff --git a/src/optimizer-page/CourseOptimizerPage.tsx b/src/optimizer-page/CourseOptimizerPage.tsx index 29ad6bc62..19f33fcf3 100644 --- a/src/optimizer-page/CourseOptimizerPage.tsx +++ b/src/optimizer-page/CourseOptimizerPage.tsx @@ -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(undefined); const rerunUpdateInterval = useRef(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(null); diff --git a/src/pages-and-resources/PagesAndResources.test.jsx b/src/pages-and-resources/PagesAndResources.test.tsx similarity index 91% rename from src/pages-and-resources/PagesAndResources.test.jsx rename to src/pages-and-resources/PagesAndResources.test.tsx index 9888586cf..2b6ce340c 100644 --- a/src/pages-and-resources/PagesAndResources.test.jsx +++ b/src/pages-and-resources/PagesAndResources.test.tsx @@ -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( + + + , +); + describe('PagesAndResources', () => { beforeEach(() => { jest.clearAllMocks(); @@ -45,7 +52,7 @@ describe('PagesAndResources', () => { }; initializeMocks({ initialState }); - render(); + 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(); + 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(); + renderComponent(); await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Xpert unit summaries')).toBeInTheDocument()); diff --git a/src/pages-and-resources/PagesAndResources.jsx b/src/pages-and-resources/PagesAndResources.tsx similarity index 78% rename from src/pages-and-resources/PagesAndResources.jsx rename to src/pages-and-resources/PagesAndResources.tsx index 56ba8a63a..a89142b1a 100644 --- a/src/pages-and-resources/PagesAndResources.jsx +++ b/src/pages-and-resources/PagesAndResources.tsx @@ -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 }) => { - } /> - } /> - } /> + } /> + } /> + } /> } /> @@ -109,8 +110,4 @@ const PagesAndResources = ({ courseId }) => { ); }; -PagesAndResources.propTypes = { - courseId: PropTypes.string.isRequired, -}; - export default PagesAndResources; diff --git a/src/pages-and-resources/discussions/DiscussionsSettings.jsx b/src/pages-and-resources/discussions/DiscussionsSettings.jsx index 06896167f..3b9ec906a 100644 --- a/src/pages-and-resources/discussions/DiscussionsSettings.jsx +++ b/src/pages-and-resources/discussions/DiscussionsSettings.jsx @@ -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 ; } @@ -147,8 +145,4 @@ const DiscussionsSettings = ({ courseId }) => { ); }; -DiscussionsSettings.propTypes = { - courseId: PropTypes.string.isRequired, -}; - export default DiscussionsSettings; diff --git a/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx b/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx index cbce43a0e..e49189564 100644 --- a/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx +++ b/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx @@ -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( - - - - } - /> - } - /> - - - - + + + + + + } + /> + } + /> + + + + + + , ); 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' diff --git a/src/pages-and-resources/discussions/app-list/AppCard.jsx b/src/pages-and-resources/discussions/app-list/AppCard.jsx index b9215a4dc..31cc9a0cc 100644 --- a/src/pages-and-resources/discussions/app-list/AppCard.jsx +++ b/src/pages-and-resources/discussions/app-list/AppCard.jsx @@ -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); diff --git a/src/pages-and-resources/discussions/app-list/AppCard.test.jsx b/src/pages-and-resources/discussions/app-list/AppCard.test.jsx index 74e9590db..1abf950bb 100644 --- a/src/pages-and-resources/discussions/app-list/AppCard.test.jsx +++ b/src/pages-and-resources/discussions/app-list/AppCard.test.jsx @@ -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( - - - jest.fn()} - selected={selected} - features={[]} - /> - - , + + jest.fn()} + selected={selected} + features={[]} + /> + , ); container = wrapper.container; return container; diff --git a/src/pages-and-resources/discussions/app-list/AppList.test.jsx b/src/pages-and-resources/discussions/app-list/AppList.test.jsx index f6e85f080..1fa13dd35 100644 --- a/src/pages-and-resources/discussions/app-list/AppList.test.jsx +++ b/src/pages-and-resources/discussions/app-list/AppList.test.jsx @@ -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( - + - - - + - , +
, ); 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'); }); diff --git a/src/pages-and-resources/discussions/data/redux.test.js b/src/pages-and-resources/discussions/data/redux.test.js index 283bc84f8..4c4753101 100644 --- a/src/pages-and-resources/discussions/data/redux.test.js +++ b/src/pages-and-resources/discussions/data/redux.test.js @@ -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'; diff --git a/src/schedule-and-details/ScheduleAndDetails.test.jsx b/src/schedule-and-details/ScheduleAndDetails.test.jsx index 76fd6de7a..77c0192e9 100644 --- a/src/schedule-and-details/ScheduleAndDetails.test.jsx +++ b/src/schedule-and-details/ScheduleAndDetails.test.jsx @@ -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) => (