diff --git a/.env b/.env index e5fa2f49a9..9ed42d1cc4 100644 --- a/.env +++ b/.env @@ -42,3 +42,4 @@ INVITE_STUDENTS_EMAIL_TO='' AI_TRANSLATIONS_BASE_URL='' ENABLE_HOME_PAGE_COURSE_API_V2=false ENABLE_CHECKLIST_QUALITY='' +SECURE_ORIGIN_XBLOCK_BOOTSTRAP_HTML_URL=null diff --git a/.env.development b/.env.development index 71f65a6d52..e952024b7f 100644 --- a/.env.development +++ b/.env.development @@ -45,3 +45,4 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com" AI_TRANSLATIONS_BASE_URL='http://localhost:18760' ENABLE_HOME_PAGE_COURSE_API_V2=false ENABLE_CHECKLIST_QUALITY=true +SECURE_ORIGIN_XBLOCK_BOOTSTRAP_HTML_URL=/xblock-bootstrap.html diff --git a/.env.test b/.env.test index 7d74809b47..8f0ae7ec41 100644 --- a/.env.test +++ b/.env.test @@ -36,3 +36,4 @@ BBB_LEARN_MORE_URL='' INVITE_STUDENTS_EMAIL_TO="someone@domain.com" ENABLE_HOME_PAGE_COURSE_API_V2=true ENABLE_CHECKLIST_QUALITY=true +SECURE_ORIGIN_XBLOCK_BOOTSTRAP_HTML_URL=/xblock-bootstrap.html diff --git a/package-lock.json b/package-lock.json index 68b552617d..ae2da26b1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,7 @@ "@testing-library/user-event": "^13.2.1", "axios": "^0.28.0", "axios-mock-adapter": "1.22.0", + "copy-webpack-plugin": "^11.0.0", "eslint-import-resolver-webpack": "^0.13.8", "fetch-mock-jest": "^1.5.1", "glob": "7.2.3", @@ -8459,6 +8460,126 @@ "node": ">=0.10.0" } }, + "node_modules/copy-webpack-plugin": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", + "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", + "dev": true, + "dependencies": { + "fast-glob": "^3.2.11", + "glob-parent": "^6.0.1", + "globby": "^13.1.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/copy-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/copy-webpack-plugin/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/globby": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", + "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", + "dev": true, + "dependencies": { + "dir-glob": "^3.0.1", + "fast-glob": "^3.3.0", + "ignore": "^5.2.4", + "merge2": "^1.4.1", + "slash": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/copy-webpack-plugin/node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/copy-webpack-plugin/node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/core-js": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.8.1.tgz", diff --git a/package.json b/package.json index 2c77795335..0a5dddd21b 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "@testing-library/user-event": "^13.2.1", "axios": "^0.28.0", "axios-mock-adapter": "1.22.0", + "copy-webpack-plugin": "^11.0.0", "eslint-import-resolver-webpack": "^0.13.8", "fetch-mock-jest": "^1.5.1", "glob": "7.2.3", diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index f82d80dd98..62d3ee869a 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -2,10 +2,16 @@ import { useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; -import { Container, Layout, Stack } from '@openedx/paragon'; +import { + Container, Layout, Stack, Button, +} from '@openedx/paragon'; import { useIntl, injectIntl } from '@edx/frontend-platform/i18n'; import { DraggableList, ErrorAlert } from '@edx/frontend-lib-content-components'; -import { Warning as WarningIcon } from '@openedx/paragon/icons'; +import { + Warning as WarningIcon, + ArrowDropDown as ArrowDownIcon, + ArrowDropUp as ArrowUpIcon, +} from '@openedx/paragon/icons'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; @@ -60,6 +66,9 @@ const CourseUnit = ({ courseId }) => { courseVerticalChildren, handleXBlockDragAndDrop, canPasteComponent, + isXBlocksExpanded, + isXBlocksRendered, + handleExpandAll, } = useCourseUnit({ courseId, blockId }); const initialXBlocksData = useMemo(() => courseVerticalChildren.children ?? [], [courseVerticalChildren.children]); @@ -73,6 +82,10 @@ const CourseUnit = ({ courseId }) => { setUnitXBlocks(courseVerticalChildren.children); }, [courseVerticalChildren.children]); + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + const { isShow: isShowProcessingNotification, title: processingNotificationTitle, @@ -141,6 +154,7 @@ const CourseUnit = ({ courseId }) => { {currentlyVisibleToStudents && ( { setState={setUnitXBlocks} updateOrder={finalizeXBlockOrder} > + {unitXBlocks.length ? ( + + ) : null} {unitXBlocks.map(({ - name, id, blockType: type, shouldScroll, userPartitionInfo, validationMessages, + name, id, blockType: type, renderError, shouldScroll, + userPartitionInfo, validationMessages, actions, }) => ( { unitXBlockActions={unitXBlockActions} data-testid="course-xblock" userPartitionInfo={userPartitionInfo} + actions={actions} + isXBlocksExpanded={isXBlocksExpanded} + isXBlocksRendered={isXBlocksRendered} /> ))} diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 95116cc1a3..151cb0ef6d 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -520,10 +520,10 @@ describe('', () => { }); it('should display a warning alert for unpublished course unit version', async () => { - const { getByRole } = render(); + const { getByTestId } = render(); await waitFor(() => { - const unpublishedAlert = getByRole('alert', { class: 'course-unit-unpublished-alert' }); + const unpublishedAlert = getByTestId('course-unit-unpublished-alert'); expect(unpublishedAlert).toHaveTextContent(messages.alertUnpublishedVersion.defaultMessage); expect(unpublishedAlert).toHaveClass('alert-warning'); }); @@ -542,7 +542,7 @@ describe('', () => { await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await waitFor(() => { - const unpublishedAlert = queryByRole('alert', { class: 'course-unit-unpublished-alert' }); + const unpublishedAlert = queryByRole('alert', { name: messages.alertUnpublishedVersion }); expect(unpublishedAlert).toBeNull(); }); }); @@ -594,6 +594,7 @@ describe('', () => { block_id: '1234567890', block_type: 'drag-and-drop-v2', user_partition_info: {}, + actions: courseVerticalChildrenMock.children[0].actions, }, ], }); @@ -966,6 +967,55 @@ describe('', () => { )).toBeInTheDocument(); }); + it('should hide action buttons when their corresponding properties are set to false', async () => { + const { + getByText, + getAllByLabelText, + queryByRole, + } = render(); + + const convertedXBlockActions = camelCaseObject(courseVerticalChildrenMock.children[0].actions); + + const updatedXBlockActions = Object.keys(convertedXBlockActions).reduce((acc, key) => { + acc[key] = false; + return acc; + }, {}); + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, { + children: [ + { + ...courseVerticalChildrenMock.children[0], + actions: updatedXBlockActions, + }, + ], + }); + + await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); + + await waitFor(() => { + expect(getByText(unitDisplayName)).toBeInTheDocument(); + const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); + userEvent.click(xblockActionBtn); + const deleteBtn = queryByRole('button', { name: courseXBlockMessages.blockLabelButtonDelete.defaultMessage }); + const duplicateBtn = queryByRole('button', { name: courseXBlockMessages.blockLabelButtonDuplicate.defaultMessage }); + const moveBtn = queryByRole('button', { name: courseXBlockMessages.blockLabelButtonMove.defaultMessage }); + const copyToClipboardBtn = queryByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage }); + const manageAccessBtn = queryByRole('button', { name: courseXBlockMessages.blockLabelButtonManageAccess.defaultMessage }); + // ToDo: uncomment next lines when manage tags will be realized + // eslint-disable-next-line max-len + // const manageTagsBtn = queryByRole('button', { name: courseXBlockMessages.blockLabelButtonManageTags.defaultMessage }); + + expect(deleteBtn).not.toBeInTheDocument(); + expect(duplicateBtn).not.toBeInTheDocument(); + expect(moveBtn).not.toBeInTheDocument(); + expect(copyToClipboardBtn).not.toBeInTheDocument(); + expect(manageAccessBtn).not.toBeInTheDocument(); + // expect(manageTagsBtn).not.toBeInTheDocument(); + }); + }); + it('should toggle visibility from header configure modal and update course unit state accordingly', async () => { const { getByRole, getByTestId } = render(); let courseUnitSidebar; @@ -1152,6 +1202,7 @@ describe('', () => { selected_partition_index: -1, selected_groups_label: '', }, + actions: courseVerticalChildrenMock.children[0].actions, }, ], }); @@ -1499,4 +1550,35 @@ describe('', () => { expect(xBlock1).toBe(xBlock2); }); }); + + it('should expand xblocks when "Expand all" button is clicked', async () => { + const { getByRole, getAllByTestId } = render(); + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, courseVerticalChildrenMock); + + await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); + + const expandAllXBlocksBtn = getByRole('button', { name: messages.expandAllButton.defaultMessage }); + const unitXBlocks = getAllByTestId('course-xblock'); + + unitXBlocks.forEach((unitXBlock) => { + const unitXBlockContentSections = unitXBlock.querySelectorAll('.pgn__card-section'); + expect(unitXBlockContentSections).toHaveLength(0); + }); + + userEvent.click(expandAllXBlocksBtn); + + await waitFor(() => { + const collapseAllXBlocksBtn = getByRole('button', { name: messages.collapseAllButton.defaultMessage }); + expect(collapseAllXBlocksBtn).toBeInTheDocument(); + + unitXBlocks.forEach((unitXBlock) => { + const unitXBlockContentSections = unitXBlock.querySelectorAll('.pgn__card-section'); + // xblock content appears inside the xblock element + expect(unitXBlockContentSections.length).toBeGreaterThan(0); + }); + }); + }); }); diff --git a/src/course-unit/__mocks__/courseVerticalChildren.js b/src/course-unit/__mocks__/courseVerticalChildren.js index 32bd8272b6..2cfcae514b 100644 --- a/src/course-unit/__mocks__/courseVerticalChildren.js +++ b/src/course-unit/__mocks__/courseVerticalChildren.js @@ -9,6 +9,7 @@ module.exports = { can_duplicate: true, can_move: true, can_manage_access: true, + can_manage_tags: true, can_delete: true, }, user_partition_info: { @@ -80,6 +81,7 @@ module.exports = { can_duplicate: true, can_move: true, can_manage_access: true, + can_manage_tags: true, can_delete: true, }, user_partition_info: { diff --git a/src/course-unit/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx index 88058ee5a5..2c675b4cba 100644 --- a/src/course-unit/course-xblock/CourseXBlock.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.jsx @@ -1,37 +1,71 @@ -import { useEffect, useRef } from 'react'; +import { + memo, useEffect, useRef, useMemo, useState, +} from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { useDispatch, useSelector } from 'react-redux'; import { - ActionRow, Card, Dropdown, Icon, IconButton, useToggle, + ActionRow, Card, Dropdown, Icon, IconButton, useToggle, OverlayTrigger, Tooltip, Button, } from '@openedx/paragon'; -import { EditOutline as EditIcon, MoreVert as MoveVertIcon } from '@openedx/paragon/icons'; +import { + EditOutline as EditIcon, + MoreVert as MoveVertIcon, + ArrowDropDown as ArrowDownIcon, + ArrowDropUp as ArrowUpIcon, +} from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import { useNavigate, useSearchParams } from 'react-router-dom'; +import { find } from 'lodash'; -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 { + getCourseId, + getXBlockIFrameHtmlAndResources, +} from '../data/selectors'; +import { + copyToClipboard, +} from '../../generic/data/thunks'; +import { getHandlerUrl } from '../data/api'; +import { fetchXBlockIFrameHtmlAndResourcesQuery } from '../data/thunk'; import { COMPONENT_TYPES } from '../constants'; import XBlockMessages from './xblock-messages/XBlockMessages'; +import RenderErrorAlert from './render-error-alert'; +import { XBlockContent } from './xblock-content'; import messages from './messages'; +import { extractStylesWithContent } from './utils'; -const CourseXBlock = ({ +const CourseXBlock = memo(({ id, title, type, unitXBlockActions, shouldScroll, userPartitionInfo, - handleConfigureSubmit, validationMessages, ...props + handleConfigureSubmit, validationMessages, renderError, actions, + isXBlocksExpanded, isXBlocksRendered, ...props }) => { const courseXBlockElementRef = useRef(null); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const dispatch = useDispatch(); const navigate = useNavigate(); - const canEdit = useSelector(getCanEdit); const courseId = useSelector(getCourseId); const intl = useIntl(); + const xblockIFrameHtmlAndResources = useSelector(getXBlockIFrameHtmlAndResources); + const xblockInstanceHtmlAndResources = useMemo( + () => find(xblockIFrameHtmlAndResources, { xblockId: id }), + [id, xblockIFrameHtmlAndResources], + ); + const [isExpanded, setIsExpanded] = useState(isXBlocksExpanded); + const [isRendered, setIsRendered] = useState(isXBlocksRendered); + + useEffect(() => { + setIsExpanded(isXBlocksExpanded); + setIsRendered(isXBlocksRendered); + }, [isXBlocksExpanded, isXBlocksRendered]); + + const { + canCopy, canDelete, canDuplicate, canManageAccess, canMove, + } = actions; const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); @@ -41,6 +75,17 @@ const CourseXBlock = ({ ? intl.formatMessage(messages.visibilityMessage, { selectedGroupsLabel: userPartitionInfo.selectedGroupsLabel }) : null; + const stylesWithContent = useMemo( + () => xblockIFrameHtmlAndResources + ?.map(item => extractStylesWithContent(item.html)) + .filter(styles => styles.length > 0), + [], + ); + + useEffect(() => { + dispatch(fetchXBlockIFrameHtmlAndResourcesQuery(id)); + }, []); + const currentItemData = { category: COURSE_BLOCK_NAMES.component.id, displayName: title, @@ -68,6 +113,11 @@ const CourseXBlock = ({ handleConfigureSubmit(id, ...arg, closeConfigureModal); }; + const handleExpandContent = () => { + setIsRendered(true); + setIsExpanded((prevState) => !prevState); + }; + useEffect(() => { // if this item has been newly added, scroll to it. if (courseXBlockElementRef.current && (shouldScroll || isScrolledToElement)) { @@ -91,7 +141,24 @@ const CourseXBlock = ({ componentStyle={{ marginBottom: 0 }} > + {intl.formatMessage(messages.expandTooltip)} + + )} + > + + + )} subtitle={visibilityMessage} actions={( @@ -109,23 +176,31 @@ const CourseXBlock = ({ iconAs={Icon} /> - unitXBlockActions.handleDuplicate(id)}> - {intl.formatMessage(messages.blockLabelButtonDuplicate)} - - - {intl.formatMessage(messages.blockLabelButtonMove)} - - {canEdit && ( + {canDuplicate && ( + unitXBlockActions.handleDuplicate(id)}> + {intl.formatMessage(messages.blockLabelButtonDuplicate)} + + )} + {canMove && ( + + {intl.formatMessage(messages.blockLabelButtonMove)} + + )} + {canCopy && ( dispatch(copyToClipboard(id))}> {intl.formatMessage(messages.blockLabelButtonCopyToClipboard)} )} - - {intl.formatMessage(messages.blockLabelButtonManageAccess)} - - - {intl.formatMessage(messages.blockLabelButtonDelete)} - + {canManageAccess && ( + + {intl.formatMessage(messages.blockLabelButtonManageAccess)} + + )} + {canDelete && ( + + {intl.formatMessage(messages.blockLabelButtonDelete)} + + )} )} /> - - -
- + {isRendered && ( + + {renderError ? : ( + <> + + {xblockInstanceHtmlAndResources && ( + + )} + + )} + + )}
); -}; +}); CourseXBlock.defaultProps = { validationMessages: [], shouldScroll: false, + renderError: undefined, }; CourseXBlock.propTypes = { id: PropTypes.string.isRequired, title: PropTypes.string.isRequired, type: PropTypes.string.isRequired, + renderError: PropTypes.string, shouldScroll: PropTypes.bool, validationMessages: PropTypes.arrayOf(PropTypes.shape({ type: PropTypes.string, @@ -187,6 +277,15 @@ CourseXBlock.propTypes = { selectedGroupsLabel: PropTypes.string, }).isRequired, handleConfigureSubmit: PropTypes.func.isRequired, + actions: PropTypes.shape({ + canCopy: PropTypes.bool, + canDelete: PropTypes.bool, + canDuplicate: PropTypes.bool, + canManageAccess: PropTypes.bool, + canMove: PropTypes.bool, + }).isRequired, + isXBlocksExpanded: PropTypes.bool.isRequired, + isXBlocksRendered: PropTypes.bool.isRequired, }; export default CourseXBlock; diff --git a/src/course-unit/course-xblock/CourseXBlock.scss b/src/course-unit/course-xblock/CourseXBlock.scss index 4ae9f6dab1..0ec0ea9749 100644 --- a/src/course-unit/course-xblock/CourseXBlock.scss +++ b/src/course-unit/course-xblock/CourseXBlock.scss @@ -1,3 +1,5 @@ +@import "xblock-content/XBlockContent"; + .course-unit { .course-unit__xblocks { .course-unit__xblock:not(:first-child) { @@ -30,7 +32,7 @@ } } - .unit-iframe__wrapper .alert-danger { + .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 index ad8e09184b..ebac3ddb20 100644 --- a/src/course-unit/course-xblock/CourseXBlock.test.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.test.jsx @@ -18,6 +18,7 @@ import { executeThunk } from '../../utils'; import { getCourseId } from '../data/selectors'; import { PUBLISH_TYPES, COMPONENT_TYPES } from '../constants'; import { courseSectionVerticalMock, courseVerticalChildrenMock } from '../__mocks__'; +import renderErrorAlertMessages from './render-error-alert/messages'; import CourseXBlock from './CourseXBlock'; import messages from './messages'; @@ -34,12 +35,14 @@ const { block_id: id, block_type: type, user_partition_info: userPartitionInfo, + actions, } = courseVerticalChildrenMock.children[0]; const userPartitionInfoFormatted = camelCaseObject(userPartitionInfo); const unitXBlockActionsMock = { handleDelete: handleDeleteMock, handleDuplicate: handleDuplicateMock, }; +const xblockActions = camelCaseObject(actions); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -63,6 +66,7 @@ const renderComponent = (props) => render( userPartitionInfo={userPartitionInfoFormatted} shouldScroll={false} handleConfigureSubmit={handleConfigureSubmitMock} + actions={xblockActions} {...props} /> @@ -310,4 +314,31 @@ describe('', () => { expect(getByText(visibilityMessage)).toBeInTheDocument(); }); }); + + it('displays a render error message if item has error', () => { + const renderErrorMessage = 'Some error message'; + const { + getByText, getByLabelText, queryByTestId, getByRole, + } = renderComponent( + { + renderError: renderErrorMessage, + }, + ); + + userEvent.click(getByRole('button', { name })); + + const errorAlertTitle = renderErrorAlertMessages.alertRenderErrorTitle.defaultMessage; + const errorAlertDescription = renderErrorAlertMessages.alertRenderErrorDescription.defaultMessage; + const errorAlertMessage = renderErrorAlertMessages.alertRenderErrorMessage.defaultMessage + .replace('{message}', renderErrorMessage); + const contentIFrame = queryByTestId('content-iframe-test-id'); + + expect(getByText(errorAlertTitle)).toBeInTheDocument(); + expect(getByText(errorAlertDescription)).toBeInTheDocument(); + expect(getByText(errorAlertMessage)).toBeInTheDocument(); + expect(getByText(name)).toBeInTheDocument(); + expect(getByLabelText(messages.blockAltButtonEdit.defaultMessage)).toBeInTheDocument(); + expect(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)).toBeInTheDocument(); + expect(contentIFrame).not.toBeInTheDocument(); + }); }); diff --git a/src/course-unit/course-xblock/constants.js b/src/course-unit/course-xblock/constants.js index 5f0177ce72..dc38965c64 100644 --- a/src/course-unit/course-xblock/constants.js +++ b/src/course-unit/course-xblock/constants.js @@ -1,5 +1,31 @@ -// eslint-disable-next-line import/prefer-default-export +import PropTypes from 'prop-types'; + +export const IFRAME_FEATURE_POLICY = ( + 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture;' +); + export const MESSAGE_ERROR_TYPES = { error: 'error', warning: 'warning', }; + +export const IFRAME_LOADING_STATUS = { + STANDBY: 'standby', // Structure has been created but is not yet loading. + LOADING: 'loading', + LOADED: 'loaded', + FAILED: 'failed', +}; + +export const statusShape = PropTypes.oneOf(Object.values(IFRAME_LOADING_STATUS)); + +export const fetchable = (valueShape) => PropTypes.shape({ + status: statusShape, + value: valueShape, +}); + +export const blockViewShape = PropTypes.shape({ + content: PropTypes.string.isRequired, + resources: PropTypes.arrayOf(PropTypes.shape({})).isRequired, +}); + +export const STYLE_TAG_PATTERN = /]*>([\s\S]*?)<\/style>/gi; diff --git a/src/course-unit/course-xblock/messages.js b/src/course-unit/course-xblock/messages.js index 3e1652de19..1eaa151272 100644 --- a/src/course-unit/course-xblock/messages.js +++ b/src/course-unit/course-xblock/messages.js @@ -50,6 +50,14 @@ const messages = defineMessages({ defaultMessage: 'This component has validation issues.', description: 'The alert text of the visibility validation issues', }, + iframeErrorText: { + id: 'course-authoring.course-unit.xblock.iframe.error.text', + defaultMessage: 'Unit iframe failed to load. Server possibly returned 4xx or 5xx response.', + }, + expandTooltip: { + id: 'course-authoring.course-unit.xblock.expandTooltip', + defaultMessage: 'Collapse/Expand this block', + }, }); export default messages; diff --git a/src/course-unit/course-xblock/render-error-alert/RenderErrorAlert.test.jsx b/src/course-unit/course-xblock/render-error-alert/RenderErrorAlert.test.jsx new file mode 100644 index 0000000000..bba3c837b1 --- /dev/null +++ b/src/course-unit/course-xblock/render-error-alert/RenderErrorAlert.test.jsx @@ -0,0 +1,55 @@ +import { render } from '@testing-library/react'; +import { CheckCircle as CheckCircleIcon } from '@openedx/paragon/icons'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import RenderErrorAlert from '.'; +import messages from './messages'; + +const defaultTitle = messages.alertRenderErrorTitle.defaultMessage; +const defaultDescription = messages.alertRenderErrorDescription.defaultMessage; +const defaultErrorFullMessage = messages.alertRenderErrorMessage.defaultMessage; +const defaultErrorMessage = 'default error message'; +const customClassName = 'some-class'; +const customErrorMessage = 'custom error message'; + +const RootWrapper = (props) => ( + + + +); + +describe('', () => { + it('renders default values when no props are provided', () => { + const { getByText } = render(); + const titleElement = getByText(defaultTitle); + const descriptionElement = getByText(defaultDescription); + expect(titleElement).toBeInTheDocument(); + expect(descriptionElement).toBeInTheDocument(); + expect(getByText(defaultErrorFullMessage.replace('{message}', defaultErrorMessage))).toBeInTheDocument(); + }); + + it('renders provided props correctly', () => { + const customProps = { + variant: 'success', + icon: CheckCircleIcon, + title: 'Custom Title', + description: 'Custom Description', + errorMessage: customErrorMessage, + }; + const { getByText } = render(); + + expect(getByText(customProps.title)).toBeInTheDocument(); + expect(getByText(customProps.description)).toBeInTheDocument(); + }); + + it('renders the alert with additional props', () => { + const { getByRole } = render(); + const alertElement = getByRole('alert'); + const classNameExists = alertElement.classList.contains(customClassName); + expect(alertElement).toBeInTheDocument(); + expect(classNameExists).toBe(true); + }); +}); diff --git a/src/course-unit/course-xblock/render-error-alert/index.jsx b/src/course-unit/course-xblock/render-error-alert/index.jsx new file mode 100644 index 0000000000..5c819a496e --- /dev/null +++ b/src/course-unit/course-xblock/render-error-alert/index.jsx @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import { Info as InfoIcon } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import AlertMessage from '../../../generic/alert-message'; +import messages from './messages'; + +const RenderErrorAlert = ({ + variant, icon, title, description, errorMessage, ...props +}) => { + const intl = useIntl(); + + return ( + +

