diff --git a/catalog-info.yaml b/catalog-info.yaml index 9eec06ff6c..9fa7a240ea 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -12,6 +12,7 @@ metadata: icon: "Web" annotations: openedx.org/arch-interest-groups: "" + openedx.org/release: "master" spec: owner: group:2u-tnl type: 'website' diff --git a/openedx.yaml b/openedx.yaml deleted file mode 100644 index 4e57270214..0000000000 --- a/openedx.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# This file describes this Open edX repo, as described in OEP-2: -# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification - -nick: cath -oeps: {} -owner: edx/platform-core-tnl -openedx-release: - # The openedx-release key is described in OEP-10: - # https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html - # The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ - ref: master diff --git a/package-lock.json b/package-lock.json index 6689a22cee..24bd8a4ac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6644,9 +6644,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001677", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001677.tgz", - "integrity": "sha512-fmfjsOlJUpMWu+mAAtZZZHz7UEwsUxIIvu1TJfO1HqFQvB/B+ii0xr9B5HpbZY/mC4XZ8SvjHJqtAY6pDPQEog==", + "version": "1.0.30001690", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz", + "integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==", "funding": [ { "type": "opencollective", diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index ded2f07eae..4bef342689 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -20,6 +20,7 @@ import { CourseUpdates } from './course-updates'; import { CourseUnit, IframeProvider } 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'; @@ -118,6 +119,10 @@ const CourseAuthoringRoutes = () => { path="export" element={} /> + } + /> } diff --git a/src/constants.js b/src/constants.js index 163a16ef84..80e7cdd778 100644 --- a/src/constants.js +++ b/src/constants.js @@ -58,6 +58,7 @@ export const COURSE_BLOCK_NAMES = ({ chapter: { id: 'chapter', name: 'Section' }, sequential: { id: 'sequential', name: 'Subsection' }, vertical: { id: 'vertical', name: 'Unit' }, + libraryContent: { id: 'library_content', name: 'Library content' }, component: { id: 'component', name: 'Component' }, }); @@ -76,3 +77,7 @@ export const REGEX_RULES = { specialCharsRule: /^[a-zA-Z0-9_\-.'*~\s]+$/, noSpaceRule: /^\S*$/, }; + +export const IFRAME_FEATURE_POLICY = ( + 'microphone *; camera *; midi *; geolocation *; encrypted-media *; clipboard-write *' +); diff --git a/src/content-tags-drawer/ContentTagsDrawer.test.jsx b/src/content-tags-drawer/ContentTagsDrawer.test.jsx index 426f0d5323..0ff99eaf27 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.test.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.test.jsx @@ -18,7 +18,7 @@ import { } from './data/api.mocks'; import { getContentTaxonomyTagsApiUrl } from './data/api'; -const path = '/content/:contentId/*'; +const path = '/content/:contentId?/*'; const mockOnClose = jest.fn(); const mockSetBlockingSheet = jest.fn(); const mockNavigate = jest.fn(); diff --git a/src/course-outline/constants.js b/src/course-outline/constants.js index 4430694a78..eda0522a03 100644 --- a/src/course-outline/constants.js +++ b/src/course-outline/constants.js @@ -87,4 +87,5 @@ export const API_ERROR_TYPES = /** @type {const} */ ({ networkError: 'networkError', serverError: 'serverError', unknown: 'unknown', + forbidden: 'forbidden', }); diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.js index 0bd146f95d..0275555262 100644 --- a/src/course-outline/data/slice.js +++ b/src/course-outline/data/slice.js @@ -104,7 +104,7 @@ const slice = createSlice({ ...payload, }; }, - fetchStatusBarSelPacedSuccess: (state, { payload }) => { + fetchStatusBarSelfPacedSuccess: (state, { payload }) => { state.statusBarData.isSelfPaced = payload.isSelfPaced; }, updateSavingStatus: (state, { payload }) => { @@ -206,7 +206,7 @@ export const { updateStatusBar, updateCourseActions, fetchStatusBarChecklistSuccess, - fetchStatusBarSelPacedSuccess, + fetchStatusBarSelfPacedSuccess, updateFetchSectionLoadingStatus, updateCourseLaunchQueryStatus, updateSavingStatus, diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index c555fcb978..457d039114 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -1,7 +1,7 @@ import { RequestStatus } from '../../data/constants'; import { updateClipboardData } from '../../generic/data/slice'; import { NOTIFICATION_MESSAGES } from '../../constants'; -import { API_ERROR_TYPES, COURSE_BLOCK_NAMES } from '../constants'; +import { COURSE_BLOCK_NAMES } from '../constants'; import { hideProcessingNotification, showProcessingNotification, @@ -10,6 +10,7 @@ import { getCourseBestPracticesChecklist, getCourseLaunchChecklist, } from '../utils/getChecklistForStatusBar'; +import { getErrorDetails } from '../utils/getErrorDetails'; import { addNewCourseItem, deleteCourseItem, @@ -41,7 +42,7 @@ import { updateStatusBar, updateCourseActions, fetchStatusBarChecklistSuccess, - fetchStatusBarSelPacedSuccess, + fetchStatusBarSelfPacedSuccess, updateSavingStatus, updateSectionList, updateFetchSectionLoadingStatus, @@ -54,24 +55,6 @@ import { updateCourseLaunchQueryStatus, } from './slice'; -const getErrorDetails = (error, dismissible = true) => { - const errorInfo = { dismissible }; - if (error.response?.data) { - const { data } = error.response; - if ((typeof data === 'string' && !data.includes('')) || typeof data === 'object') { - errorInfo.data = JSON.stringify(data); - } - errorInfo.status = error.response.status; - errorInfo.type = API_ERROR_TYPES.serverError; - } else if (error.request) { - errorInfo.type = API_ERROR_TYPES.networkError; - } else { - errorInfo.type = API_ERROR_TYPES.unknown; - errorInfo.data = error.message; - } - return errorInfo; -}; - export function fetchCourseOutlineIndexQuery(courseId) { return async (dispatch) => { dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS })); @@ -125,7 +108,7 @@ export function fetchCourseLaunchQuery({ const data = await getCourseLaunch({ courseId, gradedOnly, validateOras, all, }); - dispatch(fetchStatusBarSelPacedSuccess({ isSelfPaced: data.isSelfPaced })); + dispatch(fetchStatusBarSelfPacedSuccess({ isSelfPaced: data.isSelfPaced })); dispatch(fetchStatusBarChecklistSuccess(getCourseLaunchChecklist(data))); dispatch(updateCourseLaunchQueryStatus({ status: RequestStatus.SUCCESSFUL })); diff --git a/src/course-outline/page-alerts/PageAlerts.jsx b/src/course-outline/page-alerts/PageAlerts.jsx index 4b12996395..9d6eefb211 100644 --- a/src/course-outline/page-alerts/PageAlerts.jsx +++ b/src/course-outline/page-alerts/PageAlerts.jsx @@ -343,13 +343,38 @@ const PageAlerts = ({ const renderApiErrors = () => { let errorList = Object.entries(errors).filter(obj => obj[1] !== null).map(([k, v]) => { switch (v.type) { - case API_ERROR_TYPES.serverError: + case API_ERROR_TYPES.forbidden: { + const description = intl.formatMessage(messages.forbiddenAlertBody, { + LMS: ( + + {intl.formatMessage(messages.forbiddenAlertLmsUrl)} + + ), + }); return { key: k, - desc: v.data || intl.formatMessage(messages.serverErrorAlertBody), + desc: description, + title: intl.formatMessage(messages.forbiddenAlert), + dismissible: v.dismissible, + }; + } + case API_ERROR_TYPES.serverError: { + const description = ( + + {v.data || intl.formatMessage(messages.serverErrorAlertBody)} + + ); + return { + key: k, + desc: description, title: intl.formatMessage(messages.serverErrorAlert), dismissible: v.dismissible, }; + } case API_ERROR_TYPES.networkError: return { key: k, @@ -378,7 +403,7 @@ const PageAlerts = ({ dismissError={() => dispatch(dismissError(msgObj.key))} > {msgObj.title} - {msgObj.desc && {msgObj.desc}} + {msgObj.desc} ) : ( {msgObj.title} - {msgObj.desc && {msgObj.desc}} + {msgObj.desc} ) )) diff --git a/src/course-outline/page-alerts/PageAlerts.test.jsx b/src/course-outline/page-alerts/PageAlerts.test.jsx index 6d646f7cb4..487a7e7ac7 100644 --- a/src/course-outline/page-alerts/PageAlerts.test.jsx +++ b/src/course-outline/page-alerts/PageAlerts.test.jsx @@ -71,19 +71,19 @@ describe('', () => { }); it('renders null when no alerts are present', () => { - const { queryByTestId } = renderComponent(); - expect(queryByTestId('browser-router')).toBeEmptyDOMElement(); + renderComponent(); + expect(screen.queryByTestId('browser-router')).toBeEmptyDOMElement(); }); it('renders configuration alerts', async () => { - const { queryByText } = renderComponent({ + renderComponent({ ...pageAlertsData, notificationDismissUrl: 'some-url', handleDismissNotification, }); - expect(queryByText(messages.configurationErrorTitle.defaultMessage)).toBeInTheDocument(); - const dismissBtn = queryByText('Dismiss'); + expect(screen.queryByText(messages.configurationErrorTitle.defaultMessage)).toBeInTheDocument(); + const dismissBtn = screen.queryByText('Dismiss'); await act(async () => fireEvent.click(dismissBtn)); expect(handleDismissNotification).toBeCalled(); @@ -117,7 +117,7 @@ describe('', () => { }); it('renders deprecation warning alerts', async () => { - const { queryByText } = renderComponent({ + renderComponent({ ...pageAlertsData, deprecatedBlocksInfo: { blocks: [['url1', 'block1'], ['url2']], @@ -126,20 +126,20 @@ describe('', () => { }, }); - expect(queryByText(messages.deprecationWarningTitle.defaultMessage)).toBeInTheDocument(); - expect(queryByText(messages.deprecationWarningBlocksText.defaultMessage)).toBeInTheDocument(); - expect(queryByText('block1')).toHaveAttribute('href', 'url1'); - expect(queryByText(messages.deprecatedComponentName.defaultMessage)).toHaveAttribute('href', 'url2'); + expect(screen.queryByText(messages.deprecationWarningTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByText(messages.deprecationWarningBlocksText.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByText('block1')).toHaveAttribute('href', 'url1'); + expect(screen.queryByText(messages.deprecatedComponentName.defaultMessage)).toHaveAttribute('href', 'url2'); - const feedbackLink = queryByText(messages.advancedSettingLinkText.defaultMessage); + const feedbackLink = screen.queryByText(messages.advancedSettingLinkText.defaultMessage); expect(feedbackLink).toBeInTheDocument(); expect(feedbackLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}/some-url`); - expect(queryByText('lti')).toBeInTheDocument(); - expect(queryByText('video')).toBeInTheDocument(); + expect(screen.queryByText('lti')).toBeInTheDocument(); + expect(screen.queryByText('video')).toBeInTheDocument(); }); it('renders proctoring alerts with mfe settings link', async () => { - const { queryByText } = renderComponent({ + renderComponent({ ...pageAlertsData, mfeProctoredExamSettingsUrl: 'mfe-url', proctoringErrors: [ @@ -148,15 +148,15 @@ describe('', () => { ], }); - expect(queryByText('error 1')).toBeInTheDocument(); - expect(queryByText('error 2')).toBeInTheDocument(); - expect(queryByText('message 1')).toBeInTheDocument(); - expect(queryByText('message 2')).toBeInTheDocument(); - expect(queryByText(messages.proctoredSettingsLinkText.defaultMessage)).toHaveAttribute('href', 'mfe-url'); + expect(screen.queryByText('error 1')).toBeInTheDocument(); + expect(screen.queryByText('error 2')).toBeInTheDocument(); + expect(screen.queryByText('message 1')).toBeInTheDocument(); + expect(screen.queryByText('message 2')).toBeInTheDocument(); + expect(screen.queryByText(messages.proctoredSettingsLinkText.defaultMessage)).toHaveAttribute('href', 'mfe-url'); }); it('renders proctoring alerts without mfe settings link', async () => { - const { queryByText } = renderComponent({ + renderComponent({ ...pageAlertsData, advanceSettingsUrl: '/some-url', proctoringErrors: [ @@ -165,11 +165,11 @@ describe('', () => { ], }); - expect(queryByText('error 1')).toBeInTheDocument(); - expect(queryByText('error 2')).toBeInTheDocument(); - expect(queryByText('message 1')).toBeInTheDocument(); - expect(queryByText('message 2')).toBeInTheDocument(); - expect(queryByText(messages.advancedSettingLinkText.defaultMessage)).toHaveAttribute( + expect(screen.queryByText('error 1')).toBeInTheDocument(); + expect(screen.queryByText('error 2')).toBeInTheDocument(); + expect(screen.queryByText('message 1')).toBeInTheDocument(); + expect(screen.queryByText('message 2')).toBeInTheDocument(); + expect(screen.queryByText(messages.advancedSettingLinkText.defaultMessage)).toHaveAttribute( 'href', `${getConfig().STUDIO_BASE_URL}/some-url`, ); @@ -181,10 +181,10 @@ describe('', () => { conflictingFiles: [], errorFiles: ['error.css'], }); - const { queryByText } = renderComponent(); - expect(queryByText(messages.newFileAlertTitle.defaultMessage)).toBeInTheDocument(); - expect(queryByText(messages.errorFileAlertTitle.defaultMessage)).toBeInTheDocument(); - expect(queryByText(messages.newFileAlertAction.defaultMessage)).toHaveAttribute( + renderComponent(); + expect(screen.queryByText(messages.newFileAlertTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByText(messages.errorFileAlertTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByText(messages.newFileAlertAction.defaultMessage)).toHaveAttribute( 'href', `${getConfig().STUDIO_BASE_URL}/assets/course-id`, ); @@ -196,16 +196,16 @@ describe('', () => { conflictingFiles: ['some.css', 'some.js'], errorFiles: [], }); - const { queryByText } = renderComponent(); - expect(queryByText(messages.conflictingFileAlertTitle.defaultMessage)).toBeInTheDocument(); - expect(queryByText(messages.newFileAlertAction.defaultMessage)).toHaveAttribute( + renderComponent(); + expect(screen.queryByText(messages.conflictingFileAlertTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByText(messages.newFileAlertAction.defaultMessage)).toHaveAttribute( 'href', `${getConfig().STUDIO_BASE_URL}/assets/course-id`, ); }); it('renders api error alerts', async () => { - const { queryByText } = renderComponent({ + renderComponent({ ...pageAlertsData, errors: { outlineIndexApi: { data: 'some error', status: 400, type: API_ERROR_TYPES.serverError }, @@ -213,9 +213,34 @@ describe('', () => { reindexApi: { type: API_ERROR_TYPES.unknown, data: 'some unknown error' }, }, }); - expect(queryByText(messages.networkErrorAlert.defaultMessage)).toBeInTheDocument(); - expect(queryByText(messages.serverErrorAlert.defaultMessage)).toBeInTheDocument(); - expect(queryByText('some error')).toBeInTheDocument(); - expect(queryByText('some unknown error')).toBeInTheDocument(); + expect(screen.queryByText(messages.networkErrorAlert.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByText(messages.serverErrorAlert.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByText('some error')).toBeInTheDocument(); + expect(screen.queryByText('some unknown error')).toBeInTheDocument(); + }); + + it('renders forbidden api error alerts', async () => { + renderComponent({ + ...pageAlertsData, + errors: { + outlineIndexApi: { + data: 'some error', status: 403, type: API_ERROR_TYPES.forbidden, dismissable: false, + }, + }, + }); + expect(screen.queryByText(messages.forbiddenAlert.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByText(messages.forbiddenAlertBody.defaultMessage)).toBeInTheDocument(); + }); + + it('renders api error alerts when status is not 403', async () => { + renderComponent({ + ...pageAlertsData, + errors: { + outlineIndexApi: { + data: 'some error', status: 500, type: API_ERROR_TYPES.serverError, dismissable: true, + }, + }, + }); + expect(screen.queryByText('some error')).toBeInTheDocument(); }); }); diff --git a/src/course-outline/page-alerts/messages.js b/src/course-outline/page-alerts/messages.js index f9638398d8..9aa6756a78 100644 --- a/src/course-outline/page-alerts/messages.js +++ b/src/course-outline/page-alerts/messages.js @@ -121,6 +121,21 @@ const messages = defineMessages({ defaultMessage: 'Network error', description: 'Generic network error alert.', }, + forbiddenAlert: { + id: 'course-authoring.course-outline.page-alert.forbidden.title', + defaultMessage: 'Access Restricted', + description: 'Forbidden(403) alert title', + }, + forbiddenAlertBody: { + id: 'course-authoring.course-outline.page-alert.forbidden.body', + defaultMessage: 'It looks like you’re trying to access a page you don’t have permission to view. Contact your admin if you think this is a mistake, or head back to the {LMS}.', + description: 'Forbidden(403) alert body', + }, + forbiddenAlertLmsUrl: { + id: 'course-authoring.course-outline.page-alert.lms', + defaultMessage: 'LMS', + description: 'LMS base redirection url', + }, }); export default messages; diff --git a/src/course-outline/utils/getErrorDetails.js b/src/course-outline/utils/getErrorDetails.js new file mode 100644 index 0000000000..fa7e11145b --- /dev/null +++ b/src/course-outline/utils/getErrorDetails.js @@ -0,0 +1,24 @@ +import { API_ERROR_TYPES } from '../constants'; + +export const getErrorDetails = (error, dismissible = true) => { + const errorInfo = { dismissible }; + if (error.response?.status === 403) { + // For 403 status the error shouldn't be dismissible + errorInfo.dismissible = false; + errorInfo.type = API_ERROR_TYPES.forbidden; + errorInfo.status = error.response.status; + } else if (error.response?.data) { + const { data } = error.response; + if ((typeof data === 'string' && !data.includes('')) || typeof data === 'object') { + errorInfo.data = JSON.stringify(data); + } + errorInfo.status = error.response.status; + errorInfo.type = API_ERROR_TYPES.serverError; + } else if (error.request) { + errorInfo.type = API_ERROR_TYPES.networkError; + } else { + errorInfo.type = API_ERROR_TYPES.unknown; + errorInfo.data = error.message; + } + return errorInfo; +}; diff --git a/src/course-outline/utils/getErrorDetails.test.js b/src/course-outline/utils/getErrorDetails.test.js new file mode 100644 index 0000000000..5794859333 --- /dev/null +++ b/src/course-outline/utils/getErrorDetails.test.js @@ -0,0 +1,36 @@ +import { getErrorDetails } from './getErrorDetails'; +import { API_ERROR_TYPES } from '../constants'; + +describe('getErrorDetails', () => { + it('should handle 403 status error', () => { + const error = { response: { data: 'some data', status: 403 } }; + const result = getErrorDetails(error); + expect(result).toEqual({ dismissible: false, status: 403, type: API_ERROR_TYPES.forbidden }); + }); + + it('should handle response with data', () => { + const error = { response: { data: 'some data', status: 500 } }; + const result = getErrorDetails(error); + expect(result).toEqual({ + dismissible: true, data: '"some data"', status: 500, type: API_ERROR_TYPES.serverError, + }); + }); + + it('should handle response with HTML data', () => { + const error = { response: { data: 'error', status: 500 } }; + const result = getErrorDetails(error); + expect(result).toEqual({ dismissible: true, status: 500, type: API_ERROR_TYPES.serverError }); + }); + + it('should handle request error', () => { + const error = { request: {} }; + const result = getErrorDetails(error); + expect(result).toEqual({ dismissible: true, type: API_ERROR_TYPES.networkError }); + }); + + it('should handle unknown error', () => { + const error = { message: 'Unknown error' }; + const result = getErrorDetails(error); + expect(result).toEqual({ dismissible: true, type: API_ERROR_TYPES.unknown, data: 'Unknown error' }); + }); +}); diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 4c54d6775e..a09b985966 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -28,7 +28,7 @@ import Breadcrumbs from './breadcrumbs/Breadcrumbs'; import HeaderNavigations from './header-navigations/HeaderNavigations'; import Sequence from './course-sequence'; import Sidebar from './sidebar'; -import { useCourseUnit } from './hooks'; +import { useCourseUnit, useLayoutGrid } from './hooks'; import messages from './messages'; import PublishControls from './sidebar/PublishControls'; import LocationInfo from './sidebar/LocationInfo'; @@ -45,10 +45,13 @@ const CourseUnit = ({ courseId }) => { isLoading, sequenceId, unitTitle, + unitCategory, errorMessage, sequenceStatus, savingStatus, isTitleEditFormOpen, + isUnitVerticalType, + isUnitLibraryType, staticFileNotices, currentlyVisibleToStudents, unitXBlockActions, @@ -70,6 +73,7 @@ const CourseUnit = ({ courseId }) => { handleCloseXBlockMovedAlert, handleNavigateToTargetUnit, } = useCourseUnit({ courseId, blockId }); + const layoutGrid = useLayoutGrid(unitCategory, isUnitLibraryType); useEffect(() => { document.title = getPageHeadTitle('', unitTitle); @@ -142,28 +146,28 @@ const CourseUnit = ({ courseId }) => { /> )} breadcrumbs={( - + )} headerActions={( )} /> - - + {isUnitVerticalType && ( + + )} + {currentlyVisibleToStudents && ( { /> )} - - {showPasteXBlock && canPasteComponent && ( + {isUnitVerticalType && ( + + )} + {showPasteXBlock && canPasteComponent && isUnitVerticalType && ( { - - - - {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' - && ( - - - + {isUnitVerticalType && ( + <> + + + + {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( + + + + )} + + + + )} - - - diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss index a2d6124ba3..abc649b986 100644 --- a/src/course-unit/CourseUnit.scss +++ b/src/course-unit/CourseUnit.scss @@ -6,6 +6,10 @@ @import "./move-modal"; @import "./preview-changes"; +.course-unit { + min-width: 900px; +} + .course-unit__alert { margin-bottom: 1.75rem; } diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 63fb3bf1d1..ed2236ed08 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -24,6 +24,7 @@ import { } from './data/api'; import { createNewCourseXBlock, + deleteUnitItemQuery, editCourseUnitVisibilityAndData, fetchCourseSectionVerticalData, fetchCourseUnitQuery, @@ -41,8 +42,9 @@ import { clipboardMockResponse, courseOutlineInfoMock, } from './__mocks__'; -import { clipboardUnit } from '../__mocks__'; +import { clipboardUnit, clipboardXBlock } from '../__mocks__'; import { executeThunk } from '../utils'; +import { IFRAME_FEATURE_POLICY } from '../constants'; import pasteComponentMessages from '../generic/clipboard/paste-component/messages'; import pasteNotificationsMessages from './clipboard/paste-notification/messages'; import headerNavigationsMessages from './header-navigations/messages'; @@ -52,12 +54,14 @@ import sidebarMessages from './sidebar/messages'; import { extractCourseUnitId } from './sidebar/utils'; import CourseUnit from './CourseUnit'; +import { getClipboardUrl } from '../generic/data/api'; import configureModalMessages from '../generic/configure-modal/messages'; import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api'; import addComponentMessages from './add-component/messages'; import { messageTypes, PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants'; import { IframeProvider } from './context/iFrameContext'; import moveModalMessages from './move-modal/messages'; +import xblockContainerIframeMessages from './xblock-container-iframe/messages'; import messages from './messages'; let axiosMock; @@ -67,6 +71,13 @@ const blockId = '567890'; const unitDisplayName = courseUnitIndexMock.metadata.display_name; const mockedUsedNavigate = jest.fn(); const userName = 'openedx'; +const handleConfigureSubmitMock = jest.fn(); + +const { + block_id: id, + user_partition_info: userPartitionInfo, +} = courseVerticalChildrenMock.children[0]; +const userPartitionInfoFormatted = camelCaseObject(userPartitionInfo); const postXBlockBody = { parent_locator: blockId, @@ -114,6 +125,22 @@ const clipboardBroadcastChannelMock = { global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); +/** + * Simulates receiving a post message event for testing purposes. + * This can be used to mimic events like deletion or other actions + * sent from Backbone or other sources via postMessage. + * + * @param {string} type - The type of the message event (e.g., 'deleteXBlock'). + * @param {Object} payload - The payload data for the message event. + */ +function simulatePostMessageEvent(type, payload) { + const messageEvent = new MessageEvent('message', { + data: { type, payload }, + }); + + window.dispatchEvent(messageEvent); +} + const RootWrapper = () => ( @@ -138,6 +165,9 @@ describe('', () => { global.localStorage.clear(); store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getClipboardUrl()) + .reply(200, clipboardUnit); axiosMock .onGet(getCourseUnitApiUrl(courseId)) .reply(200, courseUnitIndexMock); @@ -175,6 +205,259 @@ describe('', () => { }); }); + it('renders the course unit iframe with correct attributes', async () => { + const { getByTitle } = render(); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute('src', `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`); + expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY); + expect(iframe).toHaveAttribute('style', 'width: 100%; height: 0px;'); + expect(iframe).toHaveAttribute('scrolling', 'no'); + expect(iframe).toHaveAttribute('referrerpolicy', 'origin'); + expect(iframe).toHaveAttribute('loading', 'lazy'); + expect(iframe).toHaveAttribute('frameborder', '0'); + }); + }); + + it('adjusts iframe height dynamically based on courseXBlockDropdownHeight postMessage event', async () => { + const { getByTitle } = render(); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute('style', 'width: 100%; height: 0px;'); + simulatePostMessageEvent(messageTypes.toggleCourseXBlockDropdown, { + courseXBlockDropdownHeight: 200, + }); + expect(iframe).toHaveAttribute('style', 'width: 100%; height: 200px;'); + }); + }); + + it('checks whether xblock is removed when the corresponding delete button is clicked and the sidebar is the updated', async () => { + const { + getByTitle, getByText, queryByRole, getAllByRole, getByRole, + } = render(); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', courseVerticalChildrenMock.children.length), + ); + + simulatePostMessageEvent(messageTypes.deleteXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + + expect(getByText(/Delete this component?/i)).toBeInTheDocument(); + expect(getByText(/Deleting this component is permanent and cannot be undone./i)).toBeInTheDocument(); + + expect(getByRole('dialog')).toBeInTheDocument(); + + // Find the Cancel and Delete buttons within the iframe by their specific classes + const cancelButton = getAllByRole('button', { name: /Cancel/i }) + .find(({ classList }) => classList.contains('btn-tertiary')); + const deleteButton = getAllByRole('button', { name: /Delete/i }) + .find(({ classList }) => classList.contains('btn-primary')); + + userEvent.click(cancelButton); + + simulatePostMessageEvent(messageTypes.deleteXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + + expect(getByRole('dialog')).toBeInTheDocument(); + userEvent.click(deleteButton); + }); + + axiosMock + .onPost(getXBlockBaseApiUrl(blockId), { + publish: PUBLISH_TYPES.makePublic, + }) + .reply(200, { dummy: 'value' }); + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, { + ...courseUnitIndexMock, + visibility_state: UNIT_VISIBILITY_STATES.live, + has_changes: false, + published_by: userName, + }); + await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + + await waitFor(() => { + // check if the sidebar status is Published and Live + expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); + expect(getByText( + sidebarMessages.publishLastPublished.defaultMessage + .replace('{publishedOn}', courseUnitIndexMock.published_on) + .replace('{publishedBy}', userName), + )).toBeInTheDocument(); + expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + expect(getByText(unitDisplayName)).toBeInTheDocument(); + }); + + axiosMock + .onDelete(getXBlockBaseApiUrl(courseVerticalChildrenMock.children[0].block_id)) + .replyOnce(200, { dummy: 'value' }); + await executeThunk(deleteUnitItemQuery(courseId, blockId), store.dispatch); + + const updatedCourseVerticalChildren = courseVerticalChildrenMock.children.filter( + child => child.block_id !== courseVerticalChildrenMock.children[0].block_id, + ); + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, { + children: updatedCourseVerticalChildren, + isPublished: false, + canPasteComponent: true, + }); + await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); + + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, courseUnitIndexMock); + await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', updatedCourseVerticalChildren.length), + ); + // after removing the xblock, the sidebar status changes to Draft (unpublished changes) + expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); + expect(getByText( + sidebarMessages.publishInfoDraftSaved.defaultMessage + .replace('{editedOn}', courseUnitIndexMock.edited_on) + .replace('{editedBy}', courseUnitIndexMock.edited_by), + )).toBeInTheDocument(); + expect(getByText( + sidebarMessages.releaseInfoWithSection.defaultMessage + .replace('{sectionName}', courseUnitIndexMock.release_date_from), + )).toBeInTheDocument(); + }); + }); + + it('checks if xblock is a duplicate when the corresponding duplicate button is clicked and if the sidebar status is updated', async () => { + const { + getByTitle, getByRole, getByText, queryByRole, + } = render(); + + simulatePostMessageEvent(messageTypes.duplicateXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + + axiosMock + .onPost(postXBlockBaseApiUrl({ + parent_locator: blockId, + duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id, + })) + .replyOnce(200, { locator: '1234567890' }); + + const updatedCourseVerticalChildren = [ + ...courseVerticalChildrenMock.children, + { + ...courseVerticalChildrenMock.children[0], + name: 'New Cloned XBlock', + }, + ]; + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, { + ...courseVerticalChildrenMock, + children: updatedCourseVerticalChildren, + }); + + await waitFor(() => { + userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); + + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', courseVerticalChildrenMock.children.length), + ); + + simulatePostMessageEvent(messageTypes.duplicateXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + }); + + axiosMock + .onPost(getXBlockBaseApiUrl(blockId), { + publish: PUBLISH_TYPES.makePublic, + }) + .reply(200, { dummy: 'value' }); + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, { + ...courseUnitIndexMock, + visibility_state: UNIT_VISIBILITY_STATES.live, + has_changes: false, + published_by: userName, + }); + await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + + await waitFor(() => { + // check if the sidebar status is Published and Live + expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); + expect(getByText( + sidebarMessages.publishLastPublished.defaultMessage + .replace('{publishedOn}', courseUnitIndexMock.published_on) + .replace('{publishedBy}', userName), + )).toBeInTheDocument(); + expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + expect(getByText(unitDisplayName)).toBeInTheDocument(); + }); + + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, courseUnitIndexMock); + await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', updatedCourseVerticalChildren.length), + ); + + // after duplicate the xblock, the sidebar status changes to Draft (unpublished changes) + expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); + expect(getByText( + sidebarMessages.publishInfoDraftSaved.defaultMessage + .replace('{editedOn}', courseUnitIndexMock.edited_on) + .replace('{editedBy}', courseUnitIndexMock.edited_by), + )).toBeInTheDocument(); + expect(getByText( + sidebarMessages.releaseInfoWithSection.defaultMessage + .replace('{sectionName}', courseUnitIndexMock.release_date_from), + )).toBeInTheDocument(); + }); + }); + it('handles CourseUnit header action buttons', async () => { const { open } = window; window.open = jest.fn(); @@ -226,6 +509,19 @@ describe('', () => { display_name: newDisplayName, }, }); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + xblock_info: { + ...courseSectionVerticalMock.xblock_info, + display_name: newDisplayName, + }, + xblock: { + ...courseSectionVerticalMock.xblock, + display_name: newDisplayName, + }, + }); await waitFor(() => { const unitHeaderTitle = getByTestId('unit-header-title'); @@ -877,6 +1173,77 @@ describe('', () => { .toHaveBeenCalledWith(`/course/${courseId}/container/${blockId}/${updatedAncestorsChild.id}`, { replace: true }); }); + it('should increase the number of course XBlocks after copying and pasting a block', async () => { + const { getByRole, getByTitle } = render(); + + simulatePostMessageEvent(messageTypes.copyXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + user_clipboard: clipboardXBlock, + }); + axiosMock + .onGet(getCourseUnitApiUrl(courseId)) + .reply(200, { + ...courseUnitIndexMock, + enable_copy_paste_units: true, + }); + await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + + userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); + userEvent.click(getByRole('button', { name: messages.pasteButtonText.defaultMessage })); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', courseVerticalChildrenMock.children.length), + ); + + simulatePostMessageEvent(messageTypes.copyXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + }); + + const updatedCourseVerticalChildren = [ + ...courseVerticalChildrenMock.children, + { + name: 'Copy XBlock', + block_id: '1234567890', + block_type: 'drag-and-drop-v2', + user_partition_info: { + selectable_partitions: [], + selected_partition_index: -1, + selected_groups_label: '', + }, + }, + ]; + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, { + ...courseVerticalChildrenMock, + children: updatedCourseVerticalChildren, + }); + + await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', updatedCourseVerticalChildren.length), + ); + }); + }); + it('displays a notification about new files after pasting a component', async () => { const { queryByTestId, getByTestId, getByRole, @@ -914,9 +1281,7 @@ describe('', () => { .reply(200, clipboardMockResponse); axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...updatedCourseSectionVerticalData, - }); + .reply(200, updatedCourseSectionVerticalData); global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices)); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); @@ -1190,7 +1555,7 @@ describe('', () => { axiosMock .onGet(getCourseUnitApiUrl(blockId)) - .reply(200, {}); + .reply(200, courseUnitIndexMock); await act(async () => { await waitFor(() => { @@ -1324,4 +1689,204 @@ describe('', () => { ); }); }); + + describe('XBlock restrict access', () => { + it('opens xblock restrict access modal successfully', () => { + const { + getByTitle, getByTestId, + } = render(); + + const modalSubtitleText = configureModalMessages.restrictAccessTo.defaultMessage; + const modalCancelBtnText = configureModalMessages.cancelButton.defaultMessage; + const modalSaveBtnText = configureModalMessages.saveButton.defaultMessage; + + waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const usageId = courseVerticalChildrenMock.children[0].block_id; + expect(iframe).toBeInTheDocument(); + + simulatePostMessageEvent(messageTypes.manageXBlockAccess, { + usageId, + }); + }); + + waitFor(() => { + const configureModal = getByTestId('configure-modal'); + + expect(within(configureModal).getByText(modalSubtitleText)).toBeInTheDocument(); + expect(within(configureModal).getByRole('button', { name: modalCancelBtnText })).toBeInTheDocument(); + expect(within(configureModal).getByRole('button', { name: modalSaveBtnText })).toBeInTheDocument(); + }); + }); + + it('closes xblock restrict access modal when cancel button is clicked', async () => { + const { + getByTitle, queryByTestId, getByTestId, + } = render(); + + waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toBeInTheDocument(); + simulatePostMessageEvent(messageTypes.manageXBlockAccess, { + usageId: courseVerticalChildrenMock.children[0].block_id, + }); + }); + + waitFor(() => { + const configureModal = getByTestId('configure-modal'); + expect(configureModal).toBeInTheDocument(); + userEvent.click(within(configureModal).getByRole('button', { + name: configureModalMessages.cancelButton.defaultMessage, + })); + expect(handleConfigureSubmitMock).not.toHaveBeenCalled(); + }); + + expect(queryByTestId('configure-modal')).not.toBeInTheDocument(); + }); + + it('handles submit xblock restrict access data when save button is clicked', async () => { + axiosMock + .onPost(getXBlockBaseApiUrl(id), { + publish: PUBLISH_TYPES.republish, + metadata: { visible_to_staff_only: false, group_access: { 970807507: [1959537066] } }, + }) + .reply(200, { dummy: 'value' }); + + const { + getByTitle, getByRole, getByTestId, + } = render(); + + const accessGroupName1 = userPartitionInfoFormatted.selectablePartitions[0].groups[0].name; + const accessGroupName2 = userPartitionInfoFormatted.selectablePartitions[0].groups[1].name; + + waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toBeInTheDocument(); + simulatePostMessageEvent(messageTypes.manageXBlockAccess, { + usageId: courseVerticalChildrenMock.children[0].block_id, + }); + }); + + waitFor(() => { + const configureModal = getByTestId('configure-modal'); + expect(configureModal).toBeInTheDocument(); + + expect(within(configureModal).queryByText(accessGroupName1)).not.toBeInTheDocument(); + expect(within(configureModal).queryByText(accessGroupName2)).not.toBeInTheDocument(); + + const restrictAccessSelect = getByRole('combobox', { + name: configureModalMessages.restrictAccessTo.defaultMessage, + }); + + userEvent.selectOptions(restrictAccessSelect, '0'); + + // eslint-disable-next-line array-callback-return + userPartitionInfoFormatted.selectablePartitions[0].groups.map((group) => { + expect(within(configureModal).getByRole('checkbox', { name: group.name })).not.toBeChecked(); + expect(within(configureModal).queryByText(group.name)).toBeInTheDocument(); + }); + + const group1Checkbox = within(configureModal).getByRole('checkbox', { name: accessGroupName1 }); + userEvent.click(group1Checkbox); + expect(group1Checkbox).toBeChecked(); + + const saveModalBtnText = within(configureModal).getByRole('button', { + name: configureModalMessages.saveButton.defaultMessage, + }); + expect(saveModalBtnText).toBeInTheDocument(); + + userEvent.click(saveModalBtnText); + expect(handleConfigureSubmitMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + it('renders and navigates to the new HTML XBlock editor after xblock duplicating', async () => { + const { getByTitle } = render(); + const updatedCourseVerticalChildrenMock = JSON.parse(JSON.stringify(courseVerticalChildrenMock)); + const targetBlockId = updatedCourseVerticalChildrenMock.children[1].block_id; + + updatedCourseVerticalChildrenMock.children = updatedCourseVerticalChildrenMock.children + .map((child) => (child.block_id === targetBlockId + ? { ...child, block_type: 'html' } + : child)); + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, updatedCourseVerticalChildrenMock); + + await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toBeInTheDocument(); + simulatePostMessageEvent(messageTypes.currentXBlockId, { + id: targetBlockId, + }); + }); + + waitFor(() => { + simulatePostMessageEvent(messageTypes.duplicateXBlock, {}); + simulatePostMessageEvent(messageTypes.newXBlockEditor, {}); + expect(mockedUsedNavigate) + .toHaveBeenCalledWith(`/course/${courseId}/editor/html/${targetBlockId}`, { replace: true }); + }); + }); + + describe('Library Content page', () => { + const newUnitId = '12345'; + const sequenceId = courseSectionVerticalMock.subsection_location; + + beforeEach(async () => { + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + xblock: { + ...courseSectionVerticalMock.xblock, + category: 'library_content', + }, + xblock_info: { + ...courseSectionVerticalMock.xblock_info, + category: 'library_content', + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + }); + + it('navigates to library content page on receive window event', () => { + render(); + + simulatePostMessageEvent(messageTypes.handleViewXBlockContent, { usageId: newUnitId }); + expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/container/${newUnitId}/${sequenceId}`); + }); + + it('should render library content page correctly', async () => { + const { + getByText, + getByRole, + queryByRole, + getByTestId, + } = render(); + + const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; + const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; + + await waitFor(() => { + const unitHeaderTitle = getByTestId('unit-header-title'); + expect(getByText(unitDisplayName)).toBeInTheDocument(); + expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument(); + expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: currentSectionName })).toBeInTheDocument(); + expect(getByRole('button', { name: currentSubSectionName })).toBeInTheDocument(); + + expect(queryByRole('heading', { name: addComponentMessages.title.defaultMessage })).not.toBeInTheDocument(); + expect(queryByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).not.toBeInTheDocument(); + expect(queryByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).not.toBeInTheDocument(); + + expect(queryByRole('heading', { name: /unit tags/i })).not.toBeInTheDocument(); + expect(queryByRole('heading', { name: /unit location/i })).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/src/course-unit/add-component/AddComponent.jsx b/src/course-unit/add-component/AddComponent.jsx index 70962a7ac6..598b82b59f 100644 --- a/src/course-unit/add-component/AddComponent.jsx +++ b/src/course-unit/add-component/AddComponent.jsx @@ -23,7 +23,7 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => { const [isOpenAdvanced, openAdvanced, closeAdvanced] = useToggle(false); const [isOpenHtml, openHtml, closeHtml] = useToggle(false); const [isOpenOpenAssessment, openOpenAssessment, closeOpenAssessment] = useToggle(false); - const { componentTemplates } = useSelector(getCourseSectionVertical); + const { componentTemplates = {} } = useSelector(getCourseSectionVertical); const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); const [isSelectLibraryContentModalOpen, showSelectLibraryContentModal, closeSelectLibraryContentModal] = useToggle(); const [selectedComponents, setSelectedComponents] = useState([]); diff --git a/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx b/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx index 91cc5b09b1..030e50b2ea 100644 --- a/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx +++ b/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import { Icon } from '@openedx/paragon'; import { EditNote as EditNoteIcon } from '@openedx/paragon/icons'; -import { COMPONENT_TYPES, COMPONENT_TYPE_ICON_MAP } from '../../../generic/block-type-utils/constants'; +import { COMPONENT_TYPE_ICON_MAP } from '../../../generic/block-type-utils/constants'; const AddComponentIcon = ({ type }) => { const icon = COMPONENT_TYPE_ICON_MAP[type] || EditNoteIcon; @@ -11,7 +11,7 @@ const AddComponentIcon = ({ type }) => { }; AddComponentIcon.propTypes = { - type: PropTypes.oneOf(Object.values(COMPONENT_TYPES)).isRequired, + type: PropTypes.string.isRequired, }; export default AddComponentIcon; diff --git a/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx b/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx index 84fbc16115..dcbd9e45c3 100644 --- a/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx +++ b/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx @@ -72,7 +72,7 @@ const ComponentModalView = ({ + {supportLabels[componentTemplate.supportLevel].tooltip} )} diff --git a/src/course-unit/breadcrumbs/Breadcrumbs.jsx b/src/course-unit/breadcrumbs/Breadcrumbs.jsx deleted file mode 100644 index 26bfa53562..0000000000 --- a/src/course-unit/breadcrumbs/Breadcrumbs.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useSelector } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { Dropdown, Icon } from '@openedx/paragon'; -import { Link } from 'react-router-dom'; -import { - ArrowDropDown as ArrowDropDownIcon, - ChevronRight as ChevronRightIcon, -} from '@openedx/paragon/icons'; -import { getConfig } from '@edx/frontend-platform'; - -import { getWaffleFlags } from '../../data/selectors'; -import { getCourseSectionVertical } from '../data/selectors'; -import messages from './messages'; - -const Breadcrumbs = () => { - const intl = useIntl(); - const { ancestorXblocks } = useSelector(getCourseSectionVertical); - const [section, subsection] = ancestorXblocks ?? []; - const waffleFlags = useSelector(getWaffleFlags); - - const getPathToCourseOutlinePage = (url) => (waffleFlags.useNewCourseOutlinePage - ? url : `${getConfig().STUDIO_BASE_URL}${url}`); - - return ( - - ); -}; - -export default Breadcrumbs; diff --git a/src/course-unit/breadcrumbs/Breadcrumbs.test.jsx b/src/course-unit/breadcrumbs/Breadcrumbs.test.tsx similarity index 74% rename from src/course-unit/breadcrumbs/Breadcrumbs.test.jsx rename to src/course-unit/breadcrumbs/Breadcrumbs.test.tsx index d20a35c339..e4c93deb8f 100644 --- a/src/course-unit/breadcrumbs/Breadcrumbs.test.jsx +++ b/src/course-unit/breadcrumbs/Breadcrumbs.test.tsx @@ -15,6 +15,7 @@ import Breadcrumbs from './Breadcrumbs'; let axiosMock; let reduxStore; const courseId = '123'; +const parentUnitId = '456'; const mockNavigate = jest.fn(); const breadcrumbsExpected = { section: { @@ -32,7 +33,7 @@ jest.mock('react-router-dom', () => ({ })); const renderComponent = () => render( - , + , ); describe('', () => { @@ -69,6 +70,39 @@ describe('', () => { }); }); + it('render Breadcrumbs with many ancestors items correctly', async () => { + axiosMock + .onGet(getCourseSectionVerticalApiUrl(courseId)) + .reply(200, { + ...courseSectionVerticalMock, + ancestor_xblocks: [ + { + children: [ + { + ...courseSectionVerticalMock.ancestor_xblocks[0], + display_name: 'Some module unit 1', + }, + { + ...courseSectionVerticalMock.ancestor_xblocks[1], + display_name: 'Some module unit 2', + }, + ], + title: 'Some module', + is_last: false, + }, + ...courseSectionVerticalMock.ancestor_xblocks, + ], + }); + await executeThunk(fetchCourseSectionVerticalData(courseId), reduxStore.dispatch); + const { getByText } = renderComponent(); + + await waitFor(() => { + expect(getByText('Some module')).toBeInTheDocument(); + expect(getByText(breadcrumbsExpected.section.displayName)).toBeInTheDocument(); + expect(getByText(breadcrumbsExpected.subsection.displayName)).toBeInTheDocument(); + }); + }); + it('render Breadcrumbs\'s dropdown menus correctly', async () => { const { getByText, queryAllByTestId } = renderComponent(); @@ -80,11 +114,13 @@ describe('', () => { const button = getByText(breadcrumbsExpected.section.displayName); userEvent.click(button); await waitFor(() => { - expect(queryAllByTestId('breadcrumbs-section-dropdown-item')).toHaveLength(5); + expect(queryAllByTestId('breadcrumbs-dropdown-item-level-0')).toHaveLength(5); }); userEvent.click(getByText(breadcrumbsExpected.subsection.displayName)); - expect(queryAllByTestId('breadcrumbs-subsection-dropdown-item')).toHaveLength(2); + await waitFor(() => { + expect(queryAllByTestId('breadcrumbs-dropdown-item-level-1')).toHaveLength(2); + }); }); it('navigates using the new course outline page when the waffle flag is enabled', async () => { @@ -118,6 +154,6 @@ describe('', () => { userEvent.click(dropdownBtn); const dropdownItem = getByRole('link', { name: display_name }); - expect(dropdownItem.href).toBe(`${getConfig().STUDIO_BASE_URL}${url}`); + expect(dropdownItem).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${url}`); }); }); diff --git a/src/course-unit/breadcrumbs/Breadcrumbs.tsx b/src/course-unit/breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 0000000000..367d0a5643 --- /dev/null +++ b/src/course-unit/breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,80 @@ +import { useSelector } from 'react-redux'; +import { Dropdown, Icon } from '@openedx/paragon'; +import { Link } from 'react-router-dom'; +import { + ArrowDropDown as ArrowDropDownIcon, + ChevronRight as ChevronRightIcon, +} from '@openedx/paragon/icons'; +import { getConfig } from '@edx/frontend-platform'; + +import { getWaffleFlags } from '../../data/selectors'; +import { getCourseSectionVertical } from '../data/selectors'; +import { adoptCourseSectionUrl } from '../utils'; + +const Breadcrumbs = ({ courseId, parentUnitId }: { courseId: string, parentUnitId: string }) => { + const { ancestorXblocks = [] } = useSelector(getCourseSectionVertical); + const waffleFlags = useSelector(getWaffleFlags); + + const getPathToCourseOutlinePage = (url) => (waffleFlags.useNewCourseOutlinePage + ? url : `${getConfig().STUDIO_BASE_URL}${url}`); + + const getPathToCourseUnitPage = (url) => (waffleFlags.useNewUnitPage + ? adoptCourseSectionUrl({ url, courseId, parentUnitId }) + : `${getConfig().STUDIO_BASE_URL}${url}`); + + const getPathToCoursePage = (isOutlinePage, url) => ( + isOutlinePage ? getPathToCourseOutlinePage(url) : getPathToCourseUnitPage(url) + ); + + return ( + + ); +}; + +export default Breadcrumbs; diff --git a/src/course-unit/clipboard/paste-notification/components/FileList.jsx b/src/course-unit/clipboard/paste-notification/components/FileList.jsx index f3f9e3beaa..148b622539 100644 --- a/src/course-unit/clipboard/paste-notification/components/FileList.jsx +++ b/src/course-unit/clipboard/paste-notification/components/FileList.jsx @@ -5,7 +5,7 @@ import { FILE_LIST_DEFAULT_VALUE } from '../constants'; const FileList = ({ fileList }) => (
    {fileList.map((fileName) => ( -
  • {fileName}
  • +
  • {fileName}
  • ))}
); diff --git a/src/course-unit/clipboard/paste-notification/index.jsx b/src/course-unit/clipboard/paste-notification/index.jsx index b92334c717..20eed888df 100644 --- a/src/course-unit/clipboard/paste-notification/index.jsx +++ b/src/course-unit/clipboard/paste-notification/index.jsx @@ -101,7 +101,7 @@ const PastNotificationAlert = ({ staticFileNotices, courseId }) => { PastNotificationAlert.propTypes = { courseId: PropTypes.string.isRequired, staticFileNotices: - PropTypes.objectOf({ + PropTypes.shape({ conflictingFiles: PropTypes.arrayOf(PropTypes.string), errorFiles: PropTypes.arrayOf(PropTypes.string), newFiles: PropTypes.arrayOf(PropTypes.string), diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js index 129fa55d9b..b2c16e83fd 100644 --- a/src/course-unit/constants.js +++ b/src/course-unit/constants.js @@ -55,8 +55,12 @@ export const messageTypes = { showMultipleComponentPicker: 'showMultipleComponentPicker', addSelectedComponentsToBank: 'addSelectedComponentsToBank', showXBlockLibraryChangesPreview: 'showXBlockLibraryChangesPreview', + copyXBlock: 'copyXBlock', + manageXBlockAccess: 'manageXBlockAccess', + deleteXBlock: 'deleteXBlock', + duplicateXBlock: 'duplicateXBlock', + refreshXBlockPositions: 'refreshPositions', + newXBlockEditor: 'newXBlockEditor', + toggleCourseXBlockDropdown: 'toggleCourseXBlockDropdown', + handleViewXBlockContent: 'handleViewXBlockContent', }; - -export const IFRAME_FEATURE_POLICY = ( - 'microphone *; camera *; midi *; geolocation *; encrypted-media *, clipboard-write *' -); diff --git a/src/course-unit/context/iFrameContext.tsx b/src/course-unit/context/iFrameContext.tsx index 75418f0d39..ab216bb79a 100644 --- a/src/course-unit/context/iFrameContext.tsx +++ b/src/course-unit/context/iFrameContext.tsx @@ -1,4 +1,4 @@ -import { +import React, { createContext, MutableRefObject, useRef, useCallback, useMemo, ReactNode, } from 'react'; import { logError } from '@edx/frontend-platform/logging'; diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index 039285dcf4..7a46974060 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -99,6 +99,7 @@ export async function createCourseXblock({ * @param {string} type - The action type (e.g., PUBLISH_TYPES.discardChanges). * @param {boolean} isVisible - The visibility status for students. * @param {boolean} groupAccess - Access group key set. + * @param {boolean} isDiscussionEnabled - Indicates whether the discussion feature is enabled. * @returns {Promise} A promise that resolves with the response data. */ export async function handleCourseUnitVisibilityAndData(unitId, type, isVisible, groupAccess, isDiscussionEnabled) { diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index aab66ea260..1755d0960f 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -93,22 +93,6 @@ const slice = createSlice({ updateCourseVerticalChildrenLoadingStatus: (state, { payload }) => { state.loadingStatus.courseVerticalChildrenLoadingStatus = payload.status; }, - deleteXBlock: (state, { payload }) => { - state.courseVerticalChildren.children = state.courseVerticalChildren.children.filter( - (component) => component.id !== payload, - ); - }, - duplicateXBlock: (state, { payload }) => { - state.courseVerticalChildren = { - ...payload.newCourseVerticalChildren, - children: payload.newCourseVerticalChildren.children.map((component) => { - if (component.blockId === payload.newId) { - component.shouldScroll = true; - } - return component; - }), - }; - }, fetchStaticFileNoticesSuccess: (state, { payload }) => { state.staticFileNotices = payload; }, @@ -139,8 +123,6 @@ export const { updateLoadingCourseXblockStatus, updateCourseVerticalChildren, updateCourseVerticalChildrenLoadingStatus, - deleteXBlock, - duplicateXBlock, fetchStaticFileNoticesSuccess, updateCourseOutlineInfo, updateCourseOutlineInfoLoadingStatus, diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index a0d421eea3..1956426cfe 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -34,8 +34,6 @@ import { updateCourseVerticalChildren, updateCourseVerticalChildrenLoadingStatus, updateQueryPendingStatus, - deleteXBlock, - duplicateXBlock, fetchStaticFileNoticesSuccess, updateCourseOutlineInfo, updateCourseOutlineInfoLoadingStatus, @@ -70,11 +68,11 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) { dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.SUCCESSFUL })); dispatch(updateModel({ modelType: 'sequences', - model: courseSectionVerticalData.sequence, + model: courseSectionVerticalData.sequence || [], })); dispatch(updateModels({ modelType: 'units', - models: courseSectionVerticalData.units, + models: courseSectionVerticalData.units || [], })); dispatch(fetchStaticFileNoticesSuccess(JSON.parse(localStorage.getItem('staticFileNotices')))); localStorage.removeItem('staticFileNotices'); @@ -103,11 +101,11 @@ export function editCourseItemQuery(itemId, displayName, sequenceId) { dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.SUCCESSFUL })); dispatch(updateModel({ modelType: 'sequences', - model: courseSectionVerticalData.sequence, + model: courseSectionVerticalData.sequence || [], })); dispatch(updateModels({ modelType: 'units', - models: courseSectionVerticalData.units, + models: courseSectionVerticalData.units || [], })); dispatch(fetchSequenceSuccess({ sequenceId })); dispatch(fetchCourseItemSuccess(courseUnit)); @@ -229,7 +227,6 @@ export function deleteUnitItemQuery(itemId, xblockId) { try { await deleteUnitItem(xblockId); - dispatch(deleteXBlock(xblockId)); const { userClipboard } = await getCourseSectionVerticalData(itemId); dispatch(updateClipboardData(userClipboard)); const courseUnit = await getCourseUnitData(itemId); @@ -249,12 +246,7 @@ export function duplicateUnitItemQuery(itemId, xblockId) { dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating)); try { - const { locator } = await duplicateUnitItem(itemId, xblockId); - const newCourseVerticalChildren = await getCourseVerticalChildren(itemId); - dispatch(duplicateXBlock({ - newId: locator, - newCourseVerticalChildren, - })); + await duplicateUnitItem(itemId, xblockId); const courseUnit = await getCourseUnitData(itemId); dispatch(fetchCourseItemSuccess(courseUnit)); dispatch(hideProcessingNotification()); diff --git a/src/course-unit/data/utils.js b/src/course-unit/data/utils.js index def2b38492..0b28805297 100644 --- a/src/course-unit/data/utils.js +++ b/src/course-unit/data/utils.js @@ -10,9 +10,9 @@ export function normalizeCourseSectionVerticalData(metadata) { sequence: { id: data.subsectionLocation, title: data.xblock.displayName, - unitIds: data.xblockInfo.ancestorInfo.ancestors[0].childInfo.children.map((item) => item.id), + unitIds: data.xblockInfo.ancestorInfo?.ancestors[0].childInfo.children.map((item) => item.id), }, - units: data.xblockInfo.ancestorInfo.ancestors[0].childInfo.children.map((unit) => ({ + units: data.xblockInfo.ancestorInfo?.ancestors[0].childInfo.children.map((unit) => ({ id: unit.id, sequenceId: data.subsectionLocation, bookmarked: unit.bookmarked, diff --git a/src/course-unit/header-navigations/HeaderNavigations.jsx b/src/course-unit/header-navigations/HeaderNavigations.jsx index 178c768dfd..a934c0c974 100644 --- a/src/course-unit/header-navigations/HeaderNavigations.jsx +++ b/src/course-unit/header-navigations/HeaderNavigations.jsx @@ -1,27 +1,42 @@ import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button } from '@openedx/paragon'; +import { Edit as EditIcon } from '@openedx/paragon/icons'; +import { COURSE_BLOCK_NAMES } from '../../constants'; import messages from './messages'; -const HeaderNavigations = ({ headerNavigationsActions }) => { +const HeaderNavigations = ({ headerNavigationsActions, unitCategory }) => { const intl = useIntl(); - const { handleViewLive, handlePreview } = headerNavigationsActions; + const { handleViewLive, handlePreview, handleEdit } = headerNavigationsActions; return ( ); }; @@ -30,7 +45,9 @@ HeaderNavigations.propTypes = { headerNavigationsActions: PropTypes.shape({ handleViewLive: PropTypes.func.isRequired, handlePreview: PropTypes.func.isRequired, + handleEdit: PropTypes.func.isRequired, }).isRequired, + unitCategory: PropTypes.string.isRequired, }; export default HeaderNavigations; diff --git a/src/course-unit/header-navigations/HeaderNavigations.test.jsx b/src/course-unit/header-navigations/HeaderNavigations.test.jsx index e5a094247e..1c93905cec 100644 --- a/src/course-unit/header-navigations/HeaderNavigations.test.jsx +++ b/src/course-unit/header-navigations/HeaderNavigations.test.jsx @@ -1,14 +1,18 @@ import { fireEvent, render } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { COURSE_BLOCK_NAMES } from '../../constants'; import HeaderNavigations from './HeaderNavigations'; import messages from './messages'; const handleViewLiveFn = jest.fn(); const handlePreviewFn = jest.fn(); +const handleEditFn = jest.fn(); + const headerNavigationsActions = { handleViewLive: handleViewLiveFn, handlePreview: handlePreviewFn, + handleEdit: handleEditFn, }; const renderComponent = (props) => render( @@ -22,14 +26,14 @@ const renderComponent = (props) => render( describe('', () => { it('render HeaderNavigations component correctly', () => { - const { getByRole } = renderComponent(); + const { getByRole } = renderComponent({ unitCategory: COURSE_BLOCK_NAMES.vertical.id }); expect(getByRole('button', { name: messages.viewLiveButton.defaultMessage })).toBeInTheDocument(); expect(getByRole('button', { name: messages.previewButton.defaultMessage })).toBeInTheDocument(); }); - it('calls the correct handlers when clicking buttons', () => { - const { getByRole } = renderComponent(); + it('calls the correct handlers when clicking buttons for unit page', () => { + const { getByRole, queryByRole } = renderComponent({ unitCategory: COURSE_BLOCK_NAMES.vertical.id }); const viewLiveButton = getByRole('button', { name: messages.viewLiveButton.defaultMessage }); fireEvent.click(viewLiveButton); @@ -38,5 +42,22 @@ describe('', () => { const previewButton = getByRole('button', { name: messages.previewButton.defaultMessage }); fireEvent.click(previewButton); expect(handlePreviewFn).toHaveBeenCalledTimes(1); + + const editButton = queryByRole('button', { name: messages.editButton.defaultMessage }); + expect(editButton).not.toBeInTheDocument(); + }); + + it('calls the correct handlers when clicking buttons for library page', () => { + const { getByRole, queryByRole } = renderComponent({ unitCategory: COURSE_BLOCK_NAMES.libraryContent.id }); + + const editButton = getByRole('button', { name: messages.editButton.defaultMessage }); + fireEvent.click(editButton); + expect(handleViewLiveFn).toHaveBeenCalledTimes(1); + + const viewLiveButton = queryByRole('button', { name: messages.viewLiveButton.defaultMessage }); + expect(viewLiveButton).not.toBeInTheDocument(); + + const previewButton = queryByRole('button', { name: messages.previewButton.defaultMessage }); + expect(previewButton).not.toBeInTheDocument(); }); }); diff --git a/src/course-unit/header-navigations/messages.js b/src/course-unit/header-navigations/messages.ts similarity index 59% rename from src/course-unit/header-navigations/messages.js rename to src/course-unit/header-navigations/messages.ts index 55e60fc965..53239434ac 100644 --- a/src/course-unit/header-navigations/messages.js +++ b/src/course-unit/header-navigations/messages.ts @@ -4,10 +4,17 @@ const messages = defineMessages({ viewLiveButton: { id: 'course-authoring.course-unit.button.view-live', defaultMessage: 'View live version', + description: 'The unit view live button text', }, previewButton: { id: 'course-authoring.course-unit.button.preview', defaultMessage: 'Preview', + description: 'The unit preview button text', + }, + editButton: { + id: 'course-authoring.course-unit.button.edit', + defaultMessage: 'Edit', + description: 'The unit edit button text', }, }); diff --git a/src/course-unit/header-title/HeaderTitle.jsx b/src/course-unit/header-title/HeaderTitle.jsx index 336d986fab..7d27563536 100644 --- a/src/course-unit/header-title/HeaderTitle.jsx +++ b/src/course-unit/header-title/HeaderTitle.jsx @@ -9,8 +9,11 @@ import { } from '@openedx/paragon/icons'; import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; +import { COURSE_BLOCK_NAMES } from '../../constants'; import { getCourseUnitData } from '../data/selectors'; import { updateQueryPendingStatus } from '../data/slice'; +import { messageTypes } from '../constants'; +import { useIframe } from '../context/hooks'; import messages from './messages'; const HeaderTitle = ({ @@ -26,9 +29,15 @@ const HeaderTitle = ({ const currentItemData = useSelector(getCourseUnitData); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const { selectedPartitionIndex, selectedGroupsLabel } = currentItemData.userPartitionInfo; + const { sendMessageToIframe } = useIframe(); const onConfigureSubmit = (...arg) => { handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal); + // TODO: this artificial delay is a temporary solution + // to ensure the iframe content is properly refreshed. + setTimeout(() => { + sendMessageToIframe(messageTypes.refreshXBlock, null); + }, 1000); }; const getVisibilityMessage = () => { @@ -86,6 +95,9 @@ const HeaderTitle = ({ onConfigureSubmit={onConfigureSubmit} currentItemData={currentItemData} isSelfPaced={false} + isXBlockComponent={ + [COURSE_BLOCK_NAMES.libraryContent.id, COURSE_BLOCK_NAMES.component.id].includes(currentItemData.category) + } /> {getVisibilityMessage()} diff --git a/src/course-unit/header-title/HeaderTitle.test.jsx b/src/course-unit/header-title/HeaderTitle.test.jsx index 7e57c408e0..da8b5bfbad 100644 --- a/src/course-unit/header-title/HeaderTitle.test.jsx +++ b/src/course-unit/header-title/HeaderTitle.test.jsx @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { render } from '@testing-library/react'; +import { render, waitFor } 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'; @@ -60,9 +60,11 @@ describe('', () => { it('render HeaderTitle component correctly', () => { const { getByText, getByRole } = renderComponent(); - expect(getByText(unitTitle)).toBeInTheDocument(); - expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument(); + waitFor(() => { + expect(getByText(unitTitle)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument(); + }); }); it('render HeaderTitle with open edit form', () => { @@ -70,18 +72,22 @@ describe('', () => { isTitleEditFormOpen: true, }); - expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toBeInTheDocument(); - expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toHaveValue(unitTitle); - expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument(); + waitFor(() => { + expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toBeInTheDocument(); + expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toHaveValue(unitTitle); + expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument(); + }); }); it('calls toggle edit title form by clicking on Edit button', () => { const { getByRole } = renderComponent(); - const editTitleButton = getByRole('button', { name: messages.altButtonEdit.defaultMessage }); - userEvent.click(editTitleButton); - expect(handleTitleEdit).toHaveBeenCalledTimes(1); + waitFor(() => { + const editTitleButton = getByRole('button', { name: messages.altButtonEdit.defaultMessage }); + userEvent.click(editTitleButton); + expect(handleTitleEdit).toHaveBeenCalledTimes(1); + }); }); it('calls saving title by clicking outside or press Enter key', async () => { @@ -89,16 +95,18 @@ describe('', () => { isTitleEditFormOpen: true, }); - const titleField = getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage }); - userEvent.type(titleField, ' 1'); - expect(titleField).toHaveValue(`${unitTitle} 1`); - userEvent.click(document.body); - expect(handleTitleEditSubmit).toHaveBeenCalledTimes(1); - - userEvent.click(titleField); - userEvent.type(titleField, ' 2[Enter]'); - expect(titleField).toHaveValue(`${unitTitle} 1 2`); - expect(handleTitleEditSubmit).toHaveBeenCalledTimes(2); + waitFor(() => { + const titleField = getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage }); + userEvent.type(titleField, ' 1'); + expect(titleField).toHaveValue(`${unitTitle} 1`); + userEvent.click(document.body); + expect(handleTitleEditSubmit).toHaveBeenCalledTimes(1); + + userEvent.click(titleField); + userEvent.type(titleField, ' 2[Enter]'); + expect(titleField).toHaveValue(`${unitTitle} 1 2`); + expect(handleTitleEditSubmit).toHaveBeenCalledTimes(2); + }); }); it('displays a visibility message with the selected groups for the unit', async () => { @@ -108,7 +116,7 @@ describe('', () => { ...courseUnitIndexMock, user_partition_info: { ...courseUnitIndexMock.user_partition_info, - selected_partition_index: '1', + selected_partition_index: 1, selected_groups_label: 'Visibility group 1', }, }); @@ -117,7 +125,9 @@ describe('', () => { const visibilityMessage = messages.definedVisibilityMessage.defaultMessage .replace('{selectedGroupsLabel}', 'Visibility group 1'); - expect(getByText(visibilityMessage)).toBeInTheDocument(); + waitFor(() => { + expect(getByText(visibilityMessage)).toBeInTheDocument(); + }); }); it('displays a visibility message with the selected groups for some of xblock', async () => { @@ -130,6 +140,8 @@ describe('', () => { await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch); const { getByText } = renderComponent(); - expect(getByText(messages.commonVisibilityMessage.defaultMessage)).toBeInTheDocument(); + waitFor(() => { + expect(getByText(messages.someVisibilityMessage.defaultMessage)).toBeInTheDocument(); + }); }); }); diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 11731cc2ad..de55705c1c 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -1,42 +1,44 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useToggle } from '@openedx/paragon'; import { RequestStatus } from '../data/constants'; import { useCopyToClipboard } from '../generic/clipboard'; +import { useEventListener } from '../generic/hooks'; +import { COURSE_BLOCK_NAMES } from '../constants'; +import { messageTypes, PUBLISH_TYPES } from './constants'; import { createNewCourseXBlock, - fetchCourseUnitQuery, - editCourseItemQuery, - fetchCourseSectionVerticalData, - fetchCourseVerticalChildrenData, deleteUnitItemQuery, duplicateUnitItemQuery, + editCourseItemQuery, editCourseUnitVisibilityAndData, + fetchCourseSectionVerticalData, + fetchCourseUnitQuery, + fetchCourseVerticalChildrenData, getCourseOutlineInfoQuery, patchUnitItemQuery, } from './data/thunk'; import { + getCanEdit, + getCourseOutlineInfo, getCourseSectionVertical, - getCourseVerticalChildren, getCourseUnitData, + getCourseVerticalChildren, + getErrorMessage, getIsLoading, + getMovedXBlockParams, getSavingStatus, - getErrorMessage, getSequenceStatus, getStaticFileNotices, - getCanEdit, - getCourseOutlineInfo, - getMovedXBlockParams, } from './data/selectors'; import { changeEditTitleFormOpen, - updateQueryPendingStatus, updateMovedXBlockParams, + updateQueryPendingStatus, } from './data/slice'; import { useIframe } from './context/hooks'; -import { messageTypes, PUBLISH_TYPES } from './constants'; export const useCourseUnit = ({ courseId, blockId }) => { const dispatch = useDispatch(); @@ -49,7 +51,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { const isLoading = useSelector(getIsLoading); const errorMessage = useSelector(getErrorMessage); const sequenceStatus = useSelector(getSequenceStatus); - const { draftPreviewLink, publishedPreviewLink } = useSelector(getCourseSectionVertical); + const { draftPreviewLink, publishedPreviewLink, xblockInfo = {} } = useSelector(getCourseSectionVertical); const courseVerticalChildren = useSelector(getCourseVerticalChildren); const staticFileNotices = useSelector(getStaticFileNotices); const navigate = useNavigate(); @@ -60,9 +62,10 @@ export const useCourseUnit = ({ courseId, blockId }) => { const { currentlyVisibleToStudents } = courseUnit; const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(canEdit); const { canPasteComponent } = courseVerticalChildren; - - const unitTitle = courseUnit.metadata?.displayName || ''; + const { displayName: unitTitle, category: unitCategory } = xblockInfo; const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id; + const isUnitVerticalType = unitCategory === COURSE_BLOCK_NAMES.vertical.id; + const isUnitLibraryType = unitCategory === COURSE_BLOCK_NAMES.libraryContent.id; const headerNavigationsActions = { handleViewLive: () => { @@ -71,6 +74,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { handlePreview: () => { window.open(draftPreviewLink, '_blank'); }, + handleEdit: () => {}, }; const handleTitleEdit = () => { @@ -86,7 +90,9 @@ export const useCourseUnit = ({ courseId, blockId }) => { isDiscussionEnabled, blockId, )); - closeModalFn(); + if (typeof closeModalFn === 'function') { + closeModalFn(); + } }; const handleTitleEditSubmit = (displayName) => { @@ -150,6 +156,17 @@ export const useCourseUnit = ({ courseId, blockId }) => { navigate(`/course/${courseId}/container/${movedXBlockParams.targetParentLocator}`); }; + const receiveMessage = useCallback(({ data }) => { + const { payload, type } = data; + + if (type === messageTypes.handleViewXBlockContent) { + const { usageId } = payload; + navigate(`/course/${courseId}/container/${usageId}/${sequenceId}`); + } + }, [courseId, sequenceId]); + + useEventListener('message', receiveMessage); + useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { dispatch(updateQueryPendingStatus(true)); @@ -175,6 +192,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { sequenceId, courseUnit, unitTitle, + unitCategory, errorMessage, sequenceStatus, savingStatus, @@ -182,6 +200,8 @@ export const useCourseUnit = ({ courseId, blockId }) => { currentlyVisibleToStudents, isLoading, isTitleEditFormOpen, + isUnitVerticalType, + isUnitLibraryType, sharedClipboardData, showPasteXBlock, showPasteUnit, @@ -202,3 +222,35 @@ export const useCourseUnit = ({ courseId, blockId }) => { handleNavigateToTargetUnit, }; }; + +/** + * Custom hook to determine the layout grid configuration based on unit category and type. + * + * @param {string} unitCategory - The category of the unit. This may influence future layout logic. + * @param {boolean} isUnitLibraryType - A flag indicating whether the unit is of library content type. + * @returns {Object} - An object representing the layout configuration for different screen sizes. + * The configuration includes keys like 'lg', 'md', 'sm', 'xs', and 'xl', + * each specifying an array of layout spans. + */ +export const useLayoutGrid = (unitCategory, isUnitLibraryType) => ( + useMemo(() => { + const layouts = { + fullWidth: { + lg: [{ span: 12 }, { span: 0 }], + md: [{ span: 12 }, { span: 0 }], + sm: [{ span: 12 }, { span: 0 }], + xs: [{ span: 12 }, { span: 0 }], + xl: [{ span: 12 }, { span: 0 }], + }, + default: { + lg: [{ span: 8 }, { span: 4 }], + md: [{ span: 8 }, { span: 4 }], + sm: [{ span: 8 }, { span: 3 }], + xs: [{ span: 9 }, { span: 3 }], + xl: [{ span: 9 }, { span: 3 }], + }, + }; + + return isUnitLibraryType ? layouts.fullWidth : layouts.default; + }, [unitCategory]) +); diff --git a/src/course-unit/move-modal/index.tsx b/src/course-unit/move-modal/index.tsx index 7844d7c310..220e1320f1 100644 --- a/src/course-unit/move-modal/index.tsx +++ b/src/course-unit/move-modal/index.tsx @@ -102,6 +102,7 @@ const MoveModal: FC = ({ onClose={handleCLoseModal} size="xl" className="move-xblock-modal" + title={intl.formatMessage(messages.moveModalTitle, { displayName })} hasCloseButton isFullscreenOnMobile > diff --git a/src/course-unit/move-modal/moveModal.test.tsx b/src/course-unit/move-modal/moveModal.test.tsx index ba94e018a9..6080a8c42e 100644 --- a/src/course-unit/move-modal/moveModal.test.tsx +++ b/src/course-unit/move-modal/moveModal.test.tsx @@ -4,8 +4,8 @@ import { AppProvider } from '@edx/frontend-platform/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import userEvent from '@testing-library/user-event'; +import userEvent from '@testing-library/user-event'; import initializeStore from '../../store'; import { getCourseOutlineInfoUrl } from '../data/api'; import { courseOutlineInfoMock } from '../__mocks__'; @@ -79,7 +79,9 @@ describe('', () => { const categoryIndicator: HTMLElement = getByTestId('move-xblock-modal-category'); expect(getByText(messages.moveModalTitle.defaultMessage.replace(' {displayName}', ''))).toBeInTheDocument(); - expect(within(breadcrumbs).getByText(messages.moveModalBreadcrumbsBaseCategory.defaultMessage)).toBeInTheDocument(); + expect( + within(breadcrumbs).getByText(messages.moveModalBreadcrumbsBaseCategory.defaultMessage), + ).toBeInTheDocument(); expect( within(categoryIndicator).getByText(messages.moveModalBreadcrumbsSections.defaultMessage), ).toBeInTheDocument(); @@ -95,7 +97,9 @@ describe('', () => { const breadcrumbs: HTMLElement = getByTestId('move-xblock-modal-breadcrumbs'); const categoryIndicator: HTMLElement = getByTestId('move-xblock-modal-category'); - expect(within(breadcrumbs).getByText(messages.moveModalBreadcrumbsBaseCategory.defaultMessage)).toBeInTheDocument(); + expect( + within(breadcrumbs).getByText(messages.moveModalBreadcrumbsBaseCategory.defaultMessage), + ).toBeInTheDocument(); expect( within(categoryIndicator).getByText(messages.moveModalBreadcrumbsSections.defaultMessage), ).toBeInTheDocument(); diff --git a/src/course-unit/preview-changes/index.tsx b/src/course-unit/preview-changes/index.tsx index dc39755183..87acd22659 100644 --- a/src/course-unit/preview-changes/index.tsx +++ b/src/course-unit/preview-changes/index.tsx @@ -108,6 +108,7 @@ const PreviewLibraryXBlockChanges = () => { isOpen={isModalOpen} onClose={closeModal} size="xl" + title={getTitle()} className="lib-preview-xblock-changes-modal" hasCloseButton isFullscreenOnMobile diff --git a/src/course-unit/sidebar/PublishControls.jsx b/src/course-unit/sidebar/PublishControls.jsx index 424594f35b..0ef08baf28 100644 --- a/src/course-unit/sidebar/PublishControls.jsx +++ b/src/course-unit/sidebar/PublishControls.jsx @@ -4,9 +4,10 @@ import { useToggle } from '@openedx/paragon'; import { InfoOutline as InfoOutlineIcon } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import useCourseUnitData from './hooks'; +import { useIframe } from '../context/hooks'; import { editCourseUnitVisibilityAndData } from '../data/thunk'; import { SidebarBody, SidebarFooter, SidebarHeader } from './components'; -import { PUBLISH_TYPES } from '../constants'; +import { PUBLISH_TYPES, messageTypes } from '../constants'; import { getCourseUnitData } from '../data/selectors'; import messages from './messages'; import ModalNotification from '../../generic/modal-notification'; @@ -20,6 +21,7 @@ const PublishControls = ({ blockId }) => { visibleToStaffOnly, } = useCourseUnitData(useSelector(getCourseUnitData)); const intl = useIntl(); + const { sendMessageToIframe } = useIframe(); const [isDiscardModalOpen, openDiscardModal, closeDiscardModal] = useToggle(false); const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false); @@ -34,6 +36,11 @@ const PublishControls = ({ blockId }) => { const handleCourseUnitDiscardChanges = () => { closeDiscardModal(); dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.discardChanges)); + // TODO: this artificial delay is a temporary solution + // to ensure the iframe content is properly refreshed. + setTimeout(() => { + sendMessageToIframe(messageTypes.refreshXBlock, null); + }, 1000); }; const handleCourseUnitPublish = () => { diff --git a/src/course-unit/sidebar/components/sidebar-footer/index.jsx b/src/course-unit/sidebar/components/sidebar-footer/index.jsx index ee1e816bad..62af6c672b 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/index.jsx +++ b/src/course-unit/sidebar/components/sidebar-footer/index.jsx @@ -43,9 +43,9 @@ const SidebarFooter = ({ SidebarFooter.propTypes = { locationId: PropTypes.string, displayUnitLocation: PropTypes.bool, - openDiscardModal: PropTypes.func.isRequired, - openVisibleModal: PropTypes.func.isRequired, - handlePublishing: PropTypes.func.isRequired, + openDiscardModal: PropTypes.func, + openVisibleModal: PropTypes.func, + handlePublishing: PropTypes.func, visibleToStaffOnly: PropTypes.bool.isRequired, }; diff --git a/src/course-unit/utils.test.ts b/src/course-unit/utils.test.ts new file mode 100644 index 0000000000..ab45700c74 --- /dev/null +++ b/src/course-unit/utils.test.ts @@ -0,0 +1,25 @@ +import { adoptCourseSectionUrl } from './utils'; + +describe('adoptCourseSectionUrl', () => { + it('should transform container URL correctly', () => { + const params = { + courseId: 'some-course-id', + parentUnitId: 'some-sequence-id', + unitId: 'some-unit-id', + url: '/container/some-unit-id', + }; + const result = adoptCourseSectionUrl(params); + expect(result).toBe(`/course/${params.courseId}/container/${params.unitId}/${params.parentUnitId}`); + }); + + it('should return original URL if no transformation is applied', () => { + const params = { + courseId: 'some-course-id', + parentUnitId: 'some-sequence-id', + unitId: 'some-unit-id', + url: '/some/other/url', + }; + const result = adoptCourseSectionUrl(params); + expect(result).toBe('/some/other/url'); + }); +}); diff --git a/src/course-unit/utils.ts b/src/course-unit/utils.ts new file mode 100644 index 0000000000..08c009994e --- /dev/null +++ b/src/course-unit/utils.ts @@ -0,0 +1,30 @@ +/** + * Adapts API URL paths to the application's internal URL format based on predefined conditions. + * + * @param {Object} params - Parameters for URL adaptation. + * @param {string} params.url - The original API URL to transform. + * @param {string} params.courseId - The course ID. + * @param {string} params.parentUnitId - The sequence ID. + * @returns {string} - A correctly formatted internal route for the application. + */ +export const adoptCourseSectionUrl = ( + { url, courseId, parentUnitId }: { url: string, courseId: string, parentUnitId: string }, +): string => { + let newUrl = url; + const urlConditions = [ + { + regex: /^\/container\/(.+)/, + transform: (unitId: string) => `/course/${courseId}/container/${unitId}/${parentUnitId}`, + }, + ]; + + for (const { regex, transform } of urlConditions) { + const match = regex.exec(url); + if (match?.[1]) { + newUrl = transform(match[1]); + break; + } + } + + return newUrl; +}; diff --git a/src/course-unit/xblock-container-iframe/hooks/index.ts b/src/course-unit/xblock-container-iframe/hooks/index.ts new file mode 100644 index 0000000000..c49993dc1e --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/index.ts @@ -0,0 +1,5 @@ +export { useIframeMessages } from './useIframeMessages'; +export { useIframeContent } from './useIframeContent'; +export { useMessageHandlers } from './useMessageHandlers'; +export { useIFrameBehavior } from './useIFrameBehavior'; +export { useLoadBearingHook } from './useLoadBearingHook'; diff --git a/src/course-unit/xblock-container-iframe/tests/hooks.test.tsx b/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx similarity index 97% rename from src/course-unit/xblock-container-iframe/tests/hooks.test.tsx rename to src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx index 13b5467622..8883efb04d 100644 --- a/src/course-unit/xblock-container-iframe/tests/hooks.test.tsx +++ b/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx @@ -3,8 +3,8 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useKeyedState } from '@edx/react-unit-test-utils'; import { logError } from '@edx/frontend-platform/logging'; -import { stateKeys, messageTypes } from '../../constants'; -import { useIFrameBehavior, useLoadBearingHook } from '../hooks'; +import { stateKeys, messageTypes } from '../../../constants'; +import { useLoadBearingHook, useIFrameBehavior } from '..'; jest.mock('@edx/react-unit-test-utils', () => ({ useKeyedState: jest.fn(), diff --git a/src/course-unit/xblock-container-iframe/hooks/types.ts b/src/course-unit/xblock-container-iframe/hooks/types.ts new file mode 100644 index 0000000000..3974656c49 --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/types.ts @@ -0,0 +1,25 @@ +export type UseMessageHandlersTypes = { + courseId: string; + navigate: (path: string) => void; + dispatch: (action: any) => void; + setIframeOffset: (height: number) => void; + handleDeleteXBlock: (usageId: string) => void; + handleRefetchXBlocks: () => void; + handleDuplicateXBlock: (blockType: string, usageId: string) => void; + handleManageXBlockAccess: (usageId: string) => void; +}; + +export type MessageHandlersTypes = Record void>; + +export interface UseIFrameBehaviorTypes { + id: string; + iframeUrl: string; + onLoaded?: boolean; +} + +export interface UseIFrameBehaviorReturnTypes { + iframeHeight: number; + handleIFrameLoad: () => void; + showError: boolean; + hasLoaded: boolean; +} diff --git a/src/course-unit/xblock-container-iframe/hooks.tsx b/src/course-unit/xblock-container-iframe/hooks/useIFrameBehavior.tsx similarity index 58% rename from src/course-unit/xblock-container-iframe/hooks.tsx rename to src/course-unit/xblock-container-iframe/hooks/useIFrameBehavior.tsx index 1a81e7852a..832ac94cd3 100644 --- a/src/course-unit/xblock-container-iframe/hooks.tsx +++ b/src/course-unit/xblock-container-iframe/hooks/useIFrameBehavior.tsx @@ -1,57 +1,12 @@ -import { - useState, useLayoutEffect, useCallback, useEffect, -} from 'react'; +import { useCallback, useEffect } from 'react'; import { logError } from '@edx/frontend-platform/logging'; // eslint-disable-next-line import/no-extraneous-dependencies import { useKeyedState } from '@edx/react-unit-test-utils'; -import { useEventListener } from '../../generic/hooks'; -import { stateKeys, messageTypes } from '../constants'; - -interface UseIFrameBehaviorParams { - id: string; - iframeUrl: string; - onLoaded?: boolean; -} - -interface UseIFrameBehaviorReturn { - iframeHeight: number; - handleIFrameLoad: () => void; - showError: boolean; - hasLoaded: boolean; -} - -/** - * We discovered an error in Firefox where - upon iframe load - React would cease to call any - * useEffect hooks until the user interacts with the page again. This is particularly confusing - * when navigating between sequences, as the UI partially updates leaving the user in a nebulous - * state. - * - * We were able to solve this error by using a layout effect to update some component state, which - * executes synchronously on render. Somehow this forces React to continue it's lifecycle - * immediately, rather than waiting for user interaction. This layout effect could be anywhere in - * the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's - * a joke) one here so it wouldn't be accidentally removed elsewhere. - * - * If we remove this hook when one of these happens: - * 1. React figures out that there's an issue here and fixes a bug. - * 2. We cease to use an iframe for unit rendering. - * 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug. - * 4. We stop supporting Firefox. - * 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to - * Firefox/React for review, and they kindly help us figure out what in the world is happening - * so we can fix it. - * - * This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If - * we change whether or not the Unit component is re-mounted when the unit ID changes, this may - * become important, as this hook will otherwise only evaluate the useLayoutEffect once. - */ -export const useLoadBearingHook = (id: string): void => { - const setValue = useState(0)[1]; - useLayoutEffect(() => { - setValue(currentValue => currentValue + 1); - }, [id]); -}; +import { useEventListener } from '../../../generic/hooks'; +import { stateKeys, messageTypes } from '../../constants'; +import { useLoadBearingHook } from './useLoadBearingHook'; +import { UseIFrameBehaviorTypes, UseIFrameBehaviorReturnTypes } from './types'; /** * Custom hook to manage iframe behavior. @@ -70,7 +25,7 @@ export const useIFrameBehavior = ({ id, iframeUrl, onLoaded = true, -}: UseIFrameBehaviorParams): UseIFrameBehaviorReturn => { +}: UseIFrameBehaviorTypes): UseIFrameBehaviorReturnTypes => { // Do not remove this hook. See function description. useLoadBearingHook(id); diff --git a/src/course-unit/xblock-container-iframe/hooks/useIframeContent.tsx b/src/course-unit/xblock-container-iframe/hooks/useIframeContent.tsx new file mode 100644 index 0000000000..abbc98b212 --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/useIframeContent.tsx @@ -0,0 +1,33 @@ +import { useEffect, useCallback, RefObject } from 'react'; + +import { messageTypes } from '../../constants'; + +/** + * Hook for managing iframe content and providing utilities to interact with the iframe. + * + * @param {React.RefObject} iframeRef - A React ref for the iframe element. + * @param {(ref: React.RefObject) => void} setIframeRef - + * A function to associate the iframeRef with the parent context. + * @param {(type: string, payload: any) => void} sendMessageToIframe - A function to send messages to the iframe. + * + * @returns {Object} - An object containing utility functions. + * @returns {() => void} return.refreshIframeContent - + * A function to refresh the iframe content by sending a specific message. + */ +export const useIframeContent = ( + iframeRef: RefObject, + setIframeRef: (ref: RefObject) => void, + sendMessageToIframe: (type: string, payload: any) => void, +): { refreshIframeContent: () => void } => { + useEffect(() => { + setIframeRef(iframeRef); + }, [setIframeRef, iframeRef]); + + // TODO: this artificial delay is a temporary solution + // to ensure the iframe content is properly refreshed. + const refreshIframeContent = useCallback(() => { + setTimeout(() => sendMessageToIframe(messageTypes.refreshXBlock, null), 1000); + }, [sendMessageToIframe]); + + return { refreshIframeContent }; +}; diff --git a/src/course-unit/xblock-container-iframe/hooks/useIframeMessages.tsx b/src/course-unit/xblock-container-iframe/hooks/useIframeMessages.tsx new file mode 100644 index 0000000000..6f192615da --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/useIframeMessages.tsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +/** + * Hook for managing and handling messages received by the iframe. + * + * @param {Record void>} messageHandlers - + * A mapping of message types to their corresponding handler functions. + */ +export const useIframeMessages = (messageHandlers: Record void>) => { + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const { type, payload } = event.data || {}; + if (type in messageHandlers) { + messageHandlers[type](payload); + } + }; + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [messageHandlers]); +}; diff --git a/src/course-unit/xblock-container-iframe/hooks/useLoadBearingHook.tsx b/src/course-unit/xblock-container-iframe/hooks/useLoadBearingHook.tsx new file mode 100644 index 0000000000..73a38b0223 --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/useLoadBearingHook.tsx @@ -0,0 +1,33 @@ +import { useLayoutEffect, useState } from 'react'; + +/** + * We discovered an error in Firefox where - upon iframe load - React would cease to call any + * useEffect hooks until the user interacts with the page again. This is particularly confusing + * when navigating between sequences, as the UI partially updates leaving the user in a nebulous + * state. + * + * We were able to solve this error by using a layout effect to update some component state, which + * executes synchronously on render. Somehow this forces React to continue it's lifecycle + * immediately, rather than waiting for user interaction. This layout effect could be anywhere in + * the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's + * a joke) one here so it wouldn't be accidentally removed elsewhere. + * + * If we remove this hook when one of these happens: + * 1. React figures out that there's an issue here and fixes a bug. + * 2. We cease to use an iframe for unit rendering. + * 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug. + * 4. We stop supporting Firefox. + * 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to + * Firefox/React for review, and they kindly help us figure out what in the world is happening + * so we can fix it. + * + * This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If + * we change whether or not the Unit component is re-mounted when the unit ID changes, this may + * become important, as this hook will otherwise only evaluate the useLayoutEffect once. + */ +export const useLoadBearingHook = (id: string): void => { + const setValue = useState(0)[1]; + useLayoutEffect(() => { + setValue(currentValue => currentValue + 1); + }, [id]); +}; diff --git a/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx b/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx new file mode 100644 index 0000000000..974f7bf0c6 --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx @@ -0,0 +1,38 @@ +import { useMemo } from 'react'; + +import { copyToClipboard } from '../../../generic/data/thunks'; +import { messageTypes } from '../../constants'; +import { MessageHandlersTypes, UseMessageHandlersTypes } from './types'; + +/** + * Hook for creating message handlers used to handle iframe messages. + * + * @param params - The parameters required to create message handlers. + * @returns {MessageHandlersTypes} - An object mapping message types to their handler functions. + */ +export const useMessageHandlers = ({ + courseId, + navigate, + dispatch, + setIframeOffset, + handleDeleteXBlock, + handleRefetchXBlocks, + handleDuplicateXBlock, + handleManageXBlockAccess, +}: UseMessageHandlersTypes): MessageHandlersTypes => useMemo(() => ({ + [messageTypes.copyXBlock]: ({ usageId }) => dispatch(copyToClipboard(usageId)), + [messageTypes.deleteXBlock]: ({ usageId }) => handleDeleteXBlock(usageId), + [messageTypes.newXBlockEditor]: ({ blockType, usageId }) => navigate(`/course/${courseId}/editor/${blockType}/${usageId}`), + [messageTypes.duplicateXBlock]: ({ blockType, usageId }) => handleDuplicateXBlock(blockType, usageId), + [messageTypes.manageXBlockAccess]: ({ usageId }) => handleManageXBlockAccess(usageId), + [messageTypes.refreshXBlockPositions]: handleRefetchXBlocks, + [messageTypes.toggleCourseXBlockDropdown]: ({ + courseXBlockDropdownHeight, + }: { courseXBlockDropdownHeight: number }) => setIframeOffset(courseXBlockDropdownHeight), +}), [ + courseId, + handleDeleteXBlock, + handleRefetchXBlocks, + handleDuplicateXBlock, + handleManageXBlockAccess, +]); diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index 761d637750..9b47a32d07 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -1,57 +1,150 @@ -import { useRef, useEffect, FC } from 'react'; -import PropTypes from 'prop-types'; +import { + useRef, FC, useEffect, useState, useMemo, useCallback, +} from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { getConfig } from '@edx/frontend-platform'; +import { useToggle } from '@openedx/paragon'; +import { useDispatch } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; -import { IFRAME_FEATURE_POLICY } from '../constants'; +import DeleteModal from '../../generic/delete-modal/DeleteModal'; +import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; +import { IFRAME_FEATURE_POLICY } from '../../constants'; +import supportedEditors from '../../editors/supportedEditors'; +import { fetchCourseUnitQuery } from '../data/thunk'; import { useIframe } from '../context/hooks'; -import { useIFrameBehavior } from './hooks'; +import { + useMessageHandlers, + useIframeContent, + useIframeMessages, + useIFrameBehavior, +} from './hooks'; +import { formatAccessManagedXBlockData, getIframeUrl } from './utils'; import messages from './messages'; -/** - * This offset is necessary to fully display the dropdown actions of the XBlock - * in case the XBlock does not have content inside. - */ -const IFRAME_BOTTOM_OFFSET = 220; +import { + XBlockContainerIframeProps, + AccessManagedXBlockDataTypes, +} from './types'; -interface XBlockContainerIframeProps { - blockId: string; -} - -const XBlockContainerIframe: FC = ({ blockId }) => { +const XBlockContainerIframe: FC = ({ + courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, +}) => { const intl = useIntl(); const iframeRef = useRef(null); - const { setIframeRef } = useIframe(); + const dispatch = useDispatch(); + const navigate = useNavigate(); - const iframeUrl = `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`; + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); + const [accessManagedXBlockData, setAccessManagedXBlockData] = useState({}); + const [iframeOffset, setIframeOffset] = useState(0); + const [deleteXBlockId, setDeleteXBlockId] = useState(null); + const [configureXBlockId, setConfigureXBlockId] = useState(null); - const { iframeHeight } = useIFrameBehavior({ - id: blockId, - iframeUrl, - }); + const iframeUrl = useMemo(() => getIframeUrl(blockId), [blockId]); + + const { setIframeRef, sendMessageToIframe } = useIframe(); + const { iframeHeight } = useIFrameBehavior({ id: blockId, iframeUrl }); + const { refreshIframeContent } = useIframeContent(iframeRef, setIframeRef, sendMessageToIframe); useEffect(() => { setIframeRef(iframeRef); }, [setIframeRef]); - return ( -