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..a2b4788039 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -164,7 +164,8 @@ const CourseUnit = ({ courseId }) => { strategy={verticalListSortingStrategy} > {unitXBlocks.map(({ - name, id, blockType: type, shouldScroll, userPartitionInfo, validationMessages, + name, id, blockType: type, renderError, shouldScroll, + userPartitionInfo, validationMessages, actions, }) => ( { title={name} type={type} blockId={blockId} + renderError={renderError} validationMessages={validationMessages} shouldScroll={shouldScroll} handleConfigureSubmit={handleConfigureSubmit} unitXBlockActions={unitXBlockActions} data-testid="course-xblock" userPartitionInfo={userPartitionInfo} + actions={actions} /> ))} diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 95116cc1a3..c9968d6825 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -966,6 +966,153 @@ describe('', () => { )).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 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: { + ...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 }); + 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 +1299,7 @@ describe('', () => { selected_partition_index: -1, selected_groups_label: '', }, + actions: courseVerticalChildrenMock.children[0].actions, }, ], }); 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..304c512687 100644 --- a/src/course-unit/course-xblock/CourseXBlock.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.jsx @@ -3,30 +3,43 @@ 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, Sheet, } from '@openedx/paragon'; import { EditOutline as EditIcon, MoreVert as MoveVertIcon } 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 ContentTagsDrawer from '../../content-tags-drawer/ContentTagsDrawer'; +import { useContentTagsCount } from '../../generic/data/apiHooks'; +import TagCount from '../../generic/tag-count'; 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 { + getCanEdit, + 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'; const CourseXBlock = ({ id, title, type, unitXBlockActions, shouldScroll, userPartitionInfo, - handleConfigureSubmit, validationMessages, ...props + handleConfigureSubmit, validationMessages, renderError, actions, ...props }) => { const courseXBlockElementRef = useRef(null); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); + const [isManageTagsOpen, openManageTagsModal, closeManageTagsModal] = useToggle(false); const dispatch = useDispatch(); const navigate = useNavigate(); const canEdit = useSelector(getCanEdit); @@ -36,11 +49,25 @@ const CourseXBlock = ({ const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); const isScrolledToElement = locatorId === id; + const { + canCopy, canDelete, canDuplicate, canManageAccess, canManageTags, canMove, + } = actions; + + const { + data: contentTaxonomyTagsCount, + isSuccess: isContentTaxonomyTagsCountLoaded, + } = useContentTagsCount(id || ''); + const xblockIFrameHtmlAndResources = useSelector(getXBlockIFrameHtmlAndResources); + const xblockInstanceHtmlAndResources = find(xblockIFrameHtmlAndResources, { xblockId: id }); const visibilityMessage = userPartitionInfo.selectedGroupsLabel ? intl.formatMessage(messages.visibilityMessage, { selectedGroupsLabel: userPartitionInfo.selectedGroupsLabel }) : null; + useEffect(() => { + dispatch(fetchXBlockIFrameHtmlAndResourcesQuery(id)); + }, []); + const currentItemData = { category: COURSE_BLOCK_NAMES.component.id, displayName: title, @@ -95,6 +122,12 @@ const CourseXBlock = ({ subtitle={visibilityMessage} actions={( + { + canManageTags + && isContentTaxonomyTagsCountLoaded + && contentTaxonomyTagsCount > 0 + &&
+ } - unitXBlockActions.handleDuplicate(id)}> - {intl.formatMessage(messages.blockLabelButtonDuplicate)} - - - {intl.formatMessage(messages.blockLabelButtonMove)} - - {canEdit && ( + {canManageTags && ( + + {intl.formatMessage(messages.blockLabelButtonManageTags)} + + )} + {canEdit && canCopy && ( dispatch(copyToClipboard(id))}> {intl.formatMessage(messages.blockLabelButtonCopyToClipboard)} )} - - {intl.formatMessage(messages.blockLabelButtonManageAccess)} - - - {intl.formatMessage(messages.blockLabelButtonDelete)} - + {canDuplicate && ( + unitXBlockActions.handleDuplicate(id)}> + {intl.formatMessage(messages.blockLabelButtonDuplicate)} + + )} + {canMove && ( + + {intl.formatMessage(messages.blockLabelButtonMove)} + + )} + {canManageAccess && ( + + {intl.formatMessage(messages.blockLabelButtonManageAccess)} + + )} + {canDelete && ( + + {intl.formatMessage(messages.blockLabelButtonDelete)} + + )} + + +
)} /> - -
+ {renderError ? : ( + <> + + {xblockInstanceHtmlAndResources && ( + + )} + + )}
@@ -156,12 +221,14 @@ const CourseXBlock = ({ 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 +254,14 @@ 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, + canManageTags: PropTypes.bool, + canMove: 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..6e5c4b516b 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) { diff --git a/src/course-unit/course-xblock/CourseXBlock.test.jsx b/src/course-unit/course-xblock/CourseXBlock.test.jsx index ad8e09184b..e03348a21d 100644 --- a/src/course-unit/course-xblock/CourseXBlock.test.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.test.jsx @@ -8,6 +8,7 @@ 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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import configureModalMessages from '../../generic/configure-modal/messages'; import deleteModalMessages from '../../generic/delete-modal/messages'; @@ -18,6 +19,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 +36,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'), @@ -51,21 +55,33 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); +const mockGetTagsCount = jest.fn(); + +jest.mock('../../generic/data/api', () => ({ + ...jest.requireActual('../../generic/data/api'), + getTagsCount: () => mockGetTagsCount(), +})); + +const queryClient = new QueryClient(); + const renderComponent = (props) => render( - - - + + + + + , ); @@ -310,4 +326,44 @@ describe('', () => { expect(getByText(visibilityMessage)).toBeInTheDocument(); }); }); + + 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(); + }); + }); + + it('displays a render error message if item has error', () => { + const renderErrorMessage = 'Some error message'; + const { getByText, getByLabelText, queryByTestId } = renderComponent( + { + renderError: renderErrorMessage, + }, + ); + + 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..0d4c76f030 100644 --- a/src/course-unit/course-xblock/constants.js +++ b/src/course-unit/course-xblock/constants.js @@ -1,5 +1,29 @@ -// 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, +}); diff --git a/src/course-unit/course-xblock/messages.js b/src/course-unit/course-xblock/messages.js index 3e1652de19..2363174c6e 100644 --- a/src/course-unit/course-xblock/messages.js +++ b/src/course-unit/course-xblock/messages.js @@ -50,6 +50,11 @@ const messages = defineMessages({ defaultMessage: 'This component has validation issues.', description: 'The alert text of the visibility validation issues', }, + blockLabelButtonManageTags: { + id: 'course-authoring.course-unit.xblock.button.manageTags.label', + defaultMessage: 'Manage tags', + description: 'The xblock manage tags button text', + }, }); 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/xblock-content/XBlockContent.jsx b/src/course-unit/course-xblock/xblock-content/XBlockContent.jsx new file mode 100644 index 0000000000..39ec9e569c --- /dev/null +++ b/src/course-unit/course-xblock/xblock-content/XBlockContent.jsx @@ -0,0 +1,144 @@ +import { useRef, useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { ensureConfig, getConfig } from '@edx/frontend-platform'; + +import { LoadingSpinner } from '../../../generic/Loading'; +import { COMPONENT_TYPES } from '../../constants'; +import { blockViewShape, fetchable, IFRAME_FEATURE_POLICY } from '../constants'; +import { wrapBlockHtmlForIFrame } from './iframe-wrapper'; + +ensureConfig(['STUDIO_BASE_URL', 'SECURE_ORIGIN_XBLOCK_BOOTSTRAP_HTML_URL'], 'studio xblock component'); + +const XBlockContent = ({ + view, type, getHandlerUrl, onBlockNotification, +}) => { + const iframeRef = useRef(null); + const [html, setHtml] = useState(null); + const [iframeHeight, setIFrameHeight] = useState(0); + const [iframeKey, setIFrameKey] = useState(0); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const processView = () => { + if (view.html) { + const newHtml = wrapBlockHtmlForIFrame( + view.html, + view.resources, + getConfig().STUDIO_BASE_URL, + type, + ); + + // Load the XBlock HTML into the IFrame: + // iframe will only re-render in React when its property changes (key here) + setHtml(newHtml); + setIFrameKey(prevKey => prevKey + 1); + } + }; + + // Process the XBlock view: + processView(); + }, [view, type]); + + useEffect(() => { + // Handle any messages we receive from the XBlock Runtime code in the IFrame. + // See iframe-wrapper.js to see the code that sends these messages. + const receivedWindowMessage = async (event) => { + if (iframeRef.current === null || event.source !== iframeRef.current.contentWindow) { + return; + } // This is some other random message. + + const { method, replyKey, ...args } = event.data; + + const frame = iframeRef.current.contentWindow; + + const sendReply = async (data) => { + frame.postMessage({ ...data, replyKey }, '*'); + }; + + if (method === 'bootstrap') { + await sendReply({ initialHtml: html }); + } else if (method === 'get_handler_url') { + const handlerUrl = await getHandlerUrl(args.usageId); + await sendReply({ handlerUrl }); + } else if (method === 'update_frame_height') { + setIFrameHeight(args.height); + } else if (method?.indexOf('xblock:') === 0) { + if (onBlockNotification) { + // This is a notification from the XBlock's frontend via 'runtime.notify(event, args)' + onBlockNotification({ + eventType: method.substr('xblock'.length), // Remove the 'xblock:' prefix that we added in iframe-wrapper.ts + ...args, + }); + } + } + }; + + // Prepare to receive messages from the IFrame. + // Messages are the only way that the code in the IFrame can communicate + // with the surrounding UI. + window.addEventListener('message', receivedWindowMessage); + + return () => { + window.removeEventListener('message', receivedWindowMessage); + }; + }, [html, getHandlerUrl, onBlockNotification]); + + /* Only draw the iframe if the HTML has already been set. This is because xblock-bootstrap.html will only request + * HTML once, upon being rendered. */ + if (html === null) { + return null; + } + + return ( +
+ {isLoading && ( +
+ +
+ )} +
+