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:
@@ -1 +1,2 @@
|
||||
export { CourseLibraries } from './CourseLibraries';
|
||||
export { courseLibrariesQueryKeys } from './data/apiHooks';
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -37,4 +37,11 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.allow-hover-on-disabled {
|
||||
&.disabled {
|
||||
pointer-events: auto;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -40,6 +40,7 @@ const initialState = {
|
||||
currentItem: {},
|
||||
actions: {
|
||||
deletable: true,
|
||||
unlinkable: false,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -52,6 +52,7 @@ export const messageTypes = {
|
||||
manageXBlockAccess: 'manageXBlockAccess',
|
||||
completeManageXBlockAccess: 'completeManageXBlockAccess',
|
||||
deleteXBlock: 'deleteXBlock',
|
||||
unlinkXBlock: 'unlinkXBlock',
|
||||
completeXBlockDeleting: 'completeXBlockDeleting',
|
||||
duplicateXBlock: 'duplicateXBlock',
|
||||
completeXBlockDuplicating: 'completeXBlockDuplicating',
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface XBlockActions {
|
||||
draggable: boolean;
|
||||
childAddable: boolean;
|
||||
duplicable: boolean;
|
||||
unlinkable?: boolean;
|
||||
allowMoveDown?: boolean;
|
||||
allowMoveUp?: boolean;
|
||||
}
|
||||
|
||||
@@ -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:');
|
||||
|
||||
80
src/generic/unlink-modal/UnlinkModal.test.jsx
Normal file
80
src/generic/unlink-modal/UnlinkModal.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
98
src/generic/unlink-modal/UnlinkModal.tsx
Normal file
98
src/generic/unlink-modal/UnlinkModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
11
src/generic/unlink-modal/data/api.ts
Normal file
11
src/generic/unlink-modal/data/api.ts
Normal 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));
|
||||
};
|
||||
18
src/generic/unlink-modal/data/apiHooks.ts
Normal file
18
src/generic/unlink-modal/data/apiHooks.ts
Normal 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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
2
src/generic/unlink-modal/index.tsx
Normal file
2
src/generic/unlink-modal/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { UnlinkModal } from './UnlinkModal';
|
||||
export { useUnlinkDownstream } from './data/apiHooks';
|
||||
75
src/generic/unlink-modal/messages.ts
Normal file
75
src/generic/unlink-modal/messages.ts
Normal 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;
|
||||
Reference in New Issue
Block a user