Skip to content

Commit e2da13d

Browse files
authored
feat: remove unit/subsection from subsection/section (#2149)
Let the user remove a unit/subsection from a subsection/section.
1 parent aeefcc6 commit e2da13d

File tree

7 files changed

+272
-31
lines changed

7 files changed

+272
-31
lines changed

src/library-authoring/components/ComponentMenu.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from '@openedx/paragon';
99
import { MoreVert } from '@openedx/paragon/icons';
1010

11+
import { getBlockType } from '@src/generic/key-utils';
1112
import { useLibraryContext } from '../common/context/LibraryContext';
1213
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
1314
import { useClipboard } from '../../generic/clipboard';
@@ -112,6 +113,8 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
112113
navigateTo,
113114
]);
114115

116+
const containerType = containerId ? getBlockType(containerId) : 'collection';
117+
115118
return (
116119
<Dropdown id="component-card-dropdown">
117120
<Dropdown.Toggle
@@ -140,7 +143,12 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
140143
</Dropdown.Item>
141144
{insideCollection && (
142145
<Dropdown.Item onClick={removeFromCollection}>
143-
<FormattedMessage {...containerMessages.menuRemoveFromCollection} />
146+
<FormattedMessage
147+
{...containerMessages.menuRemoveFromContainer}
148+
values={{
149+
containerType,
150+
}}
151+
/>
144152
</Dropdown.Item>
145153
)}
146154
<Dropdown.Item onClick={showManageCollections}>

src/library-authoring/components/messages.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,31 @@ const messages = defineMessages({
3131
defaultMessage: 'Delete',
3232
description: 'Menu item for deleting a component.',
3333
},
34+
menuAddToCollection: {
35+
id: 'course-authoring.library-authoring.component.menu.add',
36+
defaultMessage: 'Add to collection',
37+
description: 'Menu item for add a component to collection.',
38+
},
39+
menuRemoveFromCollection: {
40+
id: 'course-authoring.library-authoring.component.menu.remove-from-collection',
41+
defaultMessage: 'Remove from collection',
42+
description: 'Menu item for remove a component from collection.',
43+
},
44+
menuRemoveFromContainer: {
45+
id: 'course-authoring.library-authoring.component.menu.remove',
46+
defaultMessage: 'Remove from {containerType}',
47+
description: 'Menu item for remove an item from {containerType}.',
48+
},
49+
removeComponentFromCollectionSuccess: {
50+
id: 'course-authoring.library-authoring.component.remove-from-collection-success',
51+
defaultMessage: 'Item successfully removed',
52+
description: 'Message for successful removal of an item from collection.',
53+
},
54+
removeComponentFromCollectionFailure: {
55+
id: 'course-authoring.library-authoring.component.remove-from-collection-failure',
56+
defaultMessage: 'Failed to remove item',
57+
description: 'Message for failure of removal of an item from collection.',
58+
},
3459
deleteComponentWarningTitle: {
3560
id: 'course-authoring.library-authoring.component.delete-confirmation-title',
3661
defaultMessage: 'Delete Component',
@@ -196,5 +221,25 @@ const messages = defineMessages({
196221
defaultMessage: 'Failed to undo remove component operation',
197222
description: 'Message to display on failure to undo delete component',
198223
},
224+
containerPreviewText: {
225+
id: 'course-authoring.library-authoring.container.preview.text',
226+
defaultMessage: 'Contains {children}.',
227+
description: 'Preview message for section/subsections with the names of children separated by commas',
228+
},
229+
removeContainerWarningTitle: {
230+
id: 'course-authoring.library-authoring.container.remove-confirmation-title',
231+
defaultMessage: 'Remove {containerType}',
232+
description: 'Title text for the warning displayed before removing a container from its parent',
233+
},
234+
removeContainerConfirm: {
235+
id: 'course-authoring.library-authoring.container.remove-confirmation-text',
236+
defaultMessage: 'Remove {containerName} from {parentContainerType} {parentContainerName}? Removing this {containerType} will not delete it from the library.',
237+
description: 'Confirmation text to display before removing a container from its parent',
238+
},
239+
removeContainerButton: {
240+
id: 'course-authoring.library-authoring.container.confirm-remove-button',
241+
defaultMessage: 'Remove {containerName}',
242+
description: 'Button to confirm removal of a container from its parent',
243+
},
199244
});
200245
export default messages;

src/library-authoring/containers/ContainerCard.test.tsx

Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import {
66
fireEvent,
77
} from '../../testUtils';
88
import { LibraryProvider } from '../common/context/LibraryContext';
9-
import { mockContentLibrary } from '../data/api.mocks';
9+
import { mockContentLibrary, mockGetContainerMetadata } from '../data/api.mocks';
1010
import { type ContainerHit, PublishStatus } from '../../search-manager';
1111
import ContainerCard from './ContainerCard';
12-
import { getLibraryContainerApiUrl, getLibraryContainerRestoreApiUrl } from '../data/api';
12+
import { getLibraryContainerApiUrl, getLibraryContainerRestoreApiUrl, getLibraryContainerChildrenApiUrl } from '../data/api';
1313
import { ContainerType } from '../../generic/key-utils';
1414

1515
let axiosMock: MockAdapter;
@@ -50,18 +50,31 @@ jest.mock('react-router-dom', () => ({
5050
useNavigate: () => mockNavigate,
5151
}));
5252

53-
const render = (ui: React.ReactElement, showOnlyPublished: boolean = false) => baseRender(ui, {
54-
path: '/library/:libraryId',
55-
params: { libraryId },
56-
extraWrapper: ({ children }) => (
57-
<LibraryProvider
58-
libraryId={libraryId}
59-
showOnlyPublished={showOnlyPublished}
60-
>
61-
{children}
62-
</LibraryProvider>
63-
),
64-
});
53+
const render = (
54+
ui: React.ReactElement,
55+
showOnlyPublished: boolean = false,
56+
containerContext?: { type: ContainerType, id: string },
57+
) => {
58+
const path = containerContext
59+
? `/library/:libraryId/${containerContext.type}/:containerId`
60+
: '/library/:libraryId';
61+
const params: Record<string, string> = containerContext
62+
? { libraryId, containerId: containerContext.id }
63+
: { libraryId };
64+
65+
return baseRender(ui, {
66+
path,
67+
params,
68+
extraWrapper: ({ children }) => (
69+
<LibraryProvider
70+
libraryId={libraryId}
71+
showOnlyPublished={showOnlyPublished}
72+
>
73+
{children}
74+
</LibraryProvider>
75+
),
76+
});
77+
};
6578

6679
describe('<ContainerCard />', () => {
6780
beforeEach(() => {
@@ -387,4 +400,55 @@ describe('<ContainerCard />', () => {
387400
expect(screen.getByText(displayName)).toBeInTheDocument();
388401
expect(screen.queryByText(/contains/i)).not.toBeInTheDocument();
389402
});
403+
404+
test.each([
405+
{
406+
label: 'should be able to remove unit from subsection menu item',
407+
containerType: ContainerType.Unit,
408+
parentType: ContainerType.Subsection,
409+
parentId: mockGetContainerMetadata.subsectionId,
410+
expectedRemoveText: 'Remove from subsection',
411+
},
412+
{
413+
label: 'should be able to remove subsection from section menu item',
414+
containerType: ContainerType.Subsection,
415+
parentType: ContainerType.Section,
416+
parentId: mockGetContainerMetadata.sectionId,
417+
expectedRemoveText: 'Remove from section',
418+
},
419+
])('$label', async ({
420+
containerType, parentType, parentId, expectedRemoveText,
421+
}) => {
422+
const containerHit = getContainerHitSample(containerType);
423+
axiosMock.onDelete(getLibraryContainerChildrenApiUrl(parentId)).reply(200);
424+
axiosMock.onGet(getLibraryContainerApiUrl(parentId)).reply(200, {
425+
containerType: parentType,
426+
displayName: 'Parent Container Display Name',
427+
});
428+
429+
render(
430+
<ContainerCard hit={containerHit} />,
431+
false,
432+
{ type: parentType, id: parentId },
433+
);
434+
435+
// Open menu
436+
expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument();
437+
userEvent.click(screen.getByTestId('container-card-menu-toggle'));
438+
439+
// Click on Remove Item
440+
const removeMenuItem = await screen.findByRole('button', { name: expectedRemoveText });
441+
expect(removeMenuItem).toBeInTheDocument();
442+
fireEvent.click(removeMenuItem);
443+
444+
// Confirm remove Modal is open
445+
expect(await screen.findByText(/will not delete it from the library/i)).toBeInTheDocument();
446+
const removeButton = screen.getByRole('button', { name: /remove/i });
447+
fireEvent.click(removeButton);
448+
449+
await waitFor(() => {
450+
expect(axiosMock.history.delete.length).toBe(1);
451+
});
452+
expect(mockShowToast).toHaveBeenCalled();
453+
});
390454
});

src/library-authoring/containers/ContainerCard.tsx

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import {
1010
} from '@openedx/paragon';
1111
import { MoreVert } from '@openedx/paragon/icons';
1212

13-
import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils';
14-
import { getBlockType } from '../../generic/key-utils';
15-
import { ToastContext } from '../../generic/toast-context';
13+
import { getItemIcon, getComponentStyleColor } from '@src/generic/block-type-utils';
14+
import { getBlockType } from '@src/generic/key-utils';
15+
import { ToastContext } from '@src/generic/toast-context';
1616
import { type ContainerHit, Highlight, PublishStatus } from '../../search-manager';
1717
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
1818
import { useLibraryContext } from '../common/context/LibraryContext';
@@ -21,32 +21,41 @@ import { useRemoveItemsFromCollection } from '../data/apiHooks';
2121
import { useLibraryRoutes } from '../routes';
2222
import messages from './messages';
2323
import ContainerDeleter from './ContainerDeleter';
24+
import ContainerRemover from './ContainerRemover';
2425
import { useRunOnNextRender } from '../../utils';
2526
import BaseCard from '../components/BaseCard';
2627
import AddComponentWidget from '../components/AddComponentWidget';
2728

2829
type ContainerMenuProps = {
2930
containerKey: string;
31+
displayName: string;
3032
};
3133

32-
export const ContainerMenu = ({ containerKey } : ContainerMenuProps) => {
34+
export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps) => {
3335
const intl = useIntl();
34-
const { libraryId, collectionId } = useLibraryContext();
36+
const { libraryId, collectionId, containerId } = useLibraryContext();
3537
const {
3638
sidebarItemInfo,
3739
closeLibrarySidebar,
3840
setSidebarAction,
3941
} = useSidebarContext();
42+
4043
const { showToast } = useContext(ToastContext);
4144
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
42-
const { navigateTo, insideCollection } = useLibraryRoutes();
45+
const [isConfirmingRemove, confirmRemove, cancelRemove] = useToggle(false);
46+
const {
47+
navigateTo,
48+
insideCollection,
49+
insideSection,
50+
insideSubsection,
51+
} = useLibraryRoutes();
4352

4453
const removeComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId);
4554

46-
const removeFromCollection = () => {
55+
const handleRemoveFromCollection = () => {
4756
removeComponentsMutation.mutateAsync([containerKey]).then(() => {
4857
if (sidebarItemInfo?.id === containerKey) {
49-
// Close sidebar if current component is open
58+
// Close sidebar if current component is open
5059
closeLibrarySidebar();
5160
}
5261
showToast(intl.formatMessage(messages.removeComponentFromCollectionSuccess));
@@ -55,6 +64,14 @@ export const ContainerMenu = ({ containerKey } : ContainerMenuProps) => {
5564
});
5665
};
5766

67+
const handleRemove = () => {
68+
if (insideCollection) {
69+
handleRemoveFromCollection();
70+
} else if (insideSection || insideSubsection) {
71+
confirmRemove();
72+
}
73+
};
74+
5875
const scheduleJumpToCollection = useRunOnNextRender(() => {
5976
// TODO: Ugly hack to make sure sidebar shows add to collection section
6077
// This needs to run after all changes to url takes place to avoid conflicts.
@@ -70,6 +87,8 @@ export const ContainerMenu = ({ containerKey } : ContainerMenuProps) => {
7087
navigateTo({ containerId: containerKey });
7188
}, [navigateTo, containerKey]);
7289

90+
const containerType = containerId ? getBlockType(containerId) : 'collection';
91+
7392
return (
7493
<>
7594
<Dropdown id="container-card-dropdown">
@@ -89,9 +108,14 @@ export const ContainerMenu = ({ containerKey } : ContainerMenuProps) => {
89108
<Dropdown.Item onClick={confirmDelete}>
90109
<FormattedMessage {...messages.menuDeleteContainer} />
91110
</Dropdown.Item>
92-
{insideCollection && (
93-
<Dropdown.Item onClick={removeFromCollection}>
94-
<FormattedMessage {...messages.menuRemoveFromCollection} />
111+
{(insideCollection || insideSection || insideSubsection) && (
112+
<Dropdown.Item onClick={handleRemove}>
113+
<FormattedMessage
114+
{...messages.menuRemoveFromContainer}
115+
values={{
116+
containerType,
117+
}}
118+
/>
95119
</Dropdown.Item>
96120
)}
97121
<Dropdown.Item onClick={showManageCollections}>
@@ -106,6 +130,14 @@ export const ContainerMenu = ({ containerKey } : ContainerMenuProps) => {
106130
containerId={containerKey}
107131
/>
108132
)}
133+
{isConfirmingRemove && (
134+
<ContainerRemover
135+
isOpen={isConfirmingRemove}
136+
close={cancelRemove}
137+
containerKey={containerKey}
138+
displayName={displayName}
139+
/>
140+
)}
109141
</>
110142
);
111143
};
@@ -262,7 +294,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
262294
{componentPickerMode ? (
263295
<AddComponentWidget usageKey={containerKey} blockType={itemType} />
264296
) : (
265-
<ContainerMenu containerKey={containerKey} />
297+
<ContainerMenu containerKey={containerKey} displayName={displayName} />
266298
)}
267299
</ActionRow>
268300
)}

0 commit comments

Comments
 (0)