Skip to content

Commit 950bfee

Browse files
authored
feat: add unlink upstream menu [FC-0097] (#2393)
Adds the Unlink feature to the Course Outline for Sections, Subsections and Units.
1 parent 0f2dd4a commit 950bfee

31 files changed

+584
-29
lines changed

src/course-libraries/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { CourseLibraries } from './CourseLibraries';
2+
export { courseLibrariesQueryKeys } from './data/apiHooks';

src/course-outline/CourseOutline.test.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import pasteButtonMessages from '@src/generic/clipboard/paste-component/messages
1111
import { getApiBaseUrl, getClipboardUrl } from '@src/generic/data/api';
1212
import { postXBlockBaseApiUrl } from '@src/course-unit/data/api';
1313
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
14+
import { getDownstreamApiUrl } from '@src/generic/unlink-modal/data/api';
1415
import {
1516
act, fireEvent, initializeMocks, render, screen, waitFor, within,
1617
} from '@src/testUtils';
@@ -2440,4 +2441,46 @@ describe('<CourseOutline />', () => {
24402441
expect(outlineIndexLoadingStatus).toEqual(RequestStatus.DENIED);
24412442
});
24422443
});
2444+
2445+
it('can unlink library block', async () => {
2446+
axiosMock
2447+
.onGet(getCourseOutlineIndexApiUrl(courseId))
2448+
.reply(200, courseOutlineIndexWithoutSections);
2449+
2450+
renderComponent();
2451+
2452+
axiosMock
2453+
.onPost(getXBlockBaseApiUrl())
2454+
.reply(200, {
2455+
locator: courseSectionMock.id,
2456+
});
2457+
axiosMock
2458+
.onGet(getXBlockApiUrl(courseSectionMock.id))
2459+
.reply(200, {
2460+
...courseSectionMock,
2461+
actions: {
2462+
...courseSectionMock.actions,
2463+
unlinkable: true,
2464+
},
2465+
});
2466+
const newSectionButton = (await screen.findAllByRole('button', { name: 'New section' }))[0];
2467+
fireEvent.click(newSectionButton);
2468+
2469+
const element = await screen.findByTestId('section-card');
2470+
expect(element).toBeInTheDocument();
2471+
2472+
axiosMock.onDelete(getDownstreamApiUrl(courseSectionMock.id)).reply(200);
2473+
2474+
const menu = await within(element).findByTestId('section-card-header__menu-button');
2475+
fireEvent.click(menu);
2476+
const unlinkButton = await within(element).findByRole('button', { name: 'Unlink from Library' });
2477+
fireEvent.click(unlinkButton);
2478+
const confirmButton = await screen.findByRole('button', { name: 'Confirm Unlink' });
2479+
fireEvent.click(confirmButton);
2480+
2481+
await waitFor(() => {
2482+
expect(axiosMock.history.delete).toHaveLength(1);
2483+
});
2484+
expect(axiosMock.history.delete[0].url).toBe(getDownstreamApiUrl(courseSectionMock.id));
2485+
});
24432486
});

