feat: add unlink upstream menu [FC-0097] (#2393)

Adds the Unlink feature to the Course Outline for Sections, Subsections and Units.
This commit is contained in:
Rômulo Penido
2025-08-28 13:44:15 -03:00
committed by GitHub
parent 0f2dd4a88f
commit 950bfee7c1
31 changed files with 584 additions and 29 deletions

View File

@@ -1 +1,2 @@
export { CourseLibraries } from './CourseLibraries';
export { courseLibrariesQueryKeys } from './data/apiHooks';

View File

@@ -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('<CourseOutline />', () => {
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));
});
});

View File

@@ -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}
/>
<DeleteModal
category={deleteCategory}
category={itemCategoryName}
isOpen={isDeleteModalOpen}
close={closeDeleteModal}
onDeleteSubmit={handleDeleteItemSubmit}
/>
<UnlinkModal
displayName={currentItemData?.displayName}
category={itemCategory}
isOpen={isUnlinkModalOpen}
close={closeUnlinkModal}
onUnlinkSubmit={handleUnlinkItemSubmit}
/>
<StandardModal
title={intl.formatMessage(messages.sectionPickerModalTitle)}
isOpen={isAddLibrarySectionModalOpen}

View File

@@ -37,4 +37,11 @@
opacity: 1;
}
}
.allow-hover-on-disabled {
&.disabled {
pointer-events: auto;
cursor: default;
}
}
}

View File

@@ -13,6 +13,7 @@ const onClickMenuButtonMock = jest.fn();
const onClickPublishMock = jest.fn();
const onClickEditMock = jest.fn();
const onClickDeleteMock = jest.fn();
const onClickUnlinkMock = jest.fn();
const onClickDuplicateMock = jest.fn();
const onClickConfigureMock = jest.fn();
const onClickMoveUpMock = jest.fn();
@@ -39,6 +40,7 @@ const cardHeaderProps = {
closeForm: closeFormMock,
isDisabledEditField: false,
onClickDelete: onClickDeleteMock,
onClickUnlink: onClickUnlinkMock,
onClickDuplicate: onClickDuplicateMock,
onClickConfigure: onClickConfigureMock,
onClickMoveUp: onClickMoveUpMock,
@@ -50,6 +52,7 @@ const cardHeaderProps = {
childAddable: true,
deletable: true,
duplicable: true,
unlinkable: true,
},
};
@@ -273,6 +276,16 @@ describe('<CardHeader />', () => {
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('<CardHeader />', () => {
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();
});
});

View File