{intl.formatMessage(messages.alertRenderErrorDescription)}

+

{intl.formatMessage(messages.alertRenderErrorMessage, { message: errorMessage })}

+ + )} + {...props} + /> + ); +}; + +RenderErrorAlert.defaultProps = { + icon: InfoIcon, + variant: 'danger', + title: undefined, + description: undefined, +}; + +RenderErrorAlert.propTypes = { + variant: 'danger', + icon: PropTypes.node, + title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + description: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + errorMessage: PropTypes.string.isRequired, +}; + +export default RenderErrorAlert; diff --git a/src/course-unit/course-xblock/render-error-alert/messages.js b/src/course-unit/course-xblock/render-error-alert/messages.js new file mode 100644 index 0000000000..6e77e57174 --- /dev/null +++ b/src/course-unit/course-xblock/render-error-alert/messages.js @@ -0,0 +1,18 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + alertRenderErrorMessage: { + id: 'course-authoring.course-unit.xblock.alert.render-error.message', + defaultMessage: 'Error: {message}', + }, + alertRenderErrorTitle: { + id: 'course-authoring.course-unit.xblock.alert.render-error.title', + defaultMessage: 'We\'re having trouble rendering your component', + }, + alertRenderErrorDescription: { + id: 'course-authoring.course-unit.xblock.alert.render-error.description', + defaultMessage: 'Students will not be able to access this component. Re-edit your component to fix the error.', + }, +}); + +export default messages; diff --git a/src/course-unit/course-xblock/utils.js b/src/course-unit/course-xblock/utils.js new file mode 100644 index 0000000000..7d0617daee --- /dev/null +++ b/src/course-unit/course-xblock/utils.js @@ -0,0 +1,19 @@ +import { STYLE_TAG_PATTERN } from './constants'; + +/** + * Extracts content of `).join('\n'); + + let cssTags = generateResourceTags(cssUrls, studioBaseUrl, type); + cssTags += sheets.map(sheet => ``).join('\n'); + + /* Extract JS resources. */ + const jsUrls = filterAndExtractResources(resources, 'url', 'application/javascript'); + const scripts = filterAndExtractResources(resources, 'text', 'application/javascript'); + + let jsTags = generateResourceTags(jsUrls, studioBaseUrl, type); + jsTags += scripts.map(script => ``).join('\n'); + + let legacyIncludes = ''; + if (html.indexOf('wrapper-xblock-message') !== -1) { + legacyIncludes += ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + } + + let result = ''; + let modifiedHtml = ''; + + // Due to the use of edx-platform scripts in MFE, it is necessary to ensure that the paths for static files + // and important data-attributes are correct. + const relativeToAbsoluteXBlocksUrls = { + 'url('/asset': `url('${studioBaseUrl}/asset`, + 'src="/asset': `src="${studioBaseUrl}/asset`, + 'src="/asset': `src="${studioBaseUrl}/asset`, + 'href="/asset': `href="${studioBaseUrl}/asset`, + 'src="/static': `src="${studioBaseUrl}/static`, + 'src="/static': `src="${studioBaseUrl}/static`, + 'data-target="/preview': `data-target="${studioBaseUrl}/preview`, + 'data-url="/preview': `data-url="${studioBaseUrl}/preview`, + 'src="/preview': `src="${studioBaseUrl}/preview`, + 'src="/media': `src="${studioBaseUrl}/media`, + ': "/asset': `: "${studioBaseUrl}/asset`, + ': "/xblock': `: "${studioBaseUrl}/xblock`, + ': "/static': `: "${studioBaseUrl}/static`, + }; + + modifiedHtml = modifyVoidHrefToPreventDefault(html); + + // Block that replaces relative urls with absolute urls + Object.entries(relativeToAbsoluteXBlocksUrls).forEach(([key, value]) => { + modifiedHtml = modifiedHtml.replaceAll(key, value); + }); + + if ( + type === COMPONENT_TYPES.discussion + || type === COMPONENT_TYPES.dragAndDrop + || type === COMPONENT_TYPES.html + ) { + result = ` + + + + + + ${legacyIncludes} + ${cssTags} + ${additionalCssTags} + + +
+
+
+
+
+ ${modifiedHtml} +
+
+
+
+
+ ${jsTags} + + + + `; + } else if (COMPONENT_TYPES.advanced) { + result = ` + + + + + + ${legacyIncludes} + ${cssTags} + ${additionalCssTags} + + +
+
+ ${modifiedHtml} +
+
+ ${jsTags} + + + + `; + } else { + result = ` + + + + + + ${legacyIncludes} + ${cssTags} + ${additionalCssTags} + + + ${modifiedHtml} + ${jsTags} + + + + `; + } + + return result; +} diff --git a/src/course-unit/course-xblock/xblock-content/iframe-wrapper/index.js b/src/course-unit/course-xblock/xblock-content/iframe-wrapper/index.js new file mode 100644 index 0000000000..e0a46bf82f --- /dev/null +++ b/src/course-unit/course-xblock/xblock-content/iframe-wrapper/index.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as wrapBlockHtmlForIFrame } from './iframe-wrapper'; diff --git a/src/course-unit/course-xblock/xblock-content/iframe-wrapper/static/XBlockIFrame.css b/src/course-unit/course-xblock/xblock-content/iframe-wrapper/static/XBlockIFrame.css new file mode 100644 index 0000000000..da5fdc2ffd --- /dev/null +++ b/src/course-unit/course-xblock/xblock-content/iframe-wrapper/static/XBlockIFrame.css @@ -0,0 +1,11 @@ +.wrapper-xblock.level-element .xblock-header:not(.is-hidden) { + display: none; +} + +.wrapper-xblock { + border: none; +} + +body .poll-block-form-wrapper { + display: block; +} diff --git a/src/course-unit/course-xblock/xblock-content/iframe-wrapper/static/xblock-bootstrap.html b/src/course-unit/course-xblock/xblock-content/iframe-wrapper/static/xblock-bootstrap.html new file mode 100644 index 0000000000..e1feefc781 --- /dev/null +++ b/src/course-unit/course-xblock/xblock-content/iframe-wrapper/static/xblock-bootstrap.html @@ -0,0 +1,67 @@ + + + + + + + + + + diff --git a/src/course-unit/course-xblock/xblock-content/iframe-wrapper/tools/iframe-connector.js b/src/course-unit/course-xblock/xblock-content/iframe-wrapper/tools/iframe-connector.js new file mode 100644 index 0000000000..0cf6794190 --- /dev/null +++ b/src/course-unit/course-xblock/xblock-content/iframe-wrapper/tools/iframe-connector.js @@ -0,0 +1,231 @@ +/* eslint-disable no-param-reassign */ +/** + * The JavaScript code which runs inside our IFrame and is responsible + * for communicating with the parent window. + * + * This cannot use any imported functions because it runs in the IFrame, + * not in our app webpack bundle. + */ +// eslint-disable-next-line import/prefer-default-export +export function xblockIFrameConnector() { + const CHILDREN_KEY = '_jsrt_xb_children'; // JavaScript RunTime XBlock children + const USAGE_ID_KEY = '_jsrt_xb_usage_id'; + const HANDLER_URL = '_jsrt_xb_handler_url'; + + const uniqueKeyPrefix = `k${+Date.now()}-${Math.floor(Math.random() * 1e10)}-`; + let messageCount = 0; + + /** + * A helper method for sending messages to the parent window of this IFrame + * and getting a reply, even when the IFrame is securely sandboxed. + * @param messageData The message to send. Must be an object, as we add a key/value pair to it. + * @param callback The callback to call when the parent window replies + */ + function postMessageToParent(messageData, callback) { + messageCount += 1; + const messageReplyKey = uniqueKeyPrefix + messageCount; + messageData.replyKey = messageReplyKey; + + if (callback !== undefined) { + const handleResponse = (event) => { + if (event.source === window.parent && event.data.replyKey === messageReplyKey) { + callback(event.data); + window.removeEventListener('message', handleResponse); + } + }; + window.addEventListener('message', handleResponse); + } + window.parent.postMessage(messageData, '*'); + } + + /** + * The JavaScript runtime for any XBlock in the IFrame + */ + const runtime = { + /** + * An obscure and little-used API that retrieves a particular + * XBlock child using its 'data-name' attribute + * @param block The root DIV element of the XBlock calling this method + * @param childName The value of the 'data-name' attribute of the root + * DIV element of the XBlock child in question. + */ + childMap: (block, childName) => runtime.children(block).find((child) => child.element.getAttribute('data-name') === childName), + children: (block) => block[CHILDREN_KEY], + /** + * Get the URL for the specified handler. This method must be synchronous, so + * cannot make HTTP requests. + */ + handlerUrl: (block, handlerName, suffix, query) => { + let url = block[HANDLER_URL].replace('handler_name', handlerName); + if (suffix) { + url += `${suffix}/`; + } + if (query) { + url += `?${query}`; + } + return url; + }, + /** + * Pass an arbitrary message from the XBlock to the parent application. + * This is mostly used by the studio_view to inform the user of save events. + * Standard events are as follows: + * + * save: {state: 'start'|'end', message: string} + * -> Displays a "Saving..." style message + animation to the user until called + * again with {state: 'end'}. Then closes the modal holding the studio_view. + * + * error: {title: string, message: string} + * -> Displays an error message to the user + * + * cancel: {} + * -> Close the modal holding the studio_view + */ + notify: (eventType, params) => { + params.method = `xblock:${eventType}`; + postMessageToParent(params); + }, + }; + + /** + * Initialize an XBlock. This function should only be called by initializeXBlockAndChildren + * because it assumes that function has already run. + */ + function initializeXBlock(element, callback) { + const usageId = element[USAGE_ID_KEY]; + // Check if the XBlock has an initialization function: + const initFunctionName = element.getAttribute('data-init'); + + if (initFunctionName !== null) { + // Since this block has an init function, it may need to call handlers, + // so we first have to generate a secure handler URL for it: + postMessageToParent({ method: 'get_handler_url', usageId }, (handlerData) => { + element[HANDLER_URL] = handlerData.handlerUrl; + // Now proceed with initializing the block's JavaScript: + const InitFunction = (window)[initFunctionName]; + // Does the XBlock HTML contain arguments to pass to the InitFunction? + let data = {}; + [].forEach.call(element.children, (childNode) => { + // The newer/pure/Blockstore runtime uses 'xblock_json_init_args' + // while the Studio runtime uses 'xblock-json-init-args'. + if ( + childNode.matches('script.xblock_json_init_args') + || childNode.matches('script.xblock-json-init-args') + ) { + data = JSON.parse(childNode.textContent); + } + }); + + // An unfortunate inconsistency is that the old Studio runtime used + // to pass 'element' as a jQuery-wrapped DOM element, whereas the Studio + // runtime used to pass 'element' as the pure DOM node. In order not to + // break backwards compatibility, we would need to maintain that. + // However, this is currently disabled as it causes issues (need to + // modify the runtime methods like handlerUrl too), and we decided not + // to maintain support for legacy studio_view in this runtime. + // const isStudioView = element.className.indexOf('studio_view') !== -1; + // const passElement = isStudioView && (window as any).$ ? (window as any).$(element) : element; + const blockJS = new InitFunction(runtime, element, data) || {}; + blockJS.element = element; + callback(blockJS); + }); + } else { + const blockJS = { element }; + callback(blockJS); + } + } + + /** + * Finds the value of the first 'data-usage' or 'data-usage-id' attribute within the given element + * and its descendants. + */ + function findFirstDataAttributeValue(element) { + // eslint-disable-next-line consistent-return,no-shadow + function searchDataUsageAttribute(element) { + const { attributes, children } = element; + for (let i = 0; i < attributes.length; i++) { + const attributeName = attributes[i].name; + if (attributeName === 'data-usage' || attributeName === 'data-usage-id') { + return attributes[i].value; + } + } + + for (let j = 0; j < children.length; j++) { + const result = searchDataUsageAttribute(children[j]); + if (result !== undefined) { + return result; + } + } + } + + return searchDataUsageAttribute(element); + } + + // Recursively initialize the JavaScript code of each XBlock: + function initializeXBlockAndChildren(element, callback) { + const usageId = findFirstDataAttributeValue(element); + + if (usageId !== null) { + element[USAGE_ID_KEY] = usageId; + } else { + throw new Error('XBlock is missing a usage ID attribute on its root HTML node.'); + } + + const version = element.getAttribute('data-runtime-version'); + + if (version != null && version !== '1') { + throw new Error('Unsupported XBlock runtime version requirement.'); + } + // Recursively initialize any children first: + // We need to find all div.xblock-v1 children, unless they're grandchilden + // So we build a list of all div.xblock-v1 descendants that aren't descendants + // of an already-found descendant: + const childNodesFound = []; + [].forEach.call(element.querySelectorAll('.xblock, .xblock-v1'), (childNode) => { + if (!childNodesFound.find((el) => el.contains(childNode))) { + childNodesFound.push(childNode); + } + }); + + // This code is awkward because we can't use promises (IE11 etc.) + let childrenInitialized = -1; + function initNextChild() { + childrenInitialized += 1; + if (childrenInitialized < childNodesFound.length) { + const childNode = childNodesFound[childrenInitialized]; + initializeXBlockAndChildren(childNode, initNextChild); + } else { + // All children are initialized: + initializeXBlock(element, callback); + } + } + initNextChild(); + } + + // Find the root XBlock node. + // The newer/pure/Blockstore runtime uses '.xblock-v1' while the Studio runtime uses '.xblock'. + const rootNode = document.querySelector('.xblock, .xblock-v1'); // will always return the first matching element + + initializeXBlockAndChildren(rootNode, () => { + // When done, tell the parent window the size of this block: + postMessageToParent({ + height: document.body.scrollHeight, + method: 'update_frame_height', + }); + postMessageToParent({ method: 'init_done' }); + }); + + let lastHeight = -1; + function checkFrameHeight() { + const visibleIFrameContent = document.querySelector('.xblock-render'); + const newHeight = visibleIFrameContent.scrollHeight; + + if (newHeight !== lastHeight) { + postMessageToParent({ method: 'update_frame_height', height: newHeight }); + lastHeight = newHeight; + } + } + // Check the size whenever the DOM changes: + new MutationObserver(checkFrameHeight).observe(document.body, { attributes: true, childList: true, subtree: true }); + // And whenever the IFrame is resized + window.addEventListener('resize', checkFrameHeight); +} diff --git a/src/course-unit/course-xblock/xblock-content/iframe-wrapper/tools/index.js b/src/course-unit/course-xblock/xblock-content/iframe-wrapper/tools/index.js new file mode 100644 index 0000000000..8e857fa375 --- /dev/null +++ b/src/course-unit/course-xblock/xblock-content/iframe-wrapper/tools/index.js @@ -0,0 +1,7 @@ +export { xblockIFrameConnector } from './iframe-connector'; +export { + normalizeResources, + filterAndExtractResources, + generateResourceTags, + modifyVoidHrefToPreventDefault, +} from './utils'; diff --git a/src/course-unit/course-xblock/xblock-content/iframe-wrapper/tools/utils.js b/src/course-unit/course-xblock/xblock-content/iframe-wrapper/tools/utils.js new file mode 100644 index 0000000000..8ebf9edef5 --- /dev/null +++ b/src/course-unit/course-xblock/xblock-content/iframe-wrapper/tools/utils.js @@ -0,0 +1,47 @@ +import { COMPONENT_TYPES } from '../../../../constants'; + +/** + * Normalizes an array of resources by mapping each entry to an object with specified properties. + * + * @param {Array<[string, any]>} resourcesArray - Array of resource tuples consisting of an id and an object. + * @returns {Array<{ id: string, ...any }>} - Array of normalized resources with specified properties. + */ +export const normalizeResources = (resourcesArray) => resourcesArray.map(([id, obj]) => ({ id, ...obj })); + +/** + * Filters and extracts resources from an array based on specified criteria. + * + * @param {Array<{ kind: string, mimetype: string, data: any }>} resources - Array of resources. + * @param {string} kind - Kind of resource to filter. + * @param {string} mimetype - Mimetype of resource to filter. + * @returns {Array} - Filtered and extracted resources. + */ +export const filterAndExtractResources = (resources, kind, mimetype) => resources + .filter(r => r.kind === kind && r.mimetype === mimetype).map(r => r.data); + +/** + * Generates HTML tags for resources based on their URLs, base URL, and type. + * + * @param {Array} urls - Array of resource URLs. + * @param {string} baseUrl - Base URL to prepend to relative URLs. + * @param {string} type - Type of resource. + * @returns {string} - Generated HTML tags for resources. + */ +export const generateResourceTags = (urls, baseUrl, type) => urls.map(url => { + const fullUrl = type === COMPONENT_TYPES.openassessment ? url : baseUrl + url; + if (url.endsWith('.css')) { + return ``; + } if (url.endsWith('.js')) { + return ``; + } + return ''; +}).join('\n'); + +/** + * Modifies void hrefs in HTML to prevent default behavior. + * + * @param {string} html - HTML content to modify. + * @returns {string} - Modified HTML content with void hrefs modified to prevent default behavior. + */ +export const modifyVoidHrefToPreventDefault = (html) => html + .replace(/href="javascript:void\(0\)"/g, 'href="javascript:void(0)" onclick="event.preventDefault()"'); diff --git a/src/course-unit/course-xblock/xblock-content/index.js b/src/course-unit/course-xblock/xblock-content/index.js new file mode 100644 index 0000000000..fdfd97a639 --- /dev/null +++ b/src/course-unit/course-xblock/xblock-content/index.js @@ -0,0 +1,2 @@ +/* eslint-disable-next-line import/prefer-default-export */ +export { default as XBlockContent } from './XBlockContent'; diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index 155e9d9878..f00d269cc9 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -11,6 +11,9 @@ export const getCourseUnitApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/con export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`; export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`; export const getCourseVerticalChildrenApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container/vertical/${itemId}/children`; +export const getXBlockContainerPreview = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}/container_preview`; +export const getCsrfTokenApiUrl = () => `${getStudioBaseUrl()}/csrf/api/v1/token`; + export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`; /** @@ -161,3 +164,32 @@ export async function setXBlockOrderList(blockId, children) { return data; } + +/** + * Fetches XBlock iframe data. + * @param {string} itemId - The ID of the XBlock item. + * @returns {Promise} A Promise that resolves with the XBlock iframe data. + */ +export async function getXBlockIFrameData(itemId) { + const { data } = await getAuthenticatedHttpClient() + .get(getXBlockContainerPreview(itemId)); + + return camelCaseObject(data); +} + +export const getHandlerUrl = async (blockId) => { + const baseUrl = getConfig().STUDIO_BASE_URL; + + return `${baseUrl}/preview/xblock/${blockId}/handler/handler_name`; +}; + +/** + * Fetches CSRF token data from the server. + * @returns {Promise} A Promise that resolves to an object containing CSRF token data. + */ +export async function getCsrfTokenData() { + const { data } = await getAuthenticatedHttpClient() + .get(getCsrfTokenApiUrl()); + + return camelCaseObject(data); +} diff --git a/src/course-unit/data/selectors.js b/src/course-unit/data/selectors.js index 41cb7ea912..8a2615667f 100644 --- a/src/course-unit/data/selectors.js +++ b/src/course-unit/data/selectors.js @@ -2,7 +2,6 @@ import { createSelector } from '@reduxjs/toolkit'; import { RequestStatus } from 'CourseAuthoring/data/constants'; export const getCourseUnitData = (state) => state.courseUnit.unit; -export const getCanEdit = (state) => state.courseUnit.canEdit; export const getStaticFileNotices = (state) => state.courseUnit.staticFileNotices; export const getCourseUnit = (state) => state.courseUnit; export const getSavingStatus = (state) => state.courseUnit.savingStatus; @@ -12,7 +11,9 @@ export const getCourseSectionVertical = (state) => state.courseUnit.courseSectio export const getCourseId = (state) => state.courseDetail.courseId; export const getSequenceId = (state) => state.courseUnit.sequenceId; export const getCourseVerticalChildren = (state) => state.courseUnit.courseVerticalChildren; +export const getCsrfTokenData = (state) => state.courseUnit.csrfToken; const getLoadingStatuses = (state) => state.courseUnit.loadingStatus; +export const getXBlockIFrameHtmlAndResources = (state) => state.courseUnit.xblockIFrameHtmlAndResources; export const getIsLoading = createSelector( [getLoadingStatuses], loadingStatus => Object.values(loadingStatus) diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index 24c1b965cd..6966816e5d 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -7,9 +7,10 @@ const slice = createSlice({ name: 'courseUnit', initialState: { savingStatus: '', + errorMessage: '', + csrfToken: '', isQueryPending: false, isTitleEditFormOpen: false, - canEdit: true, loadingStatus: { fetchUnitLoadingStatus: RequestStatus.IN_PROGRESS, courseSectionVerticalLoadingStatus: RequestStatus.IN_PROGRESS, @@ -19,6 +20,7 @@ const slice = createSlice({ courseSectionVertical: {}, courseVerticalChildren: { children: [], isPublished: true }, staticFileNotices: {}, + xblockIFrameHtmlAndResources: [], }, reducers: { fetchCourseItemSuccess: (state, { payload }) => { @@ -108,6 +110,12 @@ const slice = createSlice({ // 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)); }, + fetchXBlockIFrameResources: (state, { payload }) => { + state.xblockIFrameHtmlAndResources.push(payload); + }, + fetchCsrfTokenSuccess: (state, { payload }) => { + state.csrfToken = payload; + }, }, }); @@ -130,6 +138,8 @@ export const { duplicateXBlock, fetchStaticFileNoticesSuccess, reorderXBlockList, + fetchXBlockIFrameResources, + fetchCsrfTokenSuccess, } = slice.actions; export const { diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index 109e121c7e..d6fb3a169e 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -8,6 +8,7 @@ import { RequestStatus } from '../../data/constants'; import { NOTIFICATION_MESSAGES } from '../../constants'; import { updateModel, updateModels } from '../../generic/model-store'; import { updateClipboardData } from '../../generic/data/slice'; +import { PUBLISH_TYPES } from '../constants'; import { getCourseUnitData, editUnitDisplayName, @@ -18,6 +19,8 @@ import { deleteUnitItem, duplicateUnitItem, setXBlockOrderList, + getXBlockIFrameData, + getCsrfTokenData, } from './api'; import { updateLoadingCourseUnitStatus, @@ -36,6 +39,8 @@ import { duplicateXBlock, fetchStaticFileNoticesSuccess, reorderXBlockList, + fetchXBlockIFrameResources, + fetchCsrfTokenSuccess, } from './slice'; import { getNotificationMessage } from './utils'; @@ -134,6 +139,9 @@ export function editCourseUnitVisibilityAndData(itemId, type, isVisible, groupAc dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); dispatch(hideProcessingNotification()); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + if (type === PUBLISH_TYPES.discardChanges) { + window.location.reload(); + } } }); } catch (error) { @@ -271,3 +279,35 @@ export function setXBlockOrderListQuery(blockId, xblockListIds, restoreCallback) } }; } + +export function fetchXBlockIFrameHtmlAndResourcesQuery(xblockId) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + + try { + const xblockIFrameData = await getXBlockIFrameData(xblockId); + dispatch(fetchXBlockIFrameResources({ xblockId, ...xblockIFrameData })); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } finally { + dispatch(hideProcessingNotification()); + } + }; +} + +export function fetchCsrfTokenQuery() { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + + try { + const csrfTokenData = await getCsrfTokenData(); + dispatch(fetchCsrfTokenSuccess(csrfTokenData.csrfToken)); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } finally { + dispatch(hideProcessingNotification()); + } + }; +} diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 91601a0503..d7298d36b4 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -1,4 +1,6 @@ -import { useEffect, useState } from 'react'; +import { + useCallback, useEffect, useMemo, useState, +} from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useSearchParams } from 'react-router-dom'; @@ -13,6 +15,7 @@ import { duplicateUnitItemQuery, setXBlockOrderListQuery, editCourseUnitVisibilityAndData, + fetchCsrfTokenQuery, } from './data/thunk'; import { getCourseSectionVertical, @@ -22,7 +25,6 @@ import { getSavingStatus, getSequenceStatus, getStaticFileNotices, - getCanEdit, } from './data/selectors'; import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice'; import { PUBLISH_TYPES } from './constants'; @@ -33,6 +35,8 @@ import { useCopyToClipboard } from '../generic/clipboard'; export const useCourseUnit = ({ courseId, blockId }) => { const dispatch = useDispatch(); const [searchParams] = useSearchParams(); + const [isXBlocksExpanded, setXBlocksExpanded] = useState(false); + const [isXBlocksRendered, setIsXBlocksRendered] = useState(false); const [isErrorAlert, toggleErrorAlert] = useState(false); const [hasInternetConnectionError, setInternetConnectionError] = useState(false); @@ -46,9 +50,8 @@ export const useCourseUnit = ({ courseId, blockId }) => { const navigate = useNavigate(); const isTitleEditFormOpen = useSelector(state => state.courseUnit.isTitleEditFormOpen); const isQueryPending = useSelector(state => state.courseUnit.isQueryPending); - const canEdit = useSelector(getCanEdit); const { currentlyVisibleToStudents } = courseUnit; - const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(canEdit); + const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(); const { canPasteComponent } = courseVerticalChildren; const unitTitle = courseUnit.metadata?.displayName || ''; @@ -71,10 +74,10 @@ export const useCourseUnit = ({ courseId, blockId }) => { dispatch(changeEditTitleFormOpen(!isTitleEditFormOpen)); }; - const handleConfigureSubmit = (id, isVisible, groupAccess, closeModalFn) => { + const handleConfigureSubmit = useCallback((id, isVisible, groupAccess, closeModalFn) => { dispatch(editCourseUnitVisibilityAndData(id, PUBLISH_TYPES.republish, isVisible, groupAccess, true, blockId)); closeModalFn(); - }; + }, [courseId, blockId]); const handleTitleEditSubmit = (displayName) => { if (unitTitle !== displayName) { @@ -103,19 +106,24 @@ export const useCourseUnit = ({ courseId, blockId }) => { dispatch(createNewCourseXBlock(body, callback, blockId)) ); - const unitXBlockActions = { + const unitXBlockActions = useMemo(() => ({ handleDelete: (XBlockId) => { dispatch(deleteUnitItemQuery(blockId, XBlockId)); }, handleDuplicate: (XBlockId) => { dispatch(duplicateUnitItemQuery(blockId, XBlockId)); }, - }; + }), [courseId, blockId]); const handleXBlockDragAndDrop = (xblockListIds, restoreCallback) => { dispatch(setXBlockOrderListQuery(blockId, xblockListIds, restoreCallback)); }; + const handleExpandAll = () => { + setIsXBlocksRendered(true); + setXBlocksExpanded((prevState) => !prevState); + }; + useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { dispatch(updateQueryPendingStatus(true)); @@ -128,7 +136,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { dispatch(fetchCourseUnitQuery(blockId)); dispatch(fetchCourseSectionVerticalData(blockId, sequenceId)); dispatch(fetchCourseVerticalChildrenData(blockId)); - + dispatch(fetchCsrfTokenQuery()); handleNavigate(sequenceId); }, [courseId, blockId, sequenceId]); @@ -158,5 +166,8 @@ export const useCourseUnit = ({ courseId, blockId }) => { courseVerticalChildren, handleXBlockDragAndDrop, canPasteComponent, + isXBlocksExpanded, + isXBlocksRendered, + handleExpandAll, }; }; diff --git a/src/course-unit/messages.js b/src/course-unit/messages.js index 4f0418efe5..b5934d1e28 100644 --- a/src/course-unit/messages.js +++ b/src/course-unit/messages.js @@ -2,17 +2,25 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ alertFailedGeneric: { - id: 'course-authoring.course-unit.general.alert.error.description', + id: 'course-authoring.course-unit.xblock.alert.error.description', defaultMessage: 'Unable to {actionName} {type}. Please try again.', }, alertUnpublishedVersion: { - id: 'course-authoring.course-unit.general.alert.unpublished-version.description', + id: 'course-authoring.course-unit.xblock.alert.unpublished-version.description', defaultMessage: 'Note: The last published version of this unit is live. By publishing changes you will change the student experience.', }, pasteButtonText: { id: 'course-authoring.course-unit.paste-component.btn.text', defaultMessage: 'Paste component', }, + collapseAllButton: { + id: 'course-authoring.course-unit.xblocks.button.collapse-all', + defaultMessage: 'Collapse all', + }, + expandAllButton: { + id: 'course-authoring.course-unit.xblocks.button.expand-all', + defaultMessage: 'Expand all', + }, }); export default messages; diff --git a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx index 5f78ae7617..e78a93a7e8 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx @@ -4,7 +4,7 @@ import { Button } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Divider } from '../../../../generic/divider'; -import { getCanEdit, getCourseUnitData } from '../../../data/selectors'; +import { getCourseUnitData } from '../../../data/selectors'; import { copyToClipboard } from '../../../../generic/data/thunks'; import messages from '../../messages'; @@ -17,7 +17,6 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => { hasChanges, enableCopyPasteUnits, } = useSelector(getCourseUnitData); - const canEdit = useSelector(getCanEdit); return ( <> @@ -36,7 +35,7 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => { {intl.formatMessage(messages.actionButtonDiscardChangesTitle)} )} - {enableCopyPasteUnits && canEdit && ( + {enableCopyPasteUnits && ( <>