diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.jsx index cbe42ee20c..2f734991c6 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.jsx @@ -7,7 +7,7 @@ import { getConfig } from '@edx/frontend-platform'; import { selectors } from '../../../../../data/redux'; import messages from './messages'; import TinyMceWidget from '../../../../../sharedComponents/TinyMceWidget'; -import { prepareEditorRef, replaceStaticWithAsset } from '../../../../../sharedComponents/TinyMceWidget/hooks'; +import { prepareEditorRef, useProcessedEditorContent } from '../../../../../sharedComponents/TinyMceWidget/hooks'; const ExplanationWidget = ({ // redux @@ -19,12 +19,11 @@ const ExplanationWidget = ({ }) => { const intl = useIntl(); const { editorRef, refReady, setEditorRef } = prepareEditorRef(); - const initialContent = settings?.solutionExplanation || ''; - const newContent = replaceStaticWithAsset({ - initialContent, + + const solutionContent = useProcessedEditorContent({ + initialContent: settings?.solutionExplanation || '', learningContextId, }); - const solutionContent = newContent || initialContent; let staticRootUrl; if (isLibrary) { staticRootUrl = `${getConfig().STUDIO_BASE_URL }/library_assets/blocks/${ blockId }/`; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.jsx index f8020721a1..44b8d877b5 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.jsx @@ -7,7 +7,7 @@ import { getConfig } from '@edx/frontend-platform'; import { selectors } from '../../../../../data/redux'; import messages from './messages'; import TinyMceWidget from '../../../../../sharedComponents/TinyMceWidget'; -import { prepareEditorRef, replaceStaticWithAsset } from '../../../../../sharedComponents/TinyMceWidget/hooks'; +import { prepareEditorRef, useProcessedEditorContent } from '../../../../../sharedComponents/TinyMceWidget/hooks'; const QuestionWidget = ({ // redux @@ -19,12 +19,11 @@ const QuestionWidget = ({ }) => { const intl = useIntl(); const { editorRef, refReady, setEditorRef } = prepareEditorRef(); - const initialContent = question; - const newContent = replaceStaticWithAsset({ - initialContent, + + const questionContent = useProcessedEditorContent({ + initialContent: question, learningContextId, }); - const questionContent = newContent || initialContent; let staticRootUrl; if (isLibrary) { staticRootUrl = `${getConfig().STUDIO_BASE_URL }/library_assets/blocks/${ blockId }/`; diff --git a/src/editors/containers/TextEditor/index.jsx b/src/editors/containers/TextEditor/index.jsx index 03fb498e9f..4f1b5f9083 100644 --- a/src/editors/containers/TextEditor/index.jsx +++ b/src/editors/containers/TextEditor/index.jsx @@ -17,7 +17,7 @@ import RawEditor from '../../sharedComponents/RawEditor'; import * as hooks from './hooks'; import messages from './messages'; import TinyMceWidget from '../../sharedComponents/TinyMceWidget'; -import { prepareEditorRef, replaceStaticWithAsset } from '../../sharedComponents/TinyMceWidget/hooks'; +import { prepareEditorRef, useProcessedEditorContent } from '../../sharedComponents/TinyMceWidget/hooks'; const TextEditor = ({ onClose, @@ -32,15 +32,16 @@ const TextEditor = ({ learningContextId, images, isLibrary, + validateAssetUrl, }) => { const intl = useIntl(); const { editorRef, refReady, setEditorRef } = prepareEditorRef(); - const initialContent = blockValue ? blockValue.data.data : ''; - const newContent = replaceStaticWithAsset({ - initialContent, + + const editorContent = useProcessedEditorContent({ + initialContent: blockValue ? blockValue.data.data : '', learningContextId, + validateAssetUrl, }); - const editorContent = newContent || initialContent; let staticRootUrl; if (isLibrary) { staticRootUrl = `${getConfig().STUDIO_BASE_URL }/library_assets/blocks/${ blockId }/`; @@ -106,6 +107,7 @@ TextEditor.defaultProps = { blockValue: null, blockFinished: null, returnFunction: null, + validateAssetUrl: null, }; TextEditor.propTypes = { onClose: PropTypes.func.isRequired, @@ -122,6 +124,7 @@ TextEditor.propTypes = { learningContextId: PropTypes.string, // This should be required but is NULL when the store is in initial state :/ images: PropTypes.shape({}).isRequired, isLibrary: PropTypes.bool.isRequired, + validateAssetUrl: PropTypes.bool, }; export const mapStateToProps = (state) => ({ diff --git a/src/editors/containers/TextEditor/index.test.tsx b/src/editors/containers/TextEditor/index.test.tsx index 0844ffb932..bbf11b0739 100644 --- a/src/editors/containers/TextEditor/index.test.tsx +++ b/src/editors/containers/TextEditor/index.test.tsx @@ -1,5 +1,7 @@ import React from 'react'; -import { render, screen, initializeMocks } from '@src/testUtils'; +import { + render, screen, initializeMocks, waitFor, +} from '@src/testUtils'; import { actions, selectors } from '../../data/redux'; import { RequestKeys } from '../../data/constants/requests'; import { TextEditorInternal as TextEditor, mapStateToProps, mapDispatchToProps } from '.'; @@ -67,15 +69,20 @@ describe('TextEditor', () => { expect(element?.getAttribute('editorcontenthtml')).toBe('eDiTablE Text'); }); - test('renders static images with relative paths', () => { + test('renders static images with relative paths', async () => { const updatedProps = { ...props, + validateAssetUrl: false, blockValue: { data: { data: 'eDiTablE Text with ' } }, }; const { container } = render(); const element = container.querySelector('tinymcewidget'); expect(element).toBeInTheDocument(); - expect(element?.getAttribute('editorcontenthtml')).toBe('eDiTablE Text with '); + await waitFor(() => { + expect(element?.getAttribute('editorcontenthtml')).toBe( + 'eDiTablE Text with ', + ); + }); }); test('not yet loaded, Spinner appears', () => { const { container } = render(); diff --git a/src/editors/sharedComponents/TinyMceWidget/hooks.test.js b/src/editors/sharedComponents/TinyMceWidget/hooks.test.js index 61af2dfca7..211768d069 100644 --- a/src/editors/sharedComponents/TinyMceWidget/hooks.test.js +++ b/src/editors/sharedComponents/TinyMceWidget/hooks.test.js @@ -1,5 +1,7 @@ import 'CourseAuthoring/editors/setupEditorTest'; import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import * as keyUtils from '../../../generic/key-utils'; import { MockUseState } from '../../testUtils'; import * as tinyMCE from '../../data/constants/tinyMCE'; @@ -19,6 +21,10 @@ jest.mock('react', () => ({ useCallback: (cb, prereqs) => ({ cb, prereqs }), })); +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); + const state = new MockUseState(module); const moduleKeys = keyStore(module); @@ -192,36 +198,119 @@ describe('TinyMceEditor hooks', () => { const initialContent = `test`; const learningContextId = 'course-v1:org+test+run'; const lmsEndpointUrl = getConfig().LMS_BASE_URL; - it('returns updated src for text editor to update content', () => { + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns updated src for text editor to update content', async () => { const expected = `test`; - const actual = module.replaceStaticWithAsset({ initialContent, learningContextId }); + const actual = await module.replaceStaticWithAsset({ + initialContent, + learningContextId, + validateAssetUrl: false, + }); expect(actual).toEqual(expected); }); - it('returns updated src with absolute url for expandable editor to update content', () => { - const editorType = 'expandable'; + it('returns updated src with absolute url for expandable editor to update content', async () => { const expected = `test`; - const actual = module.replaceStaticWithAsset({ + const actual = await module.replaceStaticWithAsset({ initialContent, - editorType, + editorType: 'expandable', lmsEndpointUrl, learningContextId, + validateAssetUrl: false, }); expect(actual).toEqual(expected); }); - it('returns false when there are no srcs to update', () => { + it('returns false when there are no srcs to update', async () => { const content = '
Hello world!
'; - const actual = module.replaceStaticWithAsset({ initialContent: content, learningContextId }); + const actual = await module.replaceStaticWithAsset({ initialContent: content, learningContextId }); expect(actual).toBeFalsy(); }); - it('does not convert static URLs with subdirectories but converts direct static files', () => { + it('does not convert static URLs with subdirectories but converts direct static files', async () => { const contentWithSubdirectory = ''; const expected = ``; - const actual = module.replaceStaticWithAsset({ + const actual = await module.replaceStaticWithAsset({ initialContent: contentWithSubdirectory, learningContextId, + validateAssetUrl: false, }); expect(actual).toEqual(expected); }); + + it('replaces multiple static assets in one content string', async () => { + const content = ` + + + `; + + const result = await module.replaceStaticWithAsset({ + initialContent: content, + learningContextId, + validateAssetUrl: false, + }); + + expect(result).toBeTruthy(); + }); + + it('validateAssetUrl success path replaces url', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn(() => Promise.resolve({})), + }); + + const content = ''; + + const result = await module.replaceStaticWithAsset({ + initialContent: content, + learningContextId, + validateAssetUrl: true, + }); + + expect(result).toBeTruthy(); + }); + + it('validateAssetUrl failure path keeps original content', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn(() => Promise.reject(new Error('404'))), + }); + + const content = ''; + + const result = await module.replaceStaticWithAsset({ + initialContent: content, + learningContextId, + validateAssetUrl: true, + }); + + expect(result).toBeFalsy(); + }); + + it('handles library keys correctly', async () => { + jest.spyOn(keyUtils, 'isLibraryKey').mockReturnValue(true); + + const content = ''; + + const result = await module.replaceStaticWithAsset({ + initialContent: content, + learningContextId: 'lib:test', + validateAssetUrl: false, + }); + + expect(result).toContain('static/test.png'); + }); + + it('returns false when asset already valid and no replacement needed', async () => { + const content = ''; + + const result = await module.replaceStaticWithAsset({ + initialContent: content, + learningContextId, + validateAssetUrl: false, + }); + + expect(result).toBe(false); + }); }); describe('setAssetToStaticUrl', () => { it('returns content with updated img links', () => { diff --git a/src/editors/sharedComponents/TinyMceWidget/hooks.ts b/src/editors/sharedComponents/TinyMceWidget/hooks.ts index 7b906de2d1..83afbe81cf 100644 --- a/src/editors/sharedComponents/TinyMceWidget/hooks.ts +++ b/src/editors/sharedComponents/TinyMceWidget/hooks.ts @@ -5,6 +5,7 @@ import { useEffect, } from 'react'; import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getLocale, isRtl } from '@edx/frontend-platform/i18n'; import { a11ycheckerCss } from 'frontend-components-tinymce-advanced-plugins'; import { isEmpty } from 'lodash'; @@ -104,11 +105,12 @@ export const parseContentForLabels = ({ editor, updateContent }) => { } }; -export const replaceStaticWithAsset = ({ +export const replaceStaticWithAsset = async ({ initialContent, learningContextId, editorType, lmsEndpointUrl, + validateAssetUrl = true, }) => { let content = initialContent; let hasChanges = false; @@ -116,7 +118,7 @@ export const replaceStaticWithAsset = ({ src => src.startsWith('/static') || src.startsWith('/asset'), ); if (!isEmpty(srcs)) { - srcs.forEach(src => { + for (const src of srcs) { const currentContent = content; let staticFullUrl; const isStatic = src.startsWith('/static/'); @@ -154,10 +156,25 @@ export const replaceStaticWithAsset = ({ } if (staticFullUrl) { const currentSrc = src.substring(0, src.indexOf('"')); - content = currentContent.replace(currentSrc, staticFullUrl); - hasChanges = true; + + // check if the asset exists on the server before replacing + try { + if (validateAssetUrl) { + // We intentionally await inside this loop because each replacement + // depends on the progressively updated `content` value. + // Executing the requests in parallel could introduce race conditions + // and produce inconsistent string replacements. + // eslint-disable-next-line no-await-in-loop + await getAuthenticatedHttpClient() + .get(staticFullUrl); + } + content = currentContent.replace(currentSrc, staticFullUrl); + hasChanges = true; + } catch (e) { + content = currentContent; + } } - }); + } if (hasChanges) { return content; } } return false; @@ -341,9 +358,9 @@ export const setupCustomBehavior = ({ onAction: toggleLabelFormatting, }); if (editorType === 'expandable') { - editor.on('init', () => { + editor.on('init', async () => { const initialContent = editor.getContent(); - const newContent = replaceStaticWithAsset({ + const newContent = await replaceStaticWithAsset({ initialContent, editorType, lmsEndpointUrl, @@ -369,10 +386,10 @@ export const setupCustomBehavior = ({ } }); - editor.on('ExecCommand', /* istanbul ignore next */ (e) => { + editor.on('ExecCommand', /* istanbul ignore next */ async (e) => { if (editorType === 'text' && e.command === 'mceFocus') { const initialContent = editor.getContent(); - const newContent = replaceStaticWithAsset({ + const newContent = await replaceStaticWithAsset({ initialContent, editorType, lmsEndpointUrl, @@ -560,3 +577,39 @@ export const selectedImage = (val) => { setSelection, }; }; + +export const useProcessedEditorContent = ({ + initialContent, + learningContextId, + editorType, + validateAssetUrl = false, +}) => { + const [content, setContent] = useState(initialContent); + + useEffect(() => { + let mounted = true; + + const process = async () => { + const newContent = await replaceStaticWithAsset({ + initialContent, + learningContextId, + editorType, + lmsEndpointUrl: getConfig().LMS_BASE_URL, + validateAssetUrl, + }); + + if (mounted) { + setContent(newContent || initialContent); + } + }; + + // eslint-disable-next-line no-void + void process(); + + return () => { + mounted = false; + }; + }, [initialContent, learningContextId, editorType, validateAssetUrl]); + + return content; +}; diff --git a/src/schedule-and-details/data/api.js b/src/schedule-and-details/data/api.ts similarity index 100% rename from src/schedule-and-details/data/api.js rename to src/schedule-and-details/data/api.ts diff --git a/src/schedule-and-details/data/apiHooks.ts b/src/schedule-and-details/data/apiHooks.ts new file mode 100644 index 0000000000..9b65a2f5c0 --- /dev/null +++ b/src/schedule-and-details/data/apiHooks.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import { getCourseDetails } from './api'; + +/** + * Get the details of a course. + */ +export const useGetCourseDetails = (courseId?: string) => { + const { + data, isLoading, isError, refetch, dataUpdatedAt + } = useQuery({ + queryKey: ['courseDetails', courseId], + queryFn: () => getCourseDetails(courseId), + enabled: !!courseId, + refetchOnWindowFocus: false, + }); + return { + ...data, + id: courseId, + isLoading, + isError, + refetch, + dataUpdatedAt, + }; +}; diff --git a/src/schedule-and-details/data/selectors.js b/src/schedule-and-details/data/selectors.js index cff5c69ba3..e388de4eee 100644 --- a/src/schedule-and-details/data/selectors.js +++ b/src/schedule-and-details/data/selectors.js @@ -1,5 +1,3 @@ -export const getLoadingDetailsStatus = (state) => state.scheduleAndDetails.loadingDetailsStatus; export const getLoadingSettingsStatus = (state) => state.scheduleAndDetails.loadingSettingsStatus; export const getSavingStatus = (state) => state.scheduleAndDetails.savingStatus; -export const getCourseDetails = state => state.scheduleAndDetails.courseDetails; export const getCourseSettings = (state) => state.scheduleAndDetails.courseSettings; diff --git a/src/schedule-and-details/data/slice.js b/src/schedule-and-details/data/slice.js index ba65e02a18..a73b3a1d19 100644 --- a/src/schedule-and-details/data/slice.js +++ b/src/schedule-and-details/data/slice.js @@ -6,28 +6,17 @@ import { RequestStatus } from '../../data/constants'; const slice = createSlice({ name: 'scheduleAndDetails', initialState: { - loadingDetailsStatus: RequestStatus.IN_PROGRESS, loadingSettingsStatus: RequestStatus.IN_PROGRESS, savingStatus: '', - courseDetails: {}, courseSettings: {}, }, reducers: { - updateLoadingDetailsStatus: (state, { payload }) => { - state.loadingDetailsStatus = payload.status; - }, updateLoadingSettingsStatus: (state, { payload }) => { state.loadingSettingsStatus = payload.status; }, updateSavingStatus: (state, { payload }) => { state.savingStatus = payload.status; }, - updateCourseDetailsSuccess: (state, { payload }) => { - Object.assign(state.courseDetails, payload); - }, - fetchCourseDetailsSuccess: (state, { payload }) => { - Object.assign(state.courseDetails, payload); - }, fetchCourseSettingsSuccess: (state, { payload }) => { Object.assign(state.courseSettings, payload); }, @@ -36,10 +25,7 @@ const slice = createSlice({ export const { updateSavingStatus, - updateLoadingDetailsStatus, updateLoadingSettingsStatus, - updateCourseDetailsSuccess, - fetchCourseDetailsSuccess, fetchCourseSettingsSuccess, } = slice.actions; diff --git a/src/schedule-and-details/data/thunks.js b/src/schedule-and-details/data/thunks.js index bc2be6dc41..45773b91df 100644 --- a/src/schedule-and-details/data/thunks.js +++ b/src/schedule-and-details/data/thunks.js @@ -1,44 +1,21 @@ import { RequestStatus } from '../../data/constants'; import { - getCourseDetails, updateCourseDetails, getCourseSettings, } from './api'; import { updateSavingStatus, - updateLoadingDetailsStatus, updateLoadingSettingsStatus, - fetchCourseDetailsSuccess, - updateCourseDetailsSuccess, fetchCourseSettingsSuccess, } from './slice'; -export function fetchCourseDetailsQuery(courseId) { - return async (dispatch) => { - dispatch(updateLoadingDetailsStatus({ status: RequestStatus.IN_PROGRESS })); - - try { - const detailsValues = await getCourseDetails(courseId); - dispatch(fetchCourseDetailsSuccess(detailsValues)); - dispatch(updateLoadingDetailsStatus({ status: RequestStatus.SUCCESSFUL })); - } catch (error) { - if (error.response && error.response.status === 403) { - dispatch(updateLoadingDetailsStatus({ courseId, status: RequestStatus.DENIED })); - } else { - dispatch(updateLoadingDetailsStatus({ status: RequestStatus.FAILED })); - } - } - }; -} - export function updateCourseDetailsQuery(courseId, details) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); try { - const detailsValues = await updateCourseDetails(courseId, details); + await updateCourseDetails(courseId, details); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - dispatch(updateCourseDetailsSuccess(detailsValues)); return true; } catch { dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); diff --git a/src/schedule-and-details/hooks.jsx b/src/schedule-and-details/hooks.jsx index 218b92dc1c..25e208a9ef 100644 --- a/src/schedule-and-details/hooks.jsx +++ b/src/schedule-and-details/hooks.jsx @@ -3,30 +3,28 @@ import { useDispatch, useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; import { RequestStatus } from '../data/constants'; -import { getLoadingDetailsStatus, getLoadingSettingsStatus, getSavingStatus } from './data/selectors'; +import { getLoadingSettingsStatus, getSavingStatus } from './data/selectors'; import { validateScheduleAndDetails, updateWithDefaultValues } from './utils'; const useLoadValuesPrompt = ( courseId, - fetchCourseDetailsQuery, fetchCourseSettingsQuery, + courseDetailsError, ) => { const dispatch = useDispatch(); - const loadingDetailsStatus = useSelector(getLoadingDetailsStatus); const loadingSettingsStatus = useSelector(getLoadingSettingsStatus); const [showLoadFailedAlert, setShowLoadFailedAlert] = useState(false); useEffect(() => { - dispatch(fetchCourseDetailsQuery(courseId)); dispatch(fetchCourseSettingsQuery(courseId)); }, [courseId]); useEffect(() => { - if (loadingDetailsStatus === RequestStatus.FAILED || loadingSettingsStatus === RequestStatus.FAILED) { + if (courseDetailsError || loadingSettingsStatus === RequestStatus.FAILED) { setShowLoadFailedAlert(true); window.scrollTo({ top: 0, behavior: 'smooth' }); } - }, [loadingDetailsStatus, loadingSettingsStatus]); + }, [courseDetailsError, loadingSettingsStatus]); return { showLoadFailedAlert, @@ -54,7 +52,7 @@ const useSaveValuesPrompt = ( if (!isQueryPending && !isEditableState) { setEditedValues(initialEditedData); } - }, [initialEditedData]); + }, [initialEditedData.dataUpdatedAt]); useEffect(() => { const errors = validateScheduleAndDetails(editedValues, canShowCertificateAvailableDateField, intl); @@ -115,6 +113,8 @@ const useSaveValuesPrompt = ( if (!isEditableState) { setShowModifiedAlert(false); } + // Refresh course data after successful save + initialEditedData.refetch(); } else if (savingStatus === RequestStatus.FAILED) { setIsQueryPending(false); setShowSuccessfulAlert(false); diff --git a/src/schedule-and-details/index.jsx b/src/schedule-and-details/index.jsx index 9b6aad15e3..5ded3520c6 100644 --- a/src/schedule-and-details/index.jsx +++ b/src/schedule-and-details/index.jsx @@ -9,24 +9,21 @@ import { } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; -import Placeholder from '@src/editors/Placeholder'; -import { RequestStatus } from '@src/data/constants'; -import AlertMessage from '@src/generic/alert-message'; -import InternetConnectionAlert from '@src/generic/internet-connection-alert'; -import { STATEFUL_BUTTON_STATES } from '@src/constants'; -import getPageHeadTitle from '@src/generic/utils'; -import { useScrollToHashElement } from '@src/hooks'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; - +import Placeholder from '../editors/Placeholder'; +import { RequestStatus } from '../data/constants'; +import { useGetCourseDetails } from './data/apiHooks'; +import AlertMessage from '../generic/alert-message'; +import InternetConnectionAlert from '../generic/internet-connection-alert'; +import { STATEFUL_BUTTON_STATES } from '../constants'; +import getPageHeadTitle from '../generic/utils'; +import { useScrollToHashElement } from '../hooks'; import { fetchCourseSettingsQuery, - fetchCourseDetailsQuery, updateCourseDetailsQuery, } from './data/thunks'; import { getCourseSettings, - getCourseDetails, - getLoadingDetailsStatus, getLoadingSettingsStatus, } from './data/selectors'; import BasicSection from './basic-section'; @@ -46,15 +43,14 @@ import { useLoadValuesPrompt, useSaveValuesPrompt } from './hooks'; const ScheduleAndDetails = () => { const intl = useIntl(); const courseSettings = useSelector(getCourseSettings); - const courseDetails = useSelector(getCourseDetails); - const loadingDetailsStatus = useSelector(getLoadingDetailsStatus); const loadingSettingsStatus = useSelector(getLoadingSettingsStatus); - const isLoading = loadingDetailsStatus === RequestStatus.IN_PROGRESS - || loadingSettingsStatus === RequestStatus.IN_PROGRESS; - const { courseId, courseDetails: course } = useCourseAuthoringContext(); document.title = getPageHeadTitle(course?.name || '', intl.formatMessage(messages.headingTitle)); + const courseDetails = useGetCourseDetails(courseId); + const isLoading = courseDetails.isLoading + || loadingSettingsStatus === RequestStatus.IN_PROGRESS; + const { platformName, isCreditCourse, @@ -81,8 +77,8 @@ const ScheduleAndDetails = () => { showLoadFailedAlert, } = useLoadValuesPrompt( courseId, - fetchCourseDetailsQuery, fetchCourseSettingsQuery, + courseDetails.isError, ); const { @@ -149,7 +145,7 @@ const ScheduleAndDetails = () => { return <>; } - if (loadingDetailsStatus === RequestStatus.DENIED || loadingSettingsStatus === RequestStatus.DENIED) { + if (loadingSettingsStatus === RequestStatus.DENIED) { return (