@@ -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 = ({
</Dropdown.Item>
</>
)}
{((actions.unlinkable ?? null) !== null || actions.deletable) && <Dropdown.Divider />}
{(actions.unlinkable ?? null) !== null && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-unlink-button`}
onClick={onClickUnlink}
disabled={!actions.unlinkable}
className="allow-hover-on-disabled"
title={!actions.unlinkable ? intl.formatMessage(messages.menuUnlinkDisabledTooltip) : undefined}
>
{intl.formatMessage(messages.menuUnlink)}
</Dropdown.Item>
)}
{actions.deletable && (
<Dropdown.Item
className="border-top border-light"
data-testid={`${namePrefix}-card-header__menu-delete-button`}
onClick={onClickDelete}
>

View File

@@ -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',

View File

@@ -40,6 +40,7 @@ const initialState = {
currentItem: {},
actions: {
deletable: true,
unlinkable: false,
draggable: true,
childAddable: true,
duplicable: true,

View File

@@ -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,

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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()}

View File

@@ -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}

View File

@@ -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('<CourseUnit />', () => {
});
});
it('checks if the xblock unlink is called when the corresponding unlink button is clicked', async () => {
render(<RootWrapper />);
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(<RootWrapper />);

View File

@@ -52,6 +52,7 @@ export const messageTypes = {
manageXBlockAccess: 'manageXBlockAccess',
completeManageXBlockAccess: 'completeManageXBlockAccess',
deleteXBlock: 'deleteXBlock',
unlinkXBlock: 'unlinkXBlock',
completeXBlockDeleting: 'completeXBlockDeleting',
duplicateXBlock: 'duplicateXBlock',
completeXBlockDuplicating: 'completeXBlockDuplicating',

View File

@@ -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 = () => {

View File

@@ -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(),

View File

@@ -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;

View File

@@ -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,

View File

@@ -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<XBlockContainerIframeProps> = ({
courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType,
@@ -45,6 +47,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
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<XBlockContainerIframeProps> = ({
const [accessManagedXBlockData, setAccessManagedXBlockData] = useState<AccessManagedXBlockDataTypes | {}>({});
const [iframeOffset, setIframeOffset] = useState(0);
const [deleteXBlockId, setDeleteXBlockId] = useState<string | null>(null);
const [unlinkXBlockId, setUnlinkXBlockId] = useState<string | null>(null);
const [configureXBlockId, setConfigureXBlockId] = useState<string | null>(null);
const [showLegacyEditModal, setShowLegacyEditModal] = useState<boolean>(false);
const [isManageTagsOpen, openManageTagsModal, closeManageTagsModal] = useToggle(false);
@@ -98,6 +102,11 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
openDeleteModal();
};
const handleUnlinkXBlock = (usageId: string) => {
setUnlinkXBlockId(usageId);
openUnlinkModal();
};
const handleManageXBlockAccess = (usageId: string) => {
openConfigureModal();
setConfigureXBlockId(usageId);
@@ -114,6 +123,13 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
}
};
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<XBlockContainerIframeProps> = ({
dispatch,
setIframeOffset,
handleDeleteXBlock,
handleUnlinkXBlock,
handleDuplicateXBlock,
handleManageXBlockAccess,
handleScrollToXBlock,
@@ -200,6 +217,12 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
close={closeDeleteModal}
onDeleteSubmit={onDeleteSubmit}
/>
<UnlinkModal
category="component"
isOpen={isUnlinkModalOpen}
close={closeUnlinkModal}
onUnlinkSubmit={onUnlinkSubmit}
/>
<StandardModal
title={intl.formatMessage(messages.videoPickerModalTitle)}
isOpen={isVideoSelectorModalOpen}

View File

@@ -33,6 +33,7 @@ export interface XBlockContainerIframeProps {
unitXBlockActions: {
handleDelete: (XBlockId: string | null) => void;
handleDuplicate: (XBlockId: string | null) => void;
handleUnlink: (XBlockId: string | null) => void;
};
courseVerticalChildren: Array<XBlockTypes>;
handleConfigureSubmit: (XBlockId: string, ...args: any[]) => void;

View File

@@ -33,6 +33,7 @@ export interface XBlockActions {
draggable: boolean;
childAddable: boolean;
duplicable: boolean;
unlinkable?: boolean;
allowMoveDown?: boolean;
allowMoveUp?: boolean;
}

View File

@@ -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:');

View File

@@ -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(
<IntlProvider locale="en">
<UnlinkModal
isOpen
close={closeMock}
category="chapter"
displayName="Introduction to Testing"
onDeleteSubmit={onUnlinkSubmitMock}
/>
</IntlProvider>,
);
const renderforComponent = () => defaultRender(
<IntlProvider locale="en">
<UnlinkModal
isOpen
close={closeMock}
category="component"
onDeleteSubmit={onUnlinkSubmitMock}
/>
</IntlProvider>,
);
describe('<UnlinkModal />', () => {
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);
});
});

View File

@@ -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[]) => <b>{chunk}</b>;
type UnlinkModalPropsContainer = {
displayName?: string;
category?: string;
};
type UnlinkModalPropsComponent = {
displayName?: undefined;
category: 'component';
};
type UnlinkModalProps = {
isOpen: boolean;
close: () => void;
onUnlinkSubmit: () => void | Promise<void>,
} & (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 (
<AlertModal
title={modalTitle}
isOpen={isOpen}
onClose={close}
variant="warning"
icon={Warning}
footerNode={(
<ActionRow>
<Button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
close();
}}
variant="tertiary"
>
{intl.formatMessage(messages.cancelButton)}
</Button>
<LoadingButton
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
await onUnlinkSubmit();
}}
variant="primary"
label={intl.formatMessage(messages.unlinkButton)}
/>
</ActionRow>
)}
>
<div>
<p className="mt-2">{modalDescription}</p>
<p>{modalDescriptionChildren}</p>
</div>
</AlertModal>
);
};

View File

@@ -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<void> => {
await getAuthenticatedHttpClient().delete(getDownstreamApiUrl(downstreamBlockId));
};

View File

@@ -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),
});
},
});
};

View File

@@ -0,0 +1,2 @@
export { UnlinkModal } from './UnlinkModal';
export { useUnlinkDownstream } from './data/apiHooks';

View File

@@ -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. <b>Unlinking is permanent.</b>',
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;