diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 6857626d1c..46962ab673 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -159,13 +159,15 @@ const CourseUnit = ({ courseId }) => { strategy={verticalListSortingStrategy} > {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} /> ))} diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 91e8a2f51a..6fbe432cf5 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -599,6 +599,7 @@ describe('', () => { block_id: '1234567890', block_type: 'drag-and-drop-v2', user_partition_info: {}, + actions: courseVerticalChildrenMock.children[0].actions, }, ], }); @@ -971,6 +972,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; @@ -1175,6 +1323,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 394fd22e87..c71fd9f6a6 100644 --- a/src/course-unit/course-xblock/CourseXBlock.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.jsx @@ -3,13 +3,16 @@ 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 { 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'; @@ -22,11 +25,12 @@ import messages from './messages'; const CourseXBlock = ({ id, title, type, unitXBlockActions, shouldScroll, userPartitionInfo, - handleConfigureSubmit, validationMessages, ...props + handleConfigureSubmit, validationMessages, 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); @@ -37,6 +41,15 @@ const CourseXBlock = ({ 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 visibilityMessage = userPartitionInfo.selectedGroupsLabel ? intl.formatMessage(messages.visibilityMessage, { selectedGroupsLabel: userPartitionInfo.selectedGroupsLabel }) : null; @@ -95,6 +108,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)} + + )} + + +
)} /> @@ -187,6 +228,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.test.jsx b/src/course-unit/course-xblock/CourseXBlock.test.jsx index ad8e09184b..4b69bf0be8 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'; @@ -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'), @@ -51,21 +54,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( - - - + + + + + , ); diff --git a/src/course-unit/course-xblock/messages.js b/src/course-unit/course-xblock/messages.js index 3e1652de19..acb6e220dd 100644 --- a/src/course-unit/course-xblock/messages.js +++ b/src/course-unit/course-xblock/messages.js @@ -50,6 +50,10 @@ 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', + }, }); export default messages; diff --git a/src/generic/tag-count/TagCount.test.jsx b/src/generic/tag-count/TagCount.test.jsx index bb88d44a52..f583c51435 100644 --- a/src/generic/tag-count/TagCount.test.jsx +++ b/src/generic/tag-count/TagCount.test.jsx @@ -1,20 +1,28 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + import TagCount from '.'; +const renderComponent = (props) => render( + + , + , +); + describe('', () => { it('should render the component', () => { - render(); + renderComponent({ count: 17 }); expect(screen.getByText('17')).toBeInTheDocument(); }); it('should render the component with zero', () => { - render(); + renderComponent({ count: 0 }); expect(screen.getByText('0')).toBeInTheDocument(); }); it('should render a button with onClick', () => { - render( {}} />); + renderComponent({ count: 17, onClick: () => {} }); expect(screen.getByRole('button', { name: /17/i, })); diff --git a/src/generic/tag-count/index.jsx b/src/generic/tag-count/index.jsx index bb6dada9d7..5827072812 100644 --- a/src/generic/tag-count/index.jsx +++ b/src/generic/tag-count/index.jsx @@ -1,9 +1,16 @@ import PropTypes from 'prop-types'; -import { Icon, Button } from '@openedx/paragon'; +import { + Icon, Button, OverlayTrigger, Tooltip, +} from '@openedx/paragon'; import { Tag } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; import classNames from 'classnames'; +import messages from './messages'; + const TagCount = ({ count, onClick }) => { + const intl = useIntl(); + const renderContent = () => ( <> @@ -17,9 +24,19 @@ const TagCount = ({ count, onClick }) => { } > { onClick ? ( - + + {intl.formatMessage(messages.tooltipText)} + + )} + > + + + ) : renderContent()} diff --git a/src/generic/tag-count/messages.js b/src/generic/tag-count/messages.js new file mode 100644 index 0000000000..fcdf61504b --- /dev/null +++ b/src/generic/tag-count/messages.js @@ -0,0 +1,10 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + tooltipText: { + id: 'course-authoring.generic.tag-count.tooltip.text', + defaultMessage: 'Manage tags', + }, +}); + +export default messages;