diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 2b54f876e6..a2c585522d 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; @@ -6,9 +6,7 @@ import { Container, Layout, Stack } from '@openedx/paragon'; import { getConfig } from '@edx/frontend-platform'; import { useIntl, injectIntl } from '@edx/frontend-platform/i18n'; import { Warning as WarningIcon } from '@openedx/paragon/icons'; -import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import DraggableList from '../editors/sharedComponents/DraggableList'; import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; import SubHeader from '../generic/sub-header/SubHeader'; import { RequestStatus } from '../data/constants'; @@ -20,7 +18,6 @@ import { SavingErrorAlert } from '../generic/saving-error-alert'; import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; import Loading from '../generic/Loading'; import AddComponent from './add-component/AddComponent'; -import CourseXBlock from './course-xblock/CourseXBlock'; import HeaderTitle from './header-title/HeaderTitle'; import Breadcrumbs from './breadcrumbs/Breadcrumbs'; import HeaderNavigations from './header-navigations/HeaderNavigations'; @@ -32,6 +29,7 @@ import PublishControls from './sidebar/PublishControls'; import LocationInfo from './sidebar/LocationInfo'; import TagsSidebarControls from '../content-tags-drawer/tags-sidebar-controls'; import { PasteNotificationAlert } from './clipboard'; +import XBlockContainerIframe from './xblock-container-iframe'; const CourseUnit = ({ courseId }) => { const { blockId } = useParams(); @@ -56,21 +54,13 @@ const CourseUnit = ({ courseId }) => { handleCreateNewCourseXBlock, handleConfigureSubmit, courseVerticalChildren, - handleXBlockDragAndDrop, canPasteComponent, } = useCourseUnit({ courseId, blockId }); - const initialXBlocksData = useMemo(() => courseVerticalChildren.children ?? [], [courseVerticalChildren.children]); - const [unitXBlocks, setUnitXBlocks] = useState(initialXBlocksData); - useEffect(() => { document.title = getPageHeadTitle('', unitTitle); }, [unitTitle]); - useEffect(() => { - setUnitXBlocks(courseVerticalChildren.children); - }, [courseVerticalChildren.children]); - const { isShow: isShowProcessingNotification, title: processingNotificationTitle, @@ -88,12 +78,6 @@ const CourseUnit = ({ courseId }) => { ); } - const finalizeXBlockOrder = () => (newXBlocks) => { - handleXBlockDragAndDrop(newXBlocks.map(xBlock => xBlock.id), () => { - setUnitXBlocks(initialXBlocksData); - }); - }; - return ( <> @@ -147,37 +131,11 @@ const CourseUnit = ({ courseId }) => { courseId={courseId} /> )} - - - - {unitXBlocks.map(({ - name, id, blockType: type, shouldScroll, userPartitionInfo, validationMessages, - }) => ( - - ))} - - - + ', () => { }); }); - it('checks whether xblock is deleted when corresponding delete button is clicked', async () => { - axiosMock - .onDelete(getXBlockBaseApiUrl(courseVerticalChildrenMock.children[0].block_id)) - .replyOnce(200, { dummy: 'value' }); - - const { - getByText, - getAllByLabelText, - getByRole, - getAllByTestId, - } = render(); - - await waitFor(() => { - expect(getByText(unitDisplayName)).toBeInTheDocument(); - const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); - userEvent.click(xblockActionBtn); - - const deleteBtn = getByRole('button', { name: courseXBlockMessages.blockLabelButtonDelete.defaultMessage }); - userEvent.click(deleteBtn); - expect(getByText(/Delete this component?/)).toBeInTheDocument(); - - const deleteConfirmBtn = getByRole('button', { name: deleteModalMessages.deleteButton.defaultMessage }); - userEvent.click(deleteConfirmBtn); - - expect(getAllByTestId('course-xblock')).toHaveLength(1); - }); - }); - - it('checks whether xblock is duplicate when corresponding delete button is clicked', async () => { - axiosMock - .onPost(postXBlockBaseApiUrl({ - parent_locator: blockId, - duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id, - })) - .replyOnce(200, { locator: '1234567890' }); - - axiosMock - .onGet(getCourseVerticalChildrenApiUrl(blockId)) - .reply(200, { - ...courseVerticalChildrenMock, - children: [ - ...courseVerticalChildrenMock.children, - { - name: 'New Cloned XBlock', - block_id: '1234567890', - block_type: 'drag-and-drop-v2', - user_partition_info: {}, - }, - ], - }); - - const { - getByText, - getAllByLabelText, - getAllByTestId, - } = render(); - - await waitFor(() => { - expect(getByText(unitDisplayName)).toBeInTheDocument(); - const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); - userEvent.click(xblockActionBtn); - - const duplicateBtn = getByText(courseXBlockMessages.blockLabelButtonDuplicate.defaultMessage); - userEvent.click(duplicateBtn); - - expect(getAllByTestId('course-xblock')).toHaveLength(3); - expect(getByText('New Cloned XBlock')).toBeInTheDocument(); - }); - }); - it('should toggle visibility from sidebar and update course unit state accordingly', async () => { const { getByRole, getByTestId } = render(); let courseUnitSidebar; @@ -792,189 +715,6 @@ describe('', () => { expect(discardChangesBtn).not.toBeInTheDocument(); }); - it('checks whether xblock is removed when the corresponding delete button is clicked and the sidebar is the updated', async () => { - const { - getByText, - getAllByLabelText, - getByRole, - getAllByTestId, - queryByRole, - } = render(); - - await waitFor(() => { - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); - }); - - 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); - - axiosMock - .onDelete(getXBlockBaseApiUrl(courseVerticalChildrenMock.children[0].block_id)) - .replyOnce(200, { dummy: 'value' }); - - await executeThunk(deleteUnitItemQuery(courseId, blockId), 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(); - const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); - userEvent.click(xblockActionBtn); - - const deleteBtn = getByRole('button', { name: courseXBlockMessages.blockLabelButtonDelete.defaultMessage }); - userEvent.click(deleteBtn); - expect(getByText(/Delete this component?/)).toBeInTheDocument(); - - const deleteConfirmBtn = getByRole('button', { name: deleteModalMessages.deleteButton.defaultMessage }); - userEvent.click(deleteConfirmBtn); - - expect(getAllByTestId('course-xblock')).toHaveLength(1); - }); - - axiosMock - .onGet(getCourseUnitApiUrl(blockId)) - .reply(200, courseUnitIndexMock); - - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); - - // 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 () => { - axiosMock - .onPost(postXBlockBaseApiUrl({ - parent_locator: blockId, - duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id, - })) - .replyOnce(200, { locator: '1234567890' }); - - axiosMock - .onGet(getCourseVerticalChildrenApiUrl(blockId)) - .reply(200, { - ...courseVerticalChildrenMock, - children: [ - ...courseVerticalChildrenMock.children, - { - ...courseVerticalChildrenMock.children[0], - name: 'New Cloned XBlock', - }, - ], - }); - - const { - getByText, - getAllByLabelText, - getAllByTestId, - queryByRole, - getByRole, - } = render(); - - await waitFor(() => { - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); - }); - - 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(); - const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); - userEvent.click(xblockActionBtn); - - const duplicateBtn = getByText(courseXBlockMessages.blockLabelButtonDuplicate.defaultMessage); - userEvent.click(duplicateBtn); - - expect(getAllByTestId('course-xblock')).toHaveLength(3); - expect(getByText('New Cloned XBlock')).toBeInTheDocument(); - }); - - axiosMock - .onGet(getCourseUnitApiUrl(blockId)) - .reply(200, courseUnitIndexMock); - - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); - - // 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('should toggle visibility from header configure modal and update course unit state accordingly', async () => { const { getByRole, getByTestId } = render(); let courseUnitSidebar; @@ -1063,159 +803,6 @@ describe('', () => { }); describe('Copy paste functionality', () => { - it('should display "Copy Unit" action button after enabling copy-paste units', async () => { - const { queryByText, queryByRole } = render(); - - await waitFor(() => { - expect(queryByText(sidebarMessages.actionButtonCopyUnitTitle.defaultMessage)).toBeNull(); - expect(queryByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeNull(); - }); - - axiosMock - .onGet(getCourseUnitApiUrl(courseId)) - .reply(200, { - ...courseUnitIndexMock, - enable_copy_paste_units: true, - }); - - await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); - expect(queryByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })).toBeInTheDocument(); - }); - - it('should display clipboard information in popover when hovering over What\'s in clipboard text', async () => { - const { - queryByTestId, getByRole, getAllByLabelText, getByText, - } = render(); - - await waitFor(() => { - const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); - userEvent.click(xblockActionBtn); - userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage })); - }); - - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...courseSectionVerticalMock, - user_clipboard: clipboardXBlock, - }); - - await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument(); - - const whatsInClipboardText = getByText( - pasteComponentMessages.pasteButtonWhatsInClipboardText.defaultMessage, - ); - - userEvent.hover(whatsInClipboardText); - - const popoverContent = queryByTestId('popover-content'); - expect(popoverContent.tagName).toBe('A'); - expect(popoverContent).toHaveAttribute('href', clipboardXBlock.sourceEditUrl); - expect(within(popoverContent).getByText(clipboardXBlock.content.displayName)).toBeInTheDocument(); - expect(within(popoverContent).getByText(clipboardXBlock.sourceContextTitle)).toBeInTheDocument(); - expect(within(popoverContent).getByText(clipboardXBlock.content.blockTypeDisplay)).toBeInTheDocument(); - - fireEvent.blur(whatsInClipboardText); - await waitFor(() => expect(queryByTestId('popover-content')).toBeNull()); - - fireEvent.focus(whatsInClipboardText); - await waitFor(() => expect(queryByTestId('popover-content')).toBeInTheDocument()); - - fireEvent.mouseLeave(whatsInClipboardText); - await waitFor(() => expect(queryByTestId('popover-content')).toBeNull()); - - fireEvent.mouseEnter(whatsInClipboardText); - await waitFor(() => expect(queryByTestId('popover-content')).toBeInTheDocument()); - }); - - it('should increase the number of course XBlocks after copying and pasting a block', async () => { - const { - getAllByTestId, getByRole, getAllByLabelText, - } = render(); - - await waitFor(() => { - const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); - userEvent.click(xblockActionBtn); - userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage })); - }); - - 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(() => { - expect(getAllByTestId('course-xblock')).toHaveLength(2); - }); - - axiosMock - .onGet(getCourseVerticalChildrenApiUrl(blockId)) - .reply(200, { - ...courseVerticalChildrenMock, - children: [ - ...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: '', - }, - }, - ], - }); - - await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); - expect(getAllByTestId('course-xblock')).toHaveLength(3); - }); - - it('should display the "Paste component" button after copying a xblock to clipboard', async () => { - const { getByRole, getAllByLabelText } = render(); - - await waitFor(() => { - const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); - userEvent.click(xblockActionBtn); - userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage })); - }); - - 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); - - expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument(); - }); - it('should copy a unit, paste it as a new unit, and update the course section vertical data', async () => { const { getAllByTestId, getByRole, @@ -1474,56 +1061,4 @@ describe('', () => { )).not.toBeInTheDocument(); }); }); - - describe('Drag and drop', () => { - it('checks xblock list is restored to original order when API call fails', async () => { - const { findAllByRole } = render(); - - const xBlocksDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); - const draggableButton = xBlocksDraggers[1]; - - axiosMock - .onPut(getXBlockBaseApiUrl(blockId)) - .reply(500, { dummy: 'value' }); - - const xBlock1 = store.getState().courseUnit.courseVerticalChildren.children[0].id; - - fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); - - await waitFor(async () => { - fireEvent.keyDown(draggableButton, { code: 'Space' }); - - const saveStatus = store.getState().courseUnit.savingStatus; - expect(saveStatus).toEqual(RequestStatus.FAILED); - }); - - const xBlock1New = store.getState().courseUnit.courseVerticalChildren.children[0].id; - expect(xBlock1).toBe(xBlock1New); - }); - - it('check that new xblock list is saved when dragged', async () => { - const { findAllByRole } = render(); - - const xBlocksDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); - const draggableButton = xBlocksDraggers[1]; - - axiosMock - .onPut(getXBlockBaseApiUrl(blockId)) - .reply(200, { dummy: 'value' }); - - const xBlock1 = store.getState().courseUnit.courseVerticalChildren.children[0].id; - - fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); - - await waitFor(async () => { - fireEvent.keyDown(draggableButton, { code: 'Space' }); - - const saveStatus = store.getState().courseUnit.savingStatus; - expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL); - }); - - const xBlock2 = store.getState().courseUnit.courseVerticalChildren.children[1].id; - expect(xBlock1).toBe(xBlock2); - }); - }); }); diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js index 9ff040d63c..60b199c21c 100644 --- a/src/course-unit/constants.js +++ b/src/course-unit/constants.js @@ -38,3 +38,20 @@ export const getXBlockSupportMessages = (intl) => ({ tooltip: intl.formatMessage(addComponentMessages.modalComponentSupportTooltipNotSupported), }, }); + +export const stateKeys = { + iframeHeight: 'iframeHeight', + hasLoaded: 'hasLoaded', + showError: 'showError', + windowTopOffset: 'windowTopOffset', +}; + +export const messageTypes = { + modal: 'plugin.modal', + resize: 'plugin.resize', + videoFullScreen: 'plugin.videoFullScreen', +}; + +export const IFRAME_FEATURE_POLICY = ( + 'microphone *; camera *; midi *; geolocation *; encrypted-media *, clipboard-write *' +); diff --git a/src/course-unit/course-sequence/hooks.test.js b/src/course-unit/course-sequence/hooks.test.js new file mode 100644 index 0000000000..8ce0a730cd --- /dev/null +++ b/src/course-unit/course-sequence/hooks.test.js @@ -0,0 +1,57 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { useSequenceNavigationMetadata } from './hooks'; +import { getCourseSectionVertical, getSequenceIds } from '../data/selectors'; + +import { useModel } from '../../generic/model-store'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../generic/model-store', () => ({ + useModel: jest.fn(), +})); + +jest.mock('@openedx/paragon', () => ({ + useWindowSize: jest.fn(), +})); + +describe('useSequenceNavigationMetadata', () => { + const mockCourseId = 'course-v1:example'; + const mockCurrentSequenceId = 'sequence-1'; + const mockCurrentUnitId = 'unit-1'; + const mockNextUrl = '/next-url'; + const mockPrevUrl = '/prev-url'; + const mockSequenceIds = ['sequence-1', 'sequence-2']; + const mockSequence = { + unitIds: ['unit-1', 'unit-2'], + }; + + beforeEach(() => { + useSelector.mockImplementation((selector) => { + if (selector === getCourseSectionVertical) { return { nextUrl: mockNextUrl, prevUrl: mockPrevUrl }; } + if (selector === getSequenceIds) { return mockSequenceIds; } + return null; + }); + useModel.mockReturnValue(mockSequence); + }); + + it('sets isLastUnit to true if no nextUrl is provided', () => { + useSelector.mockReturnValueOnce({ nextUrl: null, prevUrl: mockPrevUrl }); + const { result } = renderHook( + () => useSequenceNavigationMetadata(mockCourseId, mockCurrentSequenceId, mockCurrentUnitId), + ); + + expect(result.current.isLastUnit).toBe(true); + }); + + it('sets isFirstUnit to true if no prevUrl is provided', () => { + useSelector.mockReturnValueOnce({ nextUrl: mockNextUrl, prevUrl: null }); + const { result } = renderHook( + () => useSequenceNavigationMetadata(mockCourseId, mockCurrentSequenceId, mockCurrentUnitId), + ); + + expect(result.current.isFirstUnit).toBe(true); + }); +}); diff --git a/src/course-unit/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx deleted file mode 100644 index 89a13ece7a..0000000000 --- a/src/course-unit/course-xblock/CourseXBlock.jsx +++ /dev/null @@ -1,196 +0,0 @@ -import { useEffect, useRef } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { useDispatch, useSelector } from 'react-redux'; -import { - ActionRow, Card, Dropdown, Icon, IconButton, useToggle, -} from '@openedx/paragon'; -import { EditOutline as EditIcon, MoreVert as MoveVertIcon } from '@openedx/paragon/icons'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { useSearchParams } from 'react-router-dom'; - -import { getCanEdit, getCourseId } from 'CourseAuthoring/course-unit/data/selectors'; -import DeleteModal from '../../generic/delete-modal/DeleteModal'; -import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; -import SortableItem from '../../generic/drag-helper/SortableItem'; -import { scrollToElement } from '../../course-outline/utils'; -import { COURSE_BLOCK_NAMES } from '../../constants'; -import { copyToClipboard } from '../../generic/data/thunks'; -import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; -import XBlockMessages from './xblock-messages/XBlockMessages'; -import messages from './messages'; -import { createCorrectInternalRoute } from '../../utils'; - -const CourseXBlock = ({ - id, title, type, unitXBlockActions, shouldScroll, userPartitionInfo, - handleConfigureSubmit, validationMessages, ...props -}) => { - const courseXBlockElementRef = useRef(null); - const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); - const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); - const dispatch = useDispatch(); - const canEdit = useSelector(getCanEdit); - const courseId = useSelector(getCourseId); - const intl = useIntl(); - - const [searchParams] = useSearchParams(); - const locatorId = searchParams.get('show'); - const isScrolledToElement = locatorId === id; - - const visibilityMessage = userPartitionInfo.selectedGroupsLabel - ? intl.formatMessage(messages.visibilityMessage, { selectedGroupsLabel: userPartitionInfo.selectedGroupsLabel }) - : null; - - const currentItemData = { - category: COURSE_BLOCK_NAMES.component.id, - displayName: title, - userPartitionInfo, - showCorrectness: 'always', - }; - - const onDeleteSubmit = () => { - unitXBlockActions.handleDelete(id); - closeDeleteModal(); - }; - - const handleEdit = () => { - switch (type) { - case COMPONENT_TYPES.html: - case COMPONENT_TYPES.problem: - case COMPONENT_TYPES.video: - // Not using useNavigate from react router to use browser navigation - // which allows us to block back button if unsaved changes in editor are present. - window.location.assign( - createCorrectInternalRoute(`/course/${courseId}/editor/${type}/${id}`), - ); - break; - default: - } - }; - - const onConfigureSubmit = (...arg) => { - handleConfigureSubmit(id, ...arg, closeConfigureModal); - }; - - useEffect(() => { - // if this item has been newly added, scroll to it. - if (courseXBlockElementRef.current && (shouldScroll || isScrolledToElement)) { - scrollToElement(courseXBlockElementRef.current); - } - }, [isScrolledToElement]); - - return ( -
- - - - - - - unitXBlockActions.handleDuplicate(id)}> - {intl.formatMessage(messages.blockLabelButtonDuplicate)} - - - {intl.formatMessage(messages.blockLabelButtonMove)} - - {canEdit && ( - dispatch(copyToClipboard(id))}> - {intl.formatMessage(messages.blockLabelButtonCopyToClipboard)} - - )} - - {intl.formatMessage(messages.blockLabelButtonManageAccess)} - - - {intl.formatMessage(messages.blockLabelButtonDelete)} - - - - - - - )} - /> - - -
- - -
- ); -}; - -CourseXBlock.defaultProps = { - validationMessages: [], - shouldScroll: false, -}; - -CourseXBlock.propTypes = { - id: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, - shouldScroll: PropTypes.bool, - validationMessages: PropTypes.arrayOf(PropTypes.shape({ - type: PropTypes.string, - text: PropTypes.string, - })), - unitXBlockActions: PropTypes.shape({ - handleDelete: PropTypes.func, - handleDuplicate: PropTypes.func, - }).isRequired, - userPartitionInfo: PropTypes.shape({ - selectablePartitions: PropTypes.arrayOf(PropTypes.shape({ - groups: PropTypes.arrayOf(PropTypes.shape({ - deleted: PropTypes.bool, - id: PropTypes.number, - name: PropTypes.string, - selected: PropTypes.bool, - })), - id: PropTypes.number, - name: PropTypes.string, - scheme: PropTypes.string, - })), - selectedPartitionIndex: PropTypes.number, - selectedGroupsLabel: PropTypes.string, - }).isRequired, - handleConfigureSubmit: PropTypes.func.isRequired, -}; - -export default CourseXBlock; diff --git a/src/course-unit/course-xblock/CourseXBlock.scss b/src/course-unit/course-xblock/CourseXBlock.scss deleted file mode 100644 index 4ae9f6dab1..0000000000 --- a/src/course-unit/course-xblock/CourseXBlock.scss +++ /dev/null @@ -1,36 +0,0 @@ -.course-unit { - .course-unit__xblocks { - .course-unit__xblock:not(:first-child) { - margin-top: 1.75rem; - } - - .pgn__card-header { - display: flex; - justify-content: space-between; - border-bottom: 1px solid $light-400; - padding-bottom: map-get($spacers, 2); - - &:not(:has(.pgn__card-header-subtitle-md)) { - align-items: center; - } - } - - .pgn__card-header-subtitle-md { - margin-top: 0; - font-size: $font-size-sm; - } - - .pgn__card-header-title-md { - font: 700 1.375rem/1.75rem $font-family-sans-serif; - color: $black; - } - - .pgn__card-section { - padding: map-get($spacers, 3\.5) 0; - } - } - - .unit-iframe__wrapper .alert-danger { - margin-bottom: 0; - } -} diff --git a/src/course-unit/course-xblock/CourseXBlock.test.jsx b/src/course-unit/course-xblock/CourseXBlock.test.jsx deleted file mode 100644 index 95482d098e..0000000000 --- a/src/course-unit/course-xblock/CourseXBlock.test.jsx +++ /dev/null @@ -1,318 +0,0 @@ -import { - render, waitFor, within, -} from '@testing-library/react'; -import { useSelector } from 'react-redux'; -import userEvent from '@testing-library/user-event'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform'; -import { AppProvider } from '@edx/frontend-platform/react'; -import MockAdapter from 'axios-mock-adapter'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; - -import configureModalMessages from '../../generic/configure-modal/messages'; -import deleteModalMessages from '../../generic/delete-modal/messages'; -import initializeStore from '../../store'; -import { getCourseSectionVerticalApiUrl, getXBlockBaseApiUrl } from '../data/api'; -import { fetchCourseSectionVerticalData } from '../data/thunk'; -import { executeThunk } from '../../utils'; -import { getCourseId } from '../data/selectors'; -import { PUBLISH_TYPES } from '../constants'; -import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; -import { courseSectionVerticalMock, courseVerticalChildrenMock } from '../__mocks__'; -import CourseXBlock from './CourseXBlock'; -import messages from './messages'; - -let axiosMock; -let store; -const courseId = '1234'; -const blockId = '567890'; -const handleDeleteMock = jest.fn(); -const handleDuplicateMock = jest.fn(); -const handleConfigureSubmitMock = jest.fn(); -const { - name, - block_id: id, - block_type: type, - user_partition_info: userPartitionInfo, -} = courseVerticalChildrenMock.children[0]; -const userPartitionInfoFormatted = camelCaseObject(userPartitionInfo); -const unitXBlockActionsMock = { - handleDelete: handleDeleteMock, - handleDuplicate: handleDuplicateMock, -}; - -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn(), -})); - -const renderComponent = (props) => render( - - - - - , -); - -useSelector.mockImplementation((selector) => { - if (selector === getCourseId) { - return courseId; - } - return null; -}); - -describe('', () => { - const locationTemp = window.location; - beforeAll(() => { - delete window.location; - window.location = { - assign: jest.fn(), - }; - }); - afterAll(() => { - window.location = locationTemp; - }); - beforeEach(async () => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - - store = initializeStore(); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, courseSectionVerticalMock); - await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - }); - - it('render CourseXBlock component correctly', async () => { - const { getByText, getByLabelText } = renderComponent(); - - await waitFor(() => { - expect(getByText(name)).toBeInTheDocument(); - expect(getByLabelText(messages.blockAltButtonEdit.defaultMessage)).toBeInTheDocument(); - expect(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)).toBeInTheDocument(); - }); - }); - - it('render CourseXBlock component action dropdown correctly', async () => { - const { getByRole, getByLabelText } = renderComponent(); - - await waitFor(() => { - userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); - expect(getByRole('button', { name: messages.blockLabelButtonDuplicate.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.blockLabelButtonMove.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.blockLabelButtonManageAccess.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.blockLabelButtonDelete.defaultMessage })).toBeInTheDocument(); - }); - }); - - it('calls handleDuplicate when item is clicked', async () => { - const { getByText, getByLabelText } = renderComponent(); - - await waitFor(() => { - userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); - const duplicateBtn = getByText(messages.blockLabelButtonDuplicate.defaultMessage); - - userEvent.click(duplicateBtn); - expect(handleDuplicateMock).toHaveBeenCalledTimes(1); - expect(handleDuplicateMock).toHaveBeenCalledWith(id); - }); - }); - - it('opens confirm delete modal and calls handleDelete when deleting was confirmed', async () => { - const { getByText, getByLabelText, getByRole } = renderComponent(); - - await waitFor(() => { - userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); - const deleteBtn = getByText(messages.blockLabelButtonDelete.defaultMessage); - - userEvent.click(deleteBtn); - expect(getByText(/Delete this component?/)).toBeInTheDocument(); - expect(getByText(/Deleting this component is permanent and cannot be undone./)).toBeInTheDocument(); - expect(getByRole('button', { name: deleteModalMessages.cancelButton.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: deleteModalMessages.deleteButton.defaultMessage })).toBeInTheDocument(); - - userEvent.click(getByRole('button', { name: deleteModalMessages.cancelButton.defaultMessage })); - expect(handleDeleteMock).not.toHaveBeenCalled(); - - userEvent.click(getByText(messages.blockLabelButtonDelete.defaultMessage)); - expect(getByText(/Delete this component?/)).toBeInTheDocument(); - - userEvent.click(deleteBtn); - userEvent.click(getByRole('button', { name: deleteModalMessages.deleteButton.defaultMessage })); - expect(handleDeleteMock).toHaveBeenCalled(); - expect(handleDeleteMock).toHaveBeenCalledWith(id); - }); - }); - - describe('edit', () => { - it('navigates to editor page on edit HTML xblock', () => { - const { getByText, getByRole } = renderComponent({ - type: COMPONENT_TYPES.html, - }); - - const editButton = getByRole('button', { name: messages.blockAltButtonEdit.defaultMessage }); - expect(getByText(name)).toBeInTheDocument(); - expect(editButton).toBeInTheDocument(); - - userEvent.click(editButton); - expect(window.location.assign).toHaveBeenCalled(); - expect(window.location.assign).toHaveBeenCalledWith(`/course/${courseId}/editor/html/${id}`); - }); - - it('navigates to editor page on edit Video xblock', () => { - const { getByText, getByRole } = renderComponent({ - type: COMPONENT_TYPES.video, - }); - - const editButton = getByRole('button', { name: messages.blockAltButtonEdit.defaultMessage }); - expect(getByText(name)).toBeInTheDocument(); - expect(editButton).toBeInTheDocument(); - - userEvent.click(editButton); - expect(window.location.assign).toHaveBeenCalled(); - expect(window.location.assign).toHaveBeenCalledWith(`/course/${courseId}/editor/video/${id}`); - }); - - it('navigates to editor page on edit Problem xblock', () => { - const { getByText, getByRole } = renderComponent({ - type: COMPONENT_TYPES.problem, - }); - - const editButton = getByRole('button', { name: messages.blockAltButtonEdit.defaultMessage }); - expect(getByText(name)).toBeInTheDocument(); - expect(editButton).toBeInTheDocument(); - - userEvent.click(editButton); - expect(window.location.assign).toHaveBeenCalled(); - expect(window.location.assign).toHaveBeenCalledWith(`/course/${courseId}/editor/problem/${id}`); - expect(handleDeleteMock).toHaveBeenCalledWith(id); - }); - }); - - describe('restrict access', () => { - it('opens restrict access modal successfully', async () => { - const { - getByText, - getByLabelText, - findByTestId, - } = renderComponent(); - - const modalSubtitleText = configureModalMessages.restrictAccessTo.defaultMessage; - const modalCancelBtnText = configureModalMessages.cancelButton.defaultMessage; - const modalSaveBtnText = configureModalMessages.saveButton.defaultMessage; - - userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); - const accessBtn = getByText(messages.blockLabelButtonManageAccess.defaultMessage); - - userEvent.click(accessBtn); - const configureModal = await findByTestId('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 restrict access modal when cancel button is clicked', async () => { - const { - getByText, - getByLabelText, - findByTestId, - } = renderComponent(); - - userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); - const accessBtn = getByText(messages.blockLabelButtonManageAccess.defaultMessage); - - userEvent.click(accessBtn); - const configureModal = await findByTestId('configure-modal'); - expect(configureModal).toBeInTheDocument(); - - userEvent.click(within(configureModal).getByRole('button', { name: configureModalMessages.saveButton.defaultMessage })); - expect(handleConfigureSubmitMock).not.toHaveBeenCalled(); - }); - - it('handles submit 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 { - getByText, - getByLabelText, - findByTestId, - getByRole, - } = renderComponent(); - const accessGroupName1 = userPartitionInfoFormatted.selectablePartitions[0].groups[0].name; - const accessGroupName2 = userPartitionInfoFormatted.selectablePartitions[0].groups[1].name; - - userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); - const accessBtn = getByText(messages.blockLabelButtonManageAccess.defaultMessage); - - userEvent.click(accessBtn); - const configureModal = await findByTestId('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); - await waitFor(() => { - expect(handleConfigureSubmitMock).toHaveBeenCalledTimes(1); - }); - }); - }); - - it('displays a visibility message if item has accessible restrictions', async () => { - const { getByText } = renderComponent( - { - userPartitionInfo: { - ...userPartitionInfoFormatted, - selectedGroupsLabel: 'Visibility group 1', - }, - }, - ); - - await waitFor(() => { - const visibilityMessage = messages.visibilityMessage.defaultMessage - .replace('{selectedGroupsLabel}', 'Visibility group 1'); - expect(getByText(visibilityMessage)).toBeInTheDocument(); - }); - }); -}); diff --git a/src/course-unit/course-xblock/constants.js b/src/course-unit/course-xblock/constants.js deleted file mode 100644 index 5f0177ce72..0000000000 --- a/src/course-unit/course-xblock/constants.js +++ /dev/null @@ -1,5 +0,0 @@ -// eslint-disable-next-line import/prefer-default-export -export const MESSAGE_ERROR_TYPES = { - error: 'error', - warning: 'warning', -}; diff --git a/src/course-unit/course-xblock/messages.js b/src/course-unit/course-xblock/messages.js deleted file mode 100644 index 3e1652de19..0000000000 --- a/src/course-unit/course-xblock/messages.js +++ /dev/null @@ -1,55 +0,0 @@ -import { defineMessages } from '@edx/frontend-platform/i18n'; - -const messages = defineMessages({ - blockAltButtonEdit: { - id: 'course-authoring.course-unit.xblock.button.edit.alt', - defaultMessage: 'Edit', - description: 'The xblock edit button text', - }, - blockActionsDropdownAlt: { - id: 'course-authoring.course-unit.xblock.button.actions.alt', - defaultMessage: 'Actions', - description: 'The xblock three dots dropdown alt text', - }, - blockLabelButtonCopy: { - id: 'course-authoring.course-unit.xblock.button.copy.label', - defaultMessage: 'Copy', - description: 'The xblock copy button text', - }, - blockLabelButtonDuplicate: { - id: 'course-authoring.course-unit.xblock.button.duplicate.label', - defaultMessage: 'Duplicate', - description: 'The xblock duplicate button text', - }, - blockLabelButtonMove: { - id: 'course-authoring.course-unit.xblock.button.move.label', - defaultMessage: 'Move', - description: 'The xblock move button text', - }, - blockLabelButtonCopyToClipboard: { - id: 'course-authoring.course-unit.xblock.button.copyToClipboard.label', - defaultMessage: 'Copy to clipboard', - }, - blockLabelButtonManageAccess: { - id: 'course-authoring.course-unit.xblock.button.manageAccess.label', - defaultMessage: 'Manage access', - description: 'The xblock manage access button text', - }, - blockLabelButtonDelete: { - id: 'course-authoring.course-unit.xblock.button.delete.label', - defaultMessage: 'Delete', - description: 'The xblock delete button text', - }, - visibilityMessage: { - id: 'course-authoring.course-unit.xblock.visibility.message', - defaultMessage: 'Access restricted to: {selectedGroupsLabel}', - description: 'Group visibility accessibility text for xblock', - }, - validationSummary: { - id: 'course-authoring.course-unit.xblock.validation.summary', - defaultMessage: 'This component has validation issues.', - description: 'The alert text of the visibility validation issues', - }, -}); - -export default messages; diff --git a/src/course-unit/course-xblock/xblock-messages/XBlockMessages.jsx b/src/course-unit/course-xblock/xblock-messages/XBlockMessages.jsx deleted file mode 100644 index 0d7e32a4b1..0000000000 --- a/src/course-unit/course-xblock/xblock-messages/XBlockMessages.jsx +++ /dev/null @@ -1,49 +0,0 @@ -import PropTypes from 'prop-types'; -import { Alert } from '@openedx/paragon'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { Info as InfoIcon, WarningFilled as WarningIcon } from '@openedx/paragon/icons'; - -import messages from '../messages'; -import { MESSAGE_ERROR_TYPES } from '../constants'; -import { getMessagesBlockType } from './utils'; - -const XBlockMessages = ({ validationMessages }) => { - const intl = useIntl(); - const type = getMessagesBlockType(validationMessages); - const { warning } = MESSAGE_ERROR_TYPES; - const alertVariant = type === warning ? 'warning' : 'danger'; - const alertIcon = type === warning ? WarningIcon : InfoIcon; - - if (!validationMessages.length) { - return null; - } - - return ( - - - {intl.formatMessage(messages.validationSummary)} - -
    - {validationMessages.map(({ text }) => ( -
  • {text}
  • - ))} -
-
- ); -}; - -XBlockMessages.defaultProps = { - validationMessages: [], -}; - -XBlockMessages.propTypes = { - validationMessages: PropTypes.arrayOf(PropTypes.shape({ - type: PropTypes.string, - text: PropTypes.string, - })), -}; - -export default XBlockMessages; diff --git a/src/course-unit/course-xblock/xblock-messages/XBlockMessages.test.jsx b/src/course-unit/course-xblock/xblock-messages/XBlockMessages.test.jsx deleted file mode 100644 index 8d7e36e98a..0000000000 --- a/src/course-unit/course-xblock/xblock-messages/XBlockMessages.test.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import { render } from '@testing-library/react'; -import '@testing-library/jest-dom/extend-expect'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; - -import messages from '../messages'; -import XBlockMessages from './XBlockMessages'; - -const renderComponent = (props) => render( - - - , -); - -describe('', () => { - it('renders without errors', () => { - renderComponent({ validationMessages: [] }); - }); - - it('does not render anything when there are no errors', () => { - const { container } = renderComponent({ validationMessages: [] }); - expect(container.firstChild).toBeNull(); - }); - - it('renders a warning Alert when there are warning errors', () => { - const validationMessages = [{ type: 'warning', text: 'This is a warning' }]; - const { getByText } = renderComponent({ validationMessages }); - - expect(getByText('This is a warning')).toBeInTheDocument(); - expect(getByText(messages.validationSummary.defaultMessage)).toBeInTheDocument(); - }); - - it('renders a danger Alert when there are danger errors', () => { - const validationMessages = [{ type: 'danger', text: 'This is a danger' }]; - const { getByText } = renderComponent({ validationMessages }); - - expect(getByText('This is a danger')).toBeInTheDocument(); - expect(getByText(messages.validationSummary.defaultMessage)).toBeInTheDocument(); - }); - - it('renders multiple error messages in a list', () => { - const validationMessages = [ - { type: 'warning', text: 'Warning 1' }, - { type: 'danger', text: 'Danger 1' }, - { type: 'danger', text: 'Danger 2' }, - ]; - const { getByText } = renderComponent({ validationMessages }); - - expect(getByText('Warning 1')).toBeInTheDocument(); - expect(getByText('Danger 1')).toBeInTheDocument(); - expect(getByText('Danger 2')).toBeInTheDocument(); - expect(getByText(messages.validationSummary.defaultMessage)).toBeInTheDocument(); - }); -}); diff --git a/src/course-unit/course-xblock/xblock-messages/utils.js b/src/course-unit/course-xblock/xblock-messages/utils.js deleted file mode 100644 index 2a815b7aa2..0000000000 --- a/src/course-unit/course-xblock/xblock-messages/utils.js +++ /dev/null @@ -1,16 +0,0 @@ -import { MESSAGE_ERROR_TYPES } from '../constants'; - -/** - * Determines the block type based on the types of messages in the given array. - * @param {Array} messages - An array of message objects. - * @param {Object[]} messages.type - The type of each message (e.g., MESSAGE_ERROR_TYPES.error). - * @returns {string} - The block type determined by the messages (e.g., 'warning' or 'error'). - */ -// eslint-disable-next-line import/prefer-default-export -export const getMessagesBlockType = (messages) => { - let type = MESSAGE_ERROR_TYPES.warning; - if (messages.some((message) => message.type === MESSAGE_ERROR_TYPES.error)) { - type = MESSAGE_ERROR_TYPES.error; - } - return type; -}; diff --git a/src/course-unit/course-xblock/xblock-messages/utils.test.js b/src/course-unit/course-xblock/xblock-messages/utils.test.js deleted file mode 100644 index 32e8dde4f6..0000000000 --- a/src/course-unit/course-xblock/xblock-messages/utils.test.js +++ /dev/null @@ -1,44 +0,0 @@ -import { MESSAGE_ERROR_TYPES } from '../constants'; -import { getMessagesBlockType } from './utils'; - -describe('xblock-messages utils', () => { - describe('getMessagesBlockType', () => { - it('returns "warning" when there are no error messages', () => { - const messages = [ - { type: MESSAGE_ERROR_TYPES.warning, text: 'This is a warning' }, - { type: MESSAGE_ERROR_TYPES.warning, text: 'Another warning' }, - ]; - const result = getMessagesBlockType(messages); - - expect(result).toBe(MESSAGE_ERROR_TYPES.warning); - }); - - it('returns "error" when there is at least one error message', () => { - const messages = [ - { type: MESSAGE_ERROR_TYPES.warning, text: 'This is a warning' }, - { type: MESSAGE_ERROR_TYPES.error, text: 'This is an error' }, - { type: MESSAGE_ERROR_TYPES.warning, text: 'Another warning' }, - ]; - const result = getMessagesBlockType(messages); - - expect(result).toBe(MESSAGE_ERROR_TYPES.error); - }); - - it('returns "error" when there are only error messages', () => { - const messages = [ - { type: MESSAGE_ERROR_TYPES.error, text: 'This is an error' }, - { type: MESSAGE_ERROR_TYPES.error, text: 'Another error' }, - ]; - const result = getMessagesBlockType(messages); - - expect(result).toBe(MESSAGE_ERROR_TYPES.error); - }); - - it('returns "warning" when there are no messages', () => { - const messages = []; - const result = getMessagesBlockType(messages); - - expect(result).toBe(MESSAGE_ERROR_TYPES.warning); - }); - }); -}); diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index 155e9d9878..6e35b70a3f 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -148,16 +148,3 @@ export async function duplicateUnitItem(itemId, XBlockId) { return data; } - -/** - * Sets the order list of XBlocks. - * @param {string} blockId - The identifier of the course unit. - * @param {Object[]} children - The array of child elements representing the updated order of XBlocks. - * @returns {Promise} - A promise that resolves to the updated data after setting the XBlock order. - */ -export async function setXBlockOrderList(blockId, children) { - const { data } = await getAuthenticatedHttpClient() - .put(getXBlockBaseApiUrl(blockId), { children }); - - return data; -} diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index f0d5f8c3aa..39dbeb5a18 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -103,14 +103,6 @@ const slice = createSlice({ fetchStaticFileNoticesSuccess: (state, { payload }) => { state.staticFileNotices = payload; }, - reorderXBlockList: (state, { payload }) => { - // Create a map for payload IDs to their index for O(1) lookups - const indexMap = new Map(payload.map((id, index) => [id, index])); - - // Directly sort the children based on the order defined in payload - // This avoids the need to copy the array beforehand - state.courseVerticalChildren.children.sort((a, b) => (indexMap.get(a.id) || 0) - (indexMap.get(b.id) || 0)); - }, }, }); @@ -132,7 +124,6 @@ export const { deleteXBlock, duplicateXBlock, fetchStaticFileNoticesSuccess, - reorderXBlockList, } = slice.actions; export const { diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index c2ac2be7c8..3745c924ac 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -18,7 +18,6 @@ import { handleCourseUnitVisibilityAndData, deleteUnitItem, duplicateUnitItem, - setXBlockOrderList, } from './api'; import { updateLoadingCourseUnitStatus, @@ -36,7 +35,6 @@ import { deleteXBlock, duplicateXBlock, fetchStaticFileNoticesSuccess, - reorderXBlockList, } from './slice'; import { getNotificationMessage } from './utils'; @@ -249,26 +247,3 @@ export function duplicateUnitItemQuery(itemId, xblockId) { } }; } - -export function setXBlockOrderListQuery(blockId, xblockListIds, restoreCallback) { - return async (dispatch) => { - dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); - - try { - await setXBlockOrderList(blockId, xblockListIds).then(async (result) => { - if (result) { - dispatch(reorderXBlockList(xblockListIds)); - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - const courseUnit = await getCourseUnitData(blockId); - dispatch(fetchCourseItemSuccess(courseUnit)); - } - }); - } catch (error) { - restoreCallback(); - handleResponseErrors(error, dispatch, updateSavingStatus); - } finally { - dispatch(hideProcessingNotification()); - } - }; -} diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 66182ef1fd..2adadb9bad 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -11,7 +11,6 @@ import { fetchCourseVerticalChildrenData, deleteUnitItemQuery, duplicateUnitItemQuery, - setXBlockOrderListQuery, editCourseUnitVisibilityAndData, } from './data/thunk'; import { @@ -107,10 +106,6 @@ export const useCourseUnit = ({ courseId, blockId }) => { }, }; - const handleXBlockDragAndDrop = (xblockListIds, restoreCallback) => { - dispatch(setXBlockOrderListQuery(blockId, xblockListIds, restoreCallback)); - }; - useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { dispatch(updateQueryPendingStatus(true)); @@ -146,7 +141,6 @@ export const useCourseUnit = ({ courseId, blockId }) => { handleCreateNewCourseXBlock, handleConfigureSubmit, courseVerticalChildren, - handleXBlockDragAndDrop, canPasteComponent, }; }; diff --git a/src/course-unit/xblock-container-iframe/hooks.tsx b/src/course-unit/xblock-container-iframe/hooks.tsx new file mode 100644 index 0000000000..1a81e7852a --- /dev/null +++ b/src/course-unit/xblock-container-iframe/hooks.tsx @@ -0,0 +1,139 @@ +import { + useState, useLayoutEffect, 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]); +}; + +/** + * Custom hook to manage iframe behavior. + * + * @param {Object} params - The parameters for the hook. + * @param {string} params.id - The unique identifier for the iframe. + * @param {string} params.iframeUrl - The URL of the iframe. + * @param {boolean} [params.onLoaded=true] - Flag to indicate if the iframe has loaded. + * @returns {Object} The state and handlers for the iframe. + * @returns {number} return.iframeHeight - The height of the iframe. + * @returns {Function} return.handleIFrameLoad - The handler for iframe load event. + * @returns {boolean} return.showError - Flag to indicate if there was an error loading the iframe. + * @returns {boolean} return.hasLoaded - Flag to indicate if the iframe has loaded. + */ +export const useIFrameBehavior = ({ + id, + iframeUrl, + onLoaded = true, +}: UseIFrameBehaviorParams): UseIFrameBehaviorReturn => { + // Do not remove this hook. See function description. + useLoadBearingHook(id); + + const [iframeHeight, setIframeHeight] = useKeyedState(stateKeys.iframeHeight, 0); + const [hasLoaded, setHasLoaded] = useKeyedState(stateKeys.hasLoaded, false); + const [showError, setShowError] = useKeyedState(stateKeys.showError, false); + const [windowTopOffset, setWindowTopOffset] = useKeyedState(stateKeys.windowTopOffset, null); + + const receiveMessage = useCallback(({ data }: MessageEvent) => { + const { payload, type } = data; + + if (type === messageTypes.resize) { + setIframeHeight(payload.height); + + if (!hasLoaded && iframeHeight === 0 && payload.height > 0) { + setHasLoaded(true); + } + } else if (type === messageTypes.videoFullScreen) { + // We observe exit from the video xblock fullscreen mode + // and scroll to the previously saved scroll position + if (!payload.open && windowTopOffset !== null) { + window.scrollTo(0, Number(windowTopOffset)); + } + + // We listen for this message from LMS to know when we need to + // save or reset scroll position on toggle video xblock fullscreen mode + setWindowTopOffset(payload.open ? window.scrollY : null); + } else if (data.offset) { + // We listen for this message from LMS to know when the page needs to + // be scrolled to another location on the page. + window.scrollTo(0, data.offset + document.getElementById('unit-iframe')!.offsetTop); + } + }, [ + id, + onLoaded, + hasLoaded, + setHasLoaded, + iframeHeight, + setIframeHeight, + windowTopOffset, + setWindowTopOffset, + ]); + + useEventListener('message', receiveMessage); + + const handleIFrameLoad = () => { + if (!hasLoaded) { + setShowError(true); + logError('Unit iframe failed to load. Server possibly returned 4xx or 5xx response.', { + iframeUrl, + }); + } + }; + + useEffect(() => { + setIframeHeight(0); + setHasLoaded(false); + }, [iframeUrl]); + + return { + iframeHeight, + handleIFrameLoad, + showError, + hasLoaded, + }; +}; diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx new file mode 100644 index 0000000000..df3e6528bf --- /dev/null +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -0,0 +1,51 @@ +import { useRef, FC } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; + +import { IFRAME_FEATURE_POLICY } from '../constants'; +import { useIFrameBehavior } from './hooks'; +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; + +interface XBlockContainerIframeProps { + blockId: string; +} + +const XBlockContainerIframe: FC = ({ blockId }) => { + const intl = useIntl(); + const iframeRef = useRef(null); + + const iframeUrl = `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`; + + const { iframeHeight } = useIFrameBehavior({ + id: blockId, + iframeUrl, + }); + + return ( +