src/course-outline/CourseOutline.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import ProcessingNotification from '@src/generic/processing-notification';
2727
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
2828
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
2929
import ConfigureModal from '@src/generic/configure-modal/ConfigureModal';
30+
import { UnlinkModal } from '@src/generic/unlink-modal';
3031
import AlertMessage from '@src/generic/alert-message';
3132
import getPageHeadTitle from '@src/generic/utils';
3233
import CourseOutlineHeaderActionsSlot from '@src/plugin-slots/CourseOutlineHeaderActionsSlot';
@@ -90,13 +91,16 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => {
9091
isPublishModalOpen,
9192
isConfigureModalOpen,
9293
isDeleteModalOpen,
94+
isUnlinkModalOpen,
9395
closeHighlightsModal,
9496
closePublishModal,
9597
handleConfigureModalClose,
9698
closeDeleteModal,
99+
closeUnlinkModal,
97100
openPublishModal,
98101
openConfigureModal,
99102
openDeleteModal,
103+
openUnlinkModal,
100104
headerNavigationsActions,
101105
openEnableHighlightsModal,
102106
closeEnableHighlightsModal,
@@ -111,6 +115,7 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => {
111115
handlePublishItemSubmit,
112116
handleEditSubmit,
113117
handleDeleteItemSubmit,
118+
handleUnlinkItemSubmit,
114119
handleDuplicateSectionSubmit,
115120
handleDuplicateSubsectionSubmit,
116121
handleDuplicateUnitSubmit,
@@ -168,7 +173,9 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => {
168173
} = useSelector(getProcessingNotification);
169174

170175
const currentItemData = useSelector(getCurrentItem);
171-
const deleteCategory = COURSE_BLOCK_NAMES[currentItemData.category]?.name.toLowerCase();
176+
177+
const itemCategory = currentItemData?.category;
178+
const itemCategoryName = COURSE_BLOCK_NAMES[itemCategory]?.name.toLowerCase();
172179

173180
const enableProctoredExams = useSelector(getProctoredExamsFlag);
174181
const enableTimedExams = useSelector(getTimedExamsFlag);
@@ -372,6 +379,7 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => {
372379
onOpenPublishModal={openPublishModal}
373380
onOpenConfigureModal={openConfigureModal}
374381
onOpenDeleteModal={openDeleteModal}
382+
onOpenUnlinkModal={openUnlinkModal}
375383
onEditSectionSubmit={handleEditSubmit}
376384
onDuplicateSubmit={handleDuplicateSectionSubmit}
377385
isSectionsExpanded={isSectionsExpanded}
@@ -403,6 +411,7 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => {
403411
savingStatus={savingStatus}
404412
onOpenPublishModal={openPublishModal}
405413
onOpenDeleteModal={openDeleteModal}
414+
onOpenUnlinkModal={openUnlinkModal}
406415
onEditSubmit={handleEditSubmit}
407416
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
408417
onOpenConfigureModal={openConfigureModal}
@@ -438,6 +447,7 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => {
438447
onOpenPublishModal={openPublishModal}
439448
onOpenConfigureModal={openConfigureModal}
440449
onOpenDeleteModal={openDeleteModal}
450+
onOpenUnlinkModal={openUnlinkModal}
441451
onEditSubmit={handleEditSubmit}
442452
onDuplicateSubmit={handleDuplicateUnitSubmit}
443453
getTitleLink={getUnitUrl}
@@ -514,11 +524,18 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => {
514524
isSelfPaced={statusBarData.isSelfPaced}
515525
/>
516526
<DeleteModal
517-
category={deleteCategory}
527+
category={itemCategoryName}
518528
isOpen={isDeleteModalOpen}
519529
close={closeDeleteModal}
520530
onDeleteSubmit={handleDeleteItemSubmit}
521531
/>
532+
<UnlinkModal
533+
displayName={currentItemData?.displayName}
534+
category={itemCategory}
535+
isOpen={isUnlinkModalOpen}
536+
close={closeUnlinkModal}
537+
onUnlinkSubmit={handleUnlinkItemSubmit}
538+
/>
522539
<StandardModal
523540
title={intl.formatMessage(messages.sectionPickerModalTitle)}
524541
isOpen={isAddLibrarySectionModalOpen}

src/course-outline/card-header/CardHeader.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,11 @@
3737
opacity: 1;
3838
}
3939
}
40+
41+
.allow-hover-on-disabled {
42+
&.disabled {
43+
pointer-events: auto;
44+
cursor: default;
45+
}
46+
}
4047
}

src/course-outline/card-header/CardHeader.test.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const onClickMenuButtonMock = jest.fn();
1313
const onClickPublishMock = jest.fn();
1414
const onClickEditMock = jest.fn();
1515
const onClickDeleteMock = jest.fn();
16+
const onClickUnlinkMock = jest.fn();
1617
const onClickDuplicateMock = jest.fn();
1718
const onClickConfigureMock = jest.fn();
1819
const onClickMoveUpMock = jest.fn();
@@ -39,6 +40,7 @@ const cardHeaderProps = {
3940
closeForm: closeFormMock,
4041
isDisabledEditField: false,
4142
onClickDelete: onClickDeleteMock,
43+
onClickUnlink: onClickUnlinkMock,
4244
onClickDuplicate: onClickDuplicateMock,
4345
onClickConfigure: onClickConfigureMock,
4446
onClickMoveUp: onClickMoveUpMock,
@@ -50,6 +52,7 @@ const cardHeaderProps = {
5052
childAddable: true,
5153
deletable: true,
5254
duplicable: true,
55+
unlinkable: true,
5356
},
5457
};
5558

@@ -273,6 +276,16 @@ describe('<CardHeader />', () => {
273276
expect(onClickDeleteMock).toHaveBeenCalledTimes(1);
274277
});
275278

279+
it('calls onClickUnlink when item is clicked', async () => {
280+
renderComponent();
281+
282+
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
283+
await act(async () => fireEvent.click(menuButton));
284+
const unlinkMenuItem = await screen.findByText(messages.menuUnlink.defaultMessage);
285+
await act(async () => fireEvent.click(unlinkMenuItem));
286+
expect(onClickUnlinkMock).toHaveBeenCalledTimes(1);
287+
});
288+
276289
it('calls onClickDuplicate when item is clicked', async () => {
277290
renderComponent();
278291

@@ -377,4 +390,54 @@ describe('<CardHeader />', () => {
377390

378391
expect(mockClickSync).toHaveBeenCalled();
379392
});
393+
394+
[null, undefined].forEach((unlinkable) => (
395+
it(`should not render unlink button if unlinkable action is ${unlinkable}`, async () => {
396+
renderComponent({
397+
...cardHeaderProps,
398+
actions: {
399+
...cardHeaderProps.actions,
400+
unlinkable,
401+
},
402+
});
403+
404+
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
405+
fireEvent.click(menuButton);
406+
407+
expect(screen.queryByText(messages.menuUnlink.defaultMessage)).not.toBeInTheDocument();
408+
})
409+
));
410+
411+
it('should render unlink button disabled if unlinkable action is False', async () => {
412+
renderComponent({
413+
...cardHeaderProps,
414+
actions: {
415+
...cardHeaderProps.actions,
416+
unlinkable: false,
417+
},
418+
});
419+
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
420+
fireEvent.click(menuButton);
421+
422+
const unlinkMenuItem = await screen.findByText(messages.menuUnlink.defaultMessage);
423+
expect(unlinkMenuItem).toBeInTheDocument();
424+
expect(unlinkMenuItem).toHaveAttribute('aria-disabled', 'true');
425+
});
426+
427+
it('should render unlink button disabled if unlinkable action is False', async () => {
428+
renderComponent({
429+
...cardHeaderProps,
430+
actions: {
431+
...cardHeaderProps.actions,
432+
unlinkable: true,
433+
},
434+
});
435+
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
436+
fireEvent.click(menuButton);
437+
438+
const unlinkMenuItem = await screen.findByText(messages.menuUnlink.defaultMessage);
439+
fireEvent.click(unlinkMenuItem);
440+
await act(async () => fireEvent.click(unlinkMenuItem));
441+
expect(onClickUnlinkMock).toHaveBeenCalled();
442+
});
380443
});

src/course-outline/card-header/CardHeader.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ interface CardHeaderProps {
4343
closeForm: () => void;
4444
isDisabledEditField: boolean;
4545
onClickDelete: () => void;
46+
onClickUnlink: () => void;
4647
onClickDuplicate: () => void;
4748
onClickMoveUp: () => void;
4849
onClickMoveDown: () => void;
@@ -84,6 +85,7 @@ const CardHeader = ({
8485
closeForm,
8586
isDisabledEditField,
8687
onClickDelete,
88+
onClickUnlink,
8789
onClickDuplicate,
8890
onClickMoveUp,
8991
onClickMoveDown,
@@ -282,9 +284,20 @@ const CardHeader = ({
282284
</Dropdown.Item>
283285
</>
284286
)}
287+
{((actions.unlinkable ?? null) !== null || actions.deletable) && <Dropdown.Divider />}
288+
{(actions.unlinkable ?? null) !== null && (
289+
<Dropdown.Item
290+
data-testid={`${namePrefix}-card-header__menu-unlink-button`}
291+
onClick={onClickUnlink}
292+
disabled={!actions.unlinkable}
293+
className="allow-hover-on-disabled"
294+
title={!actions.unlinkable ? intl.formatMessage(messages.menuUnlinkDisabledTooltip) : undefined}
295+
>
296+
{intl.formatMessage(messages.menuUnlink)}
297+
</Dropdown.Item>
298+
)}
285299
{actions.deletable && (
286300
<Dropdown.Item
287-
className="border-top border-light"
288301
data-testid={`${namePrefix}-card-header__menu-delete-button`}
289302
onClick={onClickDelete}
290303
>

src/course-outline/card-header/messages.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ const messages = defineMessages({
6161
id: 'course-authoring.course-outline.card.menu.delete',
6262
defaultMessage: 'Delete',
6363
},
64+
menuUnlink: {
65+
id: 'course-authoring.course-outline.card.menu.unlink',
66+
defaultMessage: 'Unlink from Library',
67+
description: 'Unlink an item from the library',
68+
},
69+
menuUnlinkDisabledTooltip: {
70+
id: 'course-authoring.course-outline.card.menu.unlink.disabled-tooltip',
71+
defaultMessage: 'Only the highest level library reference can be unlinked.',
72+
description: 'Tooltip for disabled unlink option',
73+
},
6474
menuCopy: {
6575
id: 'course-authoring.course-outline.card.menu.copy',
6676
defaultMessage: 'Copy to clipboard',

src/course-outline/data/slice.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const initialState = {
4040
currentItem: {},
4141
actions: {
4242
deletable: true,
43+
unlinkable: false,
4344
draggable: true,
4445
childAddable: true,
4546
duplicable: true,

src/course-outline/hooks.jsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import moment from 'moment';
88
import { getSavingStatus as getGenericSavingStatus } from '@src/generic/data/selectors';
99
import { useWaffleFlags } from '@src/data/apiHooks';
1010
import { RequestStatus } from '@src/data/constants';
11+
import { useUnlinkDownstream } from '@src/generic/unlink-modal';
12+
1113
import { COURSE_BLOCK_NAMES } from './constants';
1214
import {
1315
addSection,
@@ -102,6 +104,7 @@ const useCourseOutline = ({ courseId }) => {
102104
const [isPublishModalOpen, openPublishModal, closePublishModal] = useToggle(false);
103105
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
104106
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
107+
const [isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal] = useToggle(false);
105108
const [
106109
isAddLibrarySectionModalOpen,
107110
openAddLibrarySectionModal,
@@ -265,6 +268,19 @@ const useCourseOutline = ({ courseId }) => {
265268
closeDeleteModal();
266269
};
267270

271+
const { mutateAsync: unlinkDownstream } = useUnlinkDownstream();
272+
273+
const handleUnlinkItemSubmit = async () => {
274+
// istanbul ignore if: this should never happen
275+
if (!currentItem.id) {
276+
return;
277+
}
278+
279+
await unlinkDownstream(currentItem.id);
280+
dispatch(fetchCourseOutlineIndexQuery(courseId));
281+
closeUnlinkModal();
282+
};
283+
268284
const handleDuplicateSectionSubmit = () => {
269285
dispatch(duplicateSectionQuery(currentSection.id, courseStructure.id));
270286
};
@@ -382,7 +398,11 @@ const useCourseOutline = ({ courseId }) => {
382398
isDeleteModalOpen,
383399
closeDeleteModal,
384400
openDeleteModal,
401+
isUnlinkModalOpen,
402+
closeUnlinkModal,
403+
openUnlinkModal,
385404
handleDeleteItemSubmit,
405+
handleUnlinkItemSubmit,
386406
handleDuplicateSectionSubmit,
387407
handleDuplicateSubsectionSubmit,
388408
handleDuplicateUnitSubmit,

src/course-outline/section-card/SectionCard.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
8686
onOpenPublishModal={jest.fn()}
8787
onOpenHighlightsModal={jest.fn()}
8888
onOpenDeleteModal={jest.fn()}
89+
onOpenUnlinkModal={jest.fn()}
8990
onOpenConfigureModal={jest.fn()}
9091
savingStatus=""
9192
onEditSectionSubmit={onEditSectionSubmit}

0 commit comments

Comments
 (0)