diff --git a/src/course-libraries/index.tsx b/src/course-libraries/index.tsx index c5fd05834..d6dd94ae0 100644 --- a/src/course-libraries/index.tsx +++ b/src/course-libraries/index.tsx @@ -1 +1,2 @@ export { CourseLibraries } from './CourseLibraries'; +export { courseLibrariesQueryKeys } from './data/apiHooks'; diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index 5ee8c8fd0..d5003f9ea 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -11,6 +11,7 @@ import pasteButtonMessages from '@src/generic/clipboard/paste-component/messages import { getApiBaseUrl, getClipboardUrl } from '@src/generic/data/api'; import { postXBlockBaseApiUrl } from '@src/course-unit/data/api'; import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; +import { getDownstreamApiUrl } from '@src/generic/unlink-modal/data/api'; import { act, fireEvent, initializeMocks, render, screen, waitFor, within, } from '@src/testUtils'; @@ -2440,4 +2441,46 @@ describe('', () => { expect(outlineIndexLoadingStatus).toEqual(RequestStatus.DENIED); }); }); + + it('can unlink library block', async () => { + axiosMock + .onGet(getCourseOutlineIndexApiUrl(courseId)) + .reply(200, courseOutlineIndexWithoutSections); + + renderComponent(); + + axiosMock + .onPost(getXBlockBaseApiUrl()) + .reply(200, { + locator: courseSectionMock.id, + }); + axiosMock + .onGet(getXBlockApiUrl(courseSectionMock.id)) + .reply(200, { + ...courseSectionMock, + actions: { + ...courseSectionMock.actions, + unlinkable: true, + }, + }); + const newSectionButton = (await screen.findAllByRole('button', { name: 'New section' }))[0]; + fireEvent.click(newSectionButton); + + const element = await screen.findByTestId('section-card'); + expect(element).toBeInTheDocument(); + + axiosMock.onDelete(getDownstreamApiUrl(courseSectionMock.id)).reply(200); + + const menu = await within(element).findByTestId('section-card-header__menu-button'); + fireEvent.click(menu); + const unlinkButton = await within(element).findByRole('button', { name: 'Unlink from Library' }); + fireEvent.click(unlinkButton); + const confirmButton = await screen.findByRole('button', { name: 'Confirm Unlink' }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(axiosMock.history.delete).toHaveLength(1); + }); + expect(axiosMock.history.delete[0].url).toBe(getDownstreamApiUrl(courseSectionMock.id)); + }); }); diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 0aec6cb48..76d5b0d9b 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -27,6 +27,7 @@ import ProcessingNotification from '@src/generic/processing-notification'; import InternetConnectionAlert from '@src/generic/internet-connection-alert'; import DeleteModal from '@src/generic/delete-modal/DeleteModal'; import ConfigureModal from '@src/generic/configure-modal/ConfigureModal'; +import { UnlinkModal } from '@src/generic/unlink-modal'; import AlertMessage from '@src/generic/alert-message'; import getPageHeadTitle from '@src/generic/utils'; import CourseOutlineHeaderActionsSlot from '@src/plugin-slots/CourseOutlineHeaderActionsSlot'; @@ -90,13 +91,16 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => { isPublishModalOpen, isConfigureModalOpen, isDeleteModalOpen, + isUnlinkModalOpen, closeHighlightsModal, closePublishModal, handleConfigureModalClose, closeDeleteModal, + closeUnlinkModal, openPublishModal, openConfigureModal, openDeleteModal, + openUnlinkModal, headerNavigationsActions, openEnableHighlightsModal, closeEnableHighlightsModal, @@ -111,6 +115,7 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => { handlePublishItemSubmit, handleEditSubmit, handleDeleteItemSubmit, + handleUnlinkItemSubmit, handleDuplicateSectionSubmit, handleDuplicateSubsectionSubmit, handleDuplicateUnitSubmit, @@ -168,7 +173,9 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => { } = useSelector(getProcessingNotification); const currentItemData = useSelector(getCurrentItem); - const deleteCategory = COURSE_BLOCK_NAMES[currentItemData.category]?.name.toLowerCase(); + + const itemCategory = currentItemData?.category; + const itemCategoryName = COURSE_BLOCK_NAMES[itemCategory]?.name.toLowerCase(); const enableProctoredExams = useSelector(getProctoredExamsFlag); const enableTimedExams = useSelector(getTimedExamsFlag); @@ -372,6 +379,7 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => { onOpenPublishModal={openPublishModal} onOpenConfigureModal={openConfigureModal} onOpenDeleteModal={openDeleteModal} + onOpenUnlinkModal={openUnlinkModal} onEditSectionSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateSectionSubmit} isSectionsExpanded={isSectionsExpanded} @@ -403,6 +411,7 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => { savingStatus={savingStatus} onOpenPublishModal={openPublishModal} onOpenDeleteModal={openDeleteModal} + onOpenUnlinkModal={openUnlinkModal} onEditSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateSubsectionSubmit} onOpenConfigureModal={openConfigureModal} @@ -438,6 +447,7 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => { onOpenPublishModal={openPublishModal} onOpenConfigureModal={openConfigureModal} onOpenDeleteModal={openDeleteModal} + onOpenUnlinkModal={openUnlinkModal} onEditSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateUnitSubmit} getTitleLink={getUnitUrl} @@ -514,11 +524,18 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => { isSelfPaced={statusBarData.isSelfPaced} /> + ', () => { expect(onClickDeleteMock).toHaveBeenCalledTimes(1); }); + it('calls onClickUnlink when item is clicked', async () => { + renderComponent(); + + const menuButton = await screen.findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(menuButton)); + const unlinkMenuItem = await screen.findByText(messages.menuUnlink.defaultMessage); + await act(async () => fireEvent.click(unlinkMenuItem)); + expect(onClickUnlinkMock).toHaveBeenCalledTimes(1); + }); + it('calls onClickDuplicate when item is clicked', async () => { renderComponent(); @@ -377,4 +390,54 @@ describe('', () => { expect(mockClickSync).toHaveBeenCalled(); }); + + [null, undefined].forEach((unlinkable) => ( + it(`should not render unlink button if unlinkable action is ${unlinkable}`, async () => { + renderComponent({ + ...cardHeaderProps, + actions: { + ...cardHeaderProps.actions, + unlinkable, + }, + }); + + const menuButton = await screen.findByTestId('subsection-card-header__menu-button'); + fireEvent.click(menuButton); + + expect(screen.queryByText(messages.menuUnlink.defaultMessage)).not.toBeInTheDocument(); + }) + )); + + it('should render unlink button disabled if unlinkable action is False', async () => { + renderComponent({ + ...cardHeaderProps, + actions: { + ...cardHeaderProps.actions, + unlinkable: false, + }, + }); + const menuButton = await screen.findByTestId('subsection-card-header__menu-button'); + fireEvent.click(menuButton); + + const unlinkMenuItem = await screen.findByText(messages.menuUnlink.defaultMessage); + expect(unlinkMenuItem).toBeInTheDocument(); + expect(unlinkMenuItem).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should render unlink button disabled if unlinkable action is False', async () => { + renderComponent({ + ...cardHeaderProps, + actions: { + ...cardHeaderProps.actions, + unlinkable: true, + }, + }); + const menuButton = await screen.findByTestId('subsection-card-header__menu-button'); + fireEvent.click(menuButton); + + const unlinkMenuItem = await screen.findByText(messages.menuUnlink.defaultMessage); + fireEvent.click(unlinkMenuItem); + await act(async () => fireEvent.click(unlinkMenuItem)); + expect(onClickUnlinkMock).toHaveBeenCalled(); + }); }); diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index ed894036c..ba8870e3f 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -43,6 +43,7 @@ interface CardHeaderProps { closeForm: () => void; isDisabledEditField: boolean; onClickDelete: () => void; + onClickUnlink: () => void; onClickDuplicate: () => void; onClickMoveUp: () => void; onClickMoveDown: () => void; @@ -84,6 +85,7 @@ const CardHeader = ({ closeForm, isDisabledEditField, onClickDelete, + onClickUnlink, onClickDuplicate, onClickMoveUp, onClickMoveDown, @@ -282,9 +284,20 @@ const CardHeader = ({ )} + {((actions.unlinkable ?? null) !== null || actions.deletable) && } + {(actions.unlinkable ?? null) !== null && ( + + {intl.formatMessage(messages.menuUnlink)} + + )} {actions.deletable && ( diff --git a/src/course-outline/card-header/messages.js b/src/course-outline/card-header/messages.js index e39fbb178..dd32c3b42 100644 --- a/src/course-outline/card-header/messages.js +++ b/src/course-outline/card-header/messages.js @@ -61,6 +61,16 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.card.menu.delete', defaultMessage: 'Delete', }, + menuUnlink: { + id: 'course-authoring.course-outline.card.menu.unlink', + defaultMessage: 'Unlink from Library', + description: 'Unlink an item from the library', + }, + menuUnlinkDisabledTooltip: { + id: 'course-authoring.course-outline.card.menu.unlink.disabled-tooltip', + defaultMessage: 'Only the highest level library reference can be unlinked.', + description: 'Tooltip for disabled unlink option', + }, menuCopy: { id: 'course-authoring.course-outline.card.menu.copy', defaultMessage: 'Copy to clipboard', diff --git a/src/course-outline/data/slice.ts b/src/course-outline/data/slice.ts index 9e504ce50..e868eaf7e 100644 --- a/src/course-outline/data/slice.ts +++ b/src/course-outline/data/slice.ts @@ -40,6 +40,7 @@ const initialState = { currentItem: {}, actions: { deletable: true, + unlinkable: false, draggable: true, childAddable: true, duplicable: true, diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index f5fea7d97..4ad0210c9 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -8,6 +8,8 @@ import moment from 'moment'; import { getSavingStatus as getGenericSavingStatus } from '@src/generic/data/selectors'; import { useWaffleFlags } from '@src/data/apiHooks'; import { RequestStatus } from '@src/data/constants'; +import { useUnlinkDownstream } from '@src/generic/unlink-modal'; + import { COURSE_BLOCK_NAMES } from './constants'; import { addSection, @@ -102,6 +104,7 @@ const useCourseOutline = ({ courseId }) => { const [isPublishModalOpen, openPublishModal, closePublishModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + const [isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal] = useToggle(false); const [ isAddLibrarySectionModalOpen, openAddLibrarySectionModal, @@ -265,6 +268,19 @@ const useCourseOutline = ({ courseId }) => { closeDeleteModal(); }; + const { mutateAsync: unlinkDownstream } = useUnlinkDownstream(); + + const handleUnlinkItemSubmit = async () => { + // istanbul ignore if: this should never happen + if (!currentItem.id) { + return; + } + + await unlinkDownstream(currentItem.id); + dispatch(fetchCourseOutlineIndexQuery(courseId)); + closeUnlinkModal(); + }; + const handleDuplicateSectionSubmit = () => { dispatch(duplicateSectionQuery(currentSection.id, courseStructure.id)); }; @@ -382,7 +398,11 @@ const useCourseOutline = ({ courseId }) => { isDeleteModalOpen, closeDeleteModal, openDeleteModal, + isUnlinkModalOpen, + closeUnlinkModal, + openUnlinkModal, handleDeleteItemSubmit, + handleUnlinkItemSubmit, handleDuplicateSectionSubmit, handleDuplicateSubsectionSubmit, handleDuplicateUnitSubmit, diff --git a/src/course-outline/section-card/SectionCard.test.tsx b/src/course-outline/section-card/SectionCard.test.tsx index e57ca27ae..117ebe89c 100644 --- a/src/course-outline/section-card/SectionCard.test.tsx +++ b/src/course-outline/section-card/SectionCard.test.tsx @@ -86,6 +86,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render( onOpenPublishModal={jest.fn()} onOpenHighlightsModal={jest.fn()} onOpenDeleteModal={jest.fn()} + onOpenUnlinkModal={jest.fn()} onOpenConfigureModal={jest.fn()} savingStatus="" onEditSectionSubmit={onEditSectionSubmit} diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 475957acf..3bdafe683 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -41,6 +41,7 @@ interface SectionCardProps { onEditSectionSubmit: (itemId: string, sectionId: string, displayName: string) => void, savingStatus: string, onOpenDeleteModal: () => void, + onOpenUnlinkModal: () => void, onDuplicateSubmit: () => void, isSectionsExpanded: boolean, onNewSubsectionSubmit: (id: string) => void, @@ -64,6 +65,7 @@ const SectionCard = ({ onEditSectionSubmit, savingStatus, onOpenDeleteModal, + onOpenUnlinkModal, onDuplicateSubmit, isSectionsExpanded, onNewSubsectionSubmit, @@ -292,6 +294,7 @@ const SectionCard = ({ onClickConfigure={onOpenConfigureModal} onClickEdit={openForm} onClickDelete={onOpenDeleteModal} + onClickUnlink={onOpenUnlinkModal} onClickMoveUp={handleSectionMoveUp} onClickMoveDown={handleSectionMoveDown} onClickSync={openSyncModal} diff --git a/src/course-outline/subsection-card/SubsectionCard.test.tsx b/src/course-outline/subsection-card/SubsectionCard.test.tsx index 35582c817..c8c3576bd 100644 --- a/src/course-outline/subsection-card/SubsectionCard.test.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.test.tsx @@ -111,6 +111,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render( onOrderChange={jest.fn()} onOpenPublishModal={jest.fn()} onOpenDeleteModal={jest.fn()} + onOpenUnlinkModal={jest.fn()} onNewUnitSubmit={jest.fn()} onAddUnitFromLibrary={handleOnAddUnitFromLibrary} isCustomRelativeDatesActive={false} diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 5ceb79d83..8dda3ac94 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -42,6 +42,7 @@ interface SubsectionCardProps { onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void, savingStatus: string, onOpenDeleteModal: () => void, + onOpenUnlinkModal: () => void, onDuplicateSubmit: () => void, onNewUnitSubmit: (subsectionId: string) => void, onAddUnitFromLibrary: (options: { @@ -74,6 +75,7 @@ const SubsectionCard = ({ onEditSubmit, savingStatus, onOpenDeleteModal, + onOpenUnlinkModal, onDuplicateSubmit, onNewUnitSubmit, onAddUnitFromLibrary, @@ -293,6 +295,7 @@ const SubsectionCard = ({ onClickPublish={onOpenPublishModal} onClickEdit={openForm} onClickDelete={onOpenDeleteModal} + onClickUnlink={onOpenUnlinkModal} onClickMoveUp={handleSubsectionMoveUp} onClickMoveDown={handleSubsectionMoveDown} onClickConfigure={onOpenConfigureModal} diff --git a/src/course-outline/unit-card/UnitCard.test.tsx b/src/course-outline/unit-card/UnitCard.test.tsx index 5792ee603..d6d5c15b4 100644 --- a/src/course-outline/unit-card/UnitCard.test.tsx +++ b/src/course-outline/unit-card/UnitCard.test.tsx @@ -79,6 +79,7 @@ const renderComponent = (props?: object) => render( onOrderChange={jest.fn()} onOpenPublishModal={jest.fn()} onOpenDeleteModal={jest.fn()} + onOpenUnlinkModal={jest.fn()} onOpenConfigureModal={jest.fn()} savingStatus="" onEditSubmit={jest.fn()} diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index 6119c180b..69e9cbcf3 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -35,6 +35,7 @@ interface UnitCardProps { onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void, savingStatus: string; onOpenDeleteModal: () => void; + onOpenUnlinkModal: () => void; onDuplicateSubmit: () => void; getTitleLink: (locator: string) => string; index: number; @@ -61,6 +62,7 @@ const UnitCard = ({ onEditSubmit, savingStatus, onOpenDeleteModal, + onOpenUnlinkModal, onDuplicateSubmit, getTitleLink, onOrderChange, @@ -238,6 +240,7 @@ const UnitCard = ({ onClickConfigure={onOpenConfigureModal} onClickEdit={openForm} onClickDelete={onOpenDeleteModal} + onClickUnlink={onOpenUnlinkModal} onClickMoveUp={handleUnitMoveUp} onClickMoveDown={handleUnitMoveDown} onClickSync={openSyncModal} diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 60305c385..31c973255 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { - act, render, waitFor, within, screen, + act, fireEvent, render, waitFor, within, screen, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; @@ -15,6 +15,13 @@ import { import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { cloneDeep, set } from 'lodash'; +import { IFRAME_FEATURE_POLICY } from '@src/constants'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; +import pasteComponentMessages from '@src/generic/clipboard/paste-component/messages'; +import { getClipboardUrl } from '@src/generic/data/api'; +import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; +import { getDownstreamApiUrl } from '@src/generic/unlink-modal/data/api'; + import { getCourseSectionVerticalApiUrl, getCourseVerticalChildrenApiUrl, @@ -42,8 +49,6 @@ import { } from './__mocks__'; import { clipboardUnit } from '../__mocks__'; import { executeThunk } from '../utils'; -import { IFRAME_FEATURE_POLICY } from '../constants'; -import pasteComponentMessages from '../generic/clipboard/paste-component/messages'; import pasteNotificationsMessages from './clipboard/paste-notification/messages'; import headerTitleMessages from './header-title/messages'; import courseSequenceMessages from './course-sequence/messages'; @@ -51,18 +56,15 @@ import { extractCourseUnitId } from './sidebar/utils'; import CourseUnit from './CourseUnit'; import tagsDrawerMessages from '../content-tags-drawer/messages'; -import { getClipboardUrl } from '../generic/data/api'; import configureModalMessages from '../generic/configure-modal/messages'; import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api'; import addComponentMessages from './add-component/messages'; import { messageTypes, PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants'; -import { IframeProvider } from '../generic/hooks/context/iFrameContext'; import moveModalMessages from './move-modal/messages'; import xblockContainerIframeMessages from './xblock-container-iframe/messages'; import headerNavigationsMessages from './header-navigations/messages'; import sidebarMessages from './sidebar/messages'; import messages from './messages'; -import { mockWaffleFlags } from '../data/apiHooks.mock'; let axiosMock; let store; @@ -465,6 +467,35 @@ describe('', () => { }); }); + it('checks if the xblock unlink is called when the corresponding unlink button is clicked', async () => { + render(); + const usageId = courseVerticalChildrenMock.children[0].block_id; + axiosMock + .onDelete(getDownstreamApiUrl(usageId)) + .reply(200); + + await waitFor(() => { + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toBeInTheDocument(); + }); + + simulatePostMessageEvent(messageTypes.unlinkXBlock, { + usageId, + }); + expect(await screen.findByText(/Unlink this component?/i)).toBeInTheDocument(); + + const dialog = await screen.findByRole('dialog'); + // Find the Unlink button + const unlinkButton = await within(dialog).findByRole('button', { name: /confirm unlink/i }); + expect(unlinkButton).toBeInTheDocument(); + fireEvent.click(unlinkButton); + + await waitFor(() => { + expect(axiosMock.history.delete.length).toBe(1); + }); + expect(axiosMock.history.delete[0].url).toBe(getDownstreamApiUrl(usageId)); + }); + it('checks if xblock is a duplicate when the corresponding duplicate button is clicked and if the sidebar status is updated', async () => { const user = userEvent.setup(); render(); diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js index dcea2f603..2da4dee30 100644 --- a/src/course-unit/constants.js +++ b/src/course-unit/constants.js @@ -52,6 +52,7 @@ export const messageTypes = { manageXBlockAccess: 'manageXBlockAccess', completeManageXBlockAccess: 'completeManageXBlockAccess', deleteXBlock: 'deleteXBlock', + unlinkXBlock: 'unlinkXBlock', completeXBlockDeleting: 'completeXBlockDeleting', duplicateXBlock: 'duplicateXBlock', completeXBlockDuplicating: 'completeXBlockDuplicating', diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index b1e6cea33..dac48c538 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -6,10 +6,13 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { useToggle } from '@openedx/paragon'; import { camelCaseObject } from '@edx/frontend-platform/utils'; -import { RequestStatus } from '../data/constants'; -import { useClipboard } from '../generic/clipboard'; -import { useEventListener } from '../generic/hooks'; -import { COURSE_BLOCK_NAMES, iframeMessageTypes } from '../constants'; +import { useUnlinkDownstream } from '@src/generic/unlink-modal'; +import { RequestStatus } from '@src/data/constants'; +import { useClipboard } from '@src/generic/clipboard'; +import { useEventListener } from '@src/generic/hooks'; +import { useIframe } from '@src/generic/hooks/context/hooks'; +import { COURSE_BLOCK_NAMES, iframeMessageTypes } from '@src/constants'; + import { messageTypes, PUBLISH_TYPES } from './constants'; import { createNewCourseXBlock, @@ -40,7 +43,6 @@ import { updateMovedXBlockParams, updateQueryPendingStatus, } from './data/slice'; -import { useIframe } from '../generic/hooks/context/hooks'; export const useCourseUnit = ({ courseId, blockId }) => { const dispatch = useDispatch(); @@ -129,6 +131,8 @@ export const useCourseUnit = ({ courseId, blockId }) => { dispatch(createNewCourseXBlock(body, callback, blockId, sendMessageToIframe)) ); + const { mutateAsync: unlinkDownstream } = useUnlinkDownstream(); + const unitXBlockActions = { handleDelete: (XBlockId) => { dispatch(deleteUnitItemQuery(blockId, XBlockId, sendMessageToIframe)); @@ -140,6 +144,10 @@ export const useCourseUnit = ({ courseId, blockId }) => { (courseKey, locator) => sendMessageToIframe(messageTypes.completeXBlockDuplicating, { courseKey, locator }), )); }, + handleUnlink: async (XBlockId) => { + await unlinkDownstream(XBlockId); + dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType)); + }, }; const handleRollbackMovedXBlock = () => { diff --git a/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx b/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx index 0f4fe41ed..7c7826a01 100644 --- a/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx +++ b/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx @@ -44,6 +44,7 @@ describe('useMessageHandlers', () => { dispatch: jest.fn(), setIframeOffset: jest.fn(), handleDeleteXBlock: jest.fn(), + handleUnlinkXBlock: jest.fn(), handleDuplicateXBlock: jest.fn(), handleScrollToXBlock: jest.fn(), handleManageXBlockAccess: jest.fn(), diff --git a/src/course-unit/xblock-container-iframe/hooks/types.ts b/src/course-unit/xblock-container-iframe/hooks/types.ts index 3c54a90f2..9d06598fb 100644 --- a/src/course-unit/xblock-container-iframe/hooks/types.ts +++ b/src/course-unit/xblock-container-iframe/hooks/types.ts @@ -3,6 +3,7 @@ export type UseMessageHandlersTypes = { dispatch: (action: any) => void; setIframeOffset: (height: number) => void; handleDeleteXBlock: (usageId: string) => void; + handleUnlinkXBlock: (usageId: string) => void; handleScrollToXBlock: (scrollOffset: number) => void; handleDuplicateXBlock: (usageId: string) => void; handleEditXBlock: (blockType: string, usageId: string) => void; diff --git a/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx b/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx index daae1b822..afbab8a2a 100644 --- a/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx +++ b/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx @@ -20,6 +20,7 @@ export const useMessageHandlers = ({ setIframeOffset, handleDeleteXBlock, handleDuplicateXBlock, + handleUnlinkXBlock, handleScrollToXBlock, handleManageXBlockAccess, handleShowLegacyEditXBlockModal, @@ -36,6 +37,7 @@ export const useMessageHandlers = ({ return useMemo(() => ({ [messageTypes.copyXBlock]: ({ usageId }) => copyToClipboard(usageId), [messageTypes.deleteXBlock]: ({ usageId }) => handleDeleteXBlock(usageId), + [messageTypes.unlinkXBlock]: ({ usageId }) => handleUnlinkXBlock(usageId), [messageTypes.newXBlockEditor]: ({ blockType, usageId }) => handleEditXBlock(blockType, usageId), [messageTypes.duplicateXBlock]: ({ usageId }) => handleDuplicateXBlock(usageId), [messageTypes.manageXBlockAccess]: ({ usageId }) => handleManageXBlockAccess(usageId), @@ -62,6 +64,7 @@ export const useMessageHandlers = ({ }), [ courseId, handleDeleteXBlock, + handleUnlinkXBlock, handleDuplicateXBlock, handleManageXBlockAccess, handleScrollToXBlock, diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index d706de352..2507c2f8a 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -9,34 +9,36 @@ import { useDispatch } from 'react-redux'; import { hideProcessingNotification, showProcessingNotification, -} from '../../generic/processing-notification/data/slice'; -import DeleteModal from '../../generic/delete-modal/DeleteModal'; -import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; -import ModalIframe from '../../generic/modal-iframe'; -import { useWaffleFlags } from '../../data/apiHooks'; -import { IFRAME_FEATURE_POLICY } from '../../constants'; -import ContentTagsDrawer from '../../content-tags-drawer/ContentTagsDrawer'; -import { useIframe } from '../../generic/hooks/context/hooks'; +} from '@src/generic/processing-notification/data/slice'; +import DeleteModal from '@src/generic/delete-modal/DeleteModal'; +import ConfigureModal from '@src/generic/configure-modal/ConfigureModal'; +import ModalIframe from '@src/generic/modal-iframe'; +import { useWaffleFlags } from '@src/data/apiHooks'; +import { IFRAME_FEATURE_POLICY } from '@src/constants'; +import ContentTagsDrawer from '@src/content-tags-drawer/ContentTagsDrawer'; +import { useIframe } from '@src/generic/hooks/context/hooks'; +import { useIframeBehavior } from '@src/generic/hooks/useIframeBehavior'; +import { useIframeContent } from '@src/generic/hooks/useIframeContent'; +import { useIframeMessages } from '@src/generic/hooks/useIframeMessages'; +import { UnlinkModal } from '@src/generic/unlink-modal'; +import VideoSelectorPage from '@src/editors/VideoSelectorPage'; +import EditorPage from '@src/editors/EditorPage'; + +import { messageTypes } from '../constants'; import { fetchCourseSectionVerticalData, fetchCourseVerticalChildrenData, updateCourseUnitSidebar, } from '../data/thunk'; -import { messageTypes } from '../constants'; import { useMessageHandlers, } from './hooks'; +import messages from './messages'; import { XBlockContainerIframeProps, AccessManagedXBlockDataTypes, } from './types'; import { formatAccessManagedXBlockData, getIframeUrl, getLegacyEditModalUrl } from './utils'; -import messages from './messages'; -import { useIframeBehavior } from '../../generic/hooks/useIframeBehavior'; -import { useIframeContent } from '../../generic/hooks/useIframeContent'; -import { useIframeMessages } from '../../generic/hooks/useIframeMessages'; -import VideoSelectorPage from '../../editors/VideoSelectorPage'; -import EditorPage from '../../editors/EditorPage'; const XBlockContainerIframe: FC = ({ courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType, @@ -45,6 +47,7 @@ const XBlockContainerIframe: FC = ({ const dispatch = useDispatch(); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + const [isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle(); const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle(); @@ -54,6 +57,7 @@ const XBlockContainerIframe: FC = ({ const [accessManagedXBlockData, setAccessManagedXBlockData] = useState({}); const [iframeOffset, setIframeOffset] = useState(0); const [deleteXBlockId, setDeleteXBlockId] = useState(null); + const [unlinkXBlockId, setUnlinkXBlockId] = useState(null); const [configureXBlockId, setConfigureXBlockId] = useState(null); const [showLegacyEditModal, setShowLegacyEditModal] = useState(false); const [isManageTagsOpen, openManageTagsModal, closeManageTagsModal] = useToggle(false); @@ -98,6 +102,11 @@ const XBlockContainerIframe: FC = ({ openDeleteModal(); }; + const handleUnlinkXBlock = (usageId: string) => { + setUnlinkXBlockId(usageId); + openUnlinkModal(); + }; + const handleManageXBlockAccess = (usageId: string) => { openConfigureModal(); setConfigureXBlockId(usageId); @@ -114,6 +123,13 @@ const XBlockContainerIframe: FC = ({ } }; + const onUnlinkSubmit = () => { + if (unlinkXBlockId) { + unitXBlockActions.handleUnlink(unlinkXBlockId); + closeUnlinkModal(); + } + }; + const onManageXBlockAccessSubmit = (...args: any[]) => { if (configureXBlockId) { handleConfigureSubmit(configureXBlockId, ...args, closeConfigureModal); @@ -171,6 +187,7 @@ const XBlockContainerIframe: FC = ({ dispatch, setIframeOffset, handleDeleteXBlock, + handleUnlinkXBlock, handleDuplicateXBlock, handleManageXBlockAccess, handleScrollToXBlock, @@ -200,6 +217,12 @@ const XBlockContainerIframe: FC = ({ close={closeDeleteModal} onDeleteSubmit={onDeleteSubmit} /> + void; handleDuplicate: (XBlockId: string | null) => void; + handleUnlink: (XBlockId: string | null) => void; }; courseVerticalChildren: Array; handleConfigureSubmit: (XBlockId: string, ...args: any[]) => void; diff --git a/src/data/types.ts b/src/data/types.ts index c2ac2a469..dc41af3b1 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -33,6 +33,7 @@ export interface XBlockActions { draggable: boolean; childAddable: boolean; duplicable: boolean; + unlinkable?: boolean; allowMoveDown?: boolean; allowMoveUp?: boolean; } diff --git a/src/generic/key-utils.ts b/src/generic/key-utils.ts index 6fc9b6ec7..7504e362b 100644 --- a/src/generic/key-utils.ts +++ b/src/generic/key-utils.ts @@ -27,6 +27,20 @@ export function getLibraryId(usageKey: string): string { throw new Error(`Invalid usageKey: ${usageKey}`); } +/** + * Given a usage key like `block-v1:org:course:html:id`, get the course key + */ +export function getCourseKey(usageKey: string): string { + const [prefix] = usageKey?.split('@') || []; + const [blockType, courseInfo] = prefix?.split(':') || []; + const [org, course, run] = courseInfo?.split('+') || []; + + if (blockType === 'block-v1' && org && course && run) { + return `course-v1:${org}+${course}+${run}`; + } + throw new Error(`Invalid usageKey: ${usageKey}`); +} + /** Check if this is a course key */ export function isCourseKey(learningContextKey: string | undefined | null): learningContextKey is string { return typeof learningContextKey === 'string' && learningContextKey.startsWith('course-v1:'); diff --git a/src/generic/unlink-modal/UnlinkModal.test.jsx b/src/generic/unlink-modal/UnlinkModal.test.jsx new file mode 100644 index 000000000..2b3de7404 --- /dev/null +++ b/src/generic/unlink-modal/UnlinkModal.test.jsx @@ -0,0 +1,80 @@ +import { + fireEvent, + screen, + render as defaultRender, + waitFor, +} from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import { UnlinkModal } from './UnlinkModal'; +import messages from './messages'; + +const onUnlinkSubmitMock = jest.fn(); +const closeMock = jest.fn(); + +const renderforContainer = () => defaultRender( + + + , +); + +const renderforComponent = () => defaultRender( + + + , +); + +describe('', () => { + it('render UnlinkModal component correctly for containers', () => { + renderforContainer(); + + expect(screen.getByText('Unlink Introduction to Testing?')).toBeInTheDocument(); + expect(screen.getByText(/are you sure you want to unlink this library Section reference/i)).toBeInTheDocument(); + expect( + screen.getByText(/subsections contained in this Section will remain linked to their library versions./i), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.unlinkButton.defaultMessage })).toBeInTheDocument(); + }); + + it('render UnlinkModal component correctly for components', () => { + renderforComponent(); + + expect(screen.getByText('Unlink this component?')).toBeInTheDocument(); + expect(screen.getByText(/are you sure you want to unlink this library Component reference/i)).toBeInTheDocument(); + expect( + screen.queryByText(/will remain linked to their library versions./i), + ).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.unlinkButton.defaultMessage })).toBeInTheDocument(); + }); + + it('calls onDeleteSubmit function when the "Unlink" button is clicked', async () => { + renderforContainer(); + + const okButton = screen.getByRole('button', { name: messages.unlinkButton.defaultMessage }); + fireEvent.click(okButton); + waitFor(() => { + expect(onUnlinkSubmitMock).toHaveBeenCalledTimes(1); + }); + }); + + it('calls the close function when the "Cancel" button is clicked', async () => { + renderforContainer(); + + const cancelButton = screen.getByRole('button', { name: messages.cancelButton.defaultMessage }); + fireEvent.click(cancelButton); + expect(closeMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/generic/unlink-modal/UnlinkModal.tsx b/src/generic/unlink-modal/UnlinkModal.tsx new file mode 100644 index 000000000..55a513064 --- /dev/null +++ b/src/generic/unlink-modal/UnlinkModal.tsx @@ -0,0 +1,98 @@ +import { + ActionRow, + Button, + AlertModal, +} from '@openedx/paragon'; +import { Warning } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; +import LoadingButton from '../loading-button'; + +const BoldText = (chunk: string[]) => {chunk}; + +type UnlinkModalPropsContainer = { + displayName?: string; + category?: string; +}; + +type UnlinkModalPropsComponent = { + displayName?: undefined; + category: 'component'; +}; + +type UnlinkModalProps = { + isOpen: boolean; + close: () => void; + onUnlinkSubmit: () => void | Promise, +} & (UnlinkModalPropsContainer | UnlinkModalPropsComponent); + +export const UnlinkModal = ({ + displayName, + category, + isOpen, + close, + onUnlinkSubmit, +}: UnlinkModalProps) => { + const intl = useIntl(); + if (!category) { + // On the first render, the initial value for `category` might be undefined. + return null; + } + + const isComponent = category === 'component' as const; + + const categoryName = intl.formatMessage(messages[`${category}Name` as keyof typeof messages]); + const childrenCategoryName = !isComponent + ? intl.formatMessage(messages[`${category}ChildrenName` as keyof typeof messages]) + : undefined; + const modalTitle = !isComponent + ? intl.formatMessage(messages.title, { displayName }) + : intl.formatMessage(messages.titleComponent); + const modalDescription = intl.formatMessage(messages.description, { + categoryName, + b: BoldText, + }); + const modalDescriptionChildren = !isComponent ? intl.formatMessage(messages.descriptionChildren, { + categoryName, + childrenCategoryName, + }) : null; + + return ( + + + { + e.preventDefault(); + e.stopPropagation(); + await onUnlinkSubmit(); + }} + variant="primary" + label={intl.formatMessage(messages.unlinkButton)} + /> + + )} + > +
+

{modalDescription}

+

{modalDescriptionChildren}

+
+
+ ); +}; diff --git a/src/generic/unlink-modal/data/api.ts b/src/generic/unlink-modal/data/api.ts new file mode 100644 index 000000000..ab4856f70 --- /dev/null +++ b/src/generic/unlink-modal/data/api.ts @@ -0,0 +1,11 @@ +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; +export const getDownstreamApiUrl = (downstreamBlockId: string) => ( + `${getApiBaseUrl()}/api/contentstore/v2/downstreams/${downstreamBlockId}` +); + +export const unlinkDownstream = async (downstreamBlockId: string): Promise => { + await getAuthenticatedHttpClient().delete(getDownstreamApiUrl(downstreamBlockId)); +}; diff --git a/src/generic/unlink-modal/data/apiHooks.ts b/src/generic/unlink-modal/data/apiHooks.ts new file mode 100644 index 000000000..8cf7639cc --- /dev/null +++ b/src/generic/unlink-modal/data/apiHooks.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { courseLibrariesQueryKeys } from '@src/course-libraries'; +import { getCourseKey } from '@src/generic/key-utils'; + +import { unlinkDownstream } from './api'; + +export const useUnlinkDownstream = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: unlinkDownstream, + onSuccess: (_, contentId: string) => { + const courseKey = getCourseKey(contentId); + queryClient.invalidateQueries({ + queryKey: courseLibrariesQueryKeys.courseLibraries(courseKey), + }); + }, + }); +}; diff --git a/src/generic/unlink-modal/index.tsx b/src/generic/unlink-modal/index.tsx new file mode 100644 index 000000000..60faf2404 --- /dev/null +++ b/src/generic/unlink-modal/index.tsx @@ -0,0 +1,2 @@ +export { UnlinkModal } from './UnlinkModal'; +export { useUnlinkDownstream } from './data/apiHooks'; diff --git a/src/generic/unlink-modal/messages.ts b/src/generic/unlink-modal/messages.ts new file mode 100644 index 000000000..ae60927d0 --- /dev/null +++ b/src/generic/unlink-modal/messages.ts @@ -0,0 +1,75 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + title: { + id: 'course-authoring.course-outline.unlink-modal.title', + defaultMessage: 'Unlink {displayName}?', + description: 'Title for the unlink confirmation modal', + }, + titleComponent: { + id: 'course-authoring.course-outline.unlink-modal.title-component', + defaultMessage: 'Unlink this component?', + description: 'Title for the unlink confirmation modal when the item is a component', + }, + description: { + id: 'course-authoring.course-outline.unlink-modal.description', + defaultMessage: 'Are you sure you want to unlink this library {categoryName} reference?' + + ' Unlinked blocks cannot be synced. Unlinking is permanent.', + description: 'Description text in the unlink confirmation modal', + }, + descriptionChildren: { + id: 'course-authoring.course-outline.unlink-modal.description-children', + defaultMessage: '{childrenCategoryName} contained in this {categoryName} will remain linked to ' + + 'their library versions.', + description: 'Description text in the unlink confirmation modal when the item has children', + }, + unlinkButton: { + id: 'course-authoring.course-outline.unlink-modal.button.unlink', + defaultMessage: 'Confirm Unlink', + }, + pendingDeleteButton: { + id: 'course-authoring.course-outline.unlink-modal.button.pending-unlink', + defaultMessage: 'Unlinking', + }, + cancelButton: { + id: 'course-authoring.course-outline.unlink-modal.button.cancel', + defaultMessage: 'Cancel', + }, + chapterName: { + id: 'course-authoring.course-outline.unlink-modal.chapter-name', + defaultMessage: 'Section', + description: 'Used to refer to a chapter in the course outline', + }, + sequentialName: { + id: 'course-authoring.course-outline.unlink-modal.sequential-name', + defaultMessage: 'Subsection', + description: 'Used to refer to a sequential in the course outline', + }, + verticalName: { + id: 'course-authoring.course-outline.unlink-modal.vertical-name', + defaultMessage: 'Unit', + description: 'Used to refer to a vertical in the course outline', + }, + componentName: { + id: 'course-authoring.course-outline.unlink-modal.component-name', + defaultMessage: 'Component', + description: 'Used to refer to a component in the course outline', + }, + chapterChildrenName: { + id: 'course-authoring.course-outline.unlink-modal.chapter-children-name', + defaultMessage: 'Subsections', + description: 'Used to refer to chapter children in the course outline', + }, + sequentialChildrenName: { + id: 'course-authoring.course-outline.unlink-modal.sequential-children-name', + defaultMessage: 'Units', + description: 'Used to refer to sequential children in the course outline', + }, + verticalChildrenName: { + id: 'course-authoring.course-outline.unlink-modal.vertical-children-name', + defaultMessage: 'Components', + description: 'Used to refer to vertical children in the course outline', + }, +}); + +export default messages;