feat: Remove component from unit [FC-0083] (#1824)

* Users can remove a component from a unit
* The component is NOT deleted, and remains present in the library
* A toast shows that the component was removed, and allows the user to undo
* Overflow menu item appears in sidebar for selected components in unit
* Overflow menu item appears directly on components in full page unit view
This commit is contained in:
Chris Chávez
2025-04-17 17:51:42 -05:00
committed by GitHub
parent 4ddb8c3168
commit 9adfa58d65
11 changed files with 378 additions and 112 deletions

View File

@@ -380,6 +380,25 @@ describe('<LibraryCollectionPage />', () => {
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
});
it('should show error when remove component from collection', async () => {
const url = getLibraryCollectionItemsApiUrl(
mockContentLibrary.libraryId,
mockCollection.collectionId,
);
axiosMock.onDelete(url).reply(404);
await renderLibraryCollectionPage();
const menuBtns = await screen.findAllByRole('button', { name: 'Component actions menu' });
// open menu
fireEvent.click(menuBtns[0]);
fireEvent.click(await screen.findByText('Remove from collection'));
await waitFor(() => {
expect(axiosMock.history.delete.length).toEqual(1);
});
expect(mockShowToast).toHaveBeenCalledWith('Failed to remove item');
});
it('should remove unit from collection and hides sidebar', async () => {
const url = getLibraryCollectionItemsApiUrl(
mockContentLibrary.libraryId,

View File

@@ -1,110 +1,21 @@
import { useCallback, useContext } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { useCallback } from 'react';
import {
ActionRow,
Dropdown,
Icon,
IconButton,
useToggle,
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
import { useClipboard } from '../../generic/clipboard';
import { ToastContext } from '../../generic/toast-context';
import { type ContentHit, PublishStatus } from '../../search-manager';
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
import { useLibraryContext } from '../common/context/LibraryContext';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
import { useRemoveItemsFromCollection } from '../data/apiHooks';
import { useSidebarContext } from '../common/context/SidebarContext';
import { useLibraryRoutes } from '../routes';
import AddComponentWidget from './AddComponentWidget';
import BaseCard from './BaseCard';
import { canEditComponent } from './ComponentEditorModal';
import ComponentDeleter from './ComponentDeleter';
import messages from './messages';
import { ComponentMenu } from './ComponentMenu';
type ComponentCardProps = {
hit: ContentHit,
};
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
const intl = useIntl();
const {
libraryId,
collectionId,
openComponentEditor,
} = useLibraryContext();
const {
sidebarComponentInfo,
openComponentInfoSidebar,
closeLibrarySidebar,
setSidebarAction,
} = useSidebarContext();
const canEdit = usageKey && canEditComponent(usageKey);
const { showToast } = useContext(ToastContext);
const removeComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId);
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
const { copyToClipboard } = useClipboard();
const updateClipboardClick = () => {
copyToClipboard(usageKey);
};
const removeFromCollection = () => {
removeComponentsMutation.mutateAsync([usageKey]).then(() => {
if (sidebarComponentInfo?.id === usageKey) {
// Close sidebar if current component is open
closeLibrarySidebar();
}
showToast(intl.formatMessage(messages.removeComponentSucess));
}).catch(() => {
showToast(intl.formatMessage(messages.removeComponentFailure));
});
};
const showManageCollections = useCallback(() => {
setSidebarAction(SidebarActions.JumpToAddCollections);
openComponentInfoSidebar(usageKey);
}, [setSidebarAction, openComponentInfoSidebar, usageKey]);
return (
<Dropdown id="component-card-dropdown">
<Dropdown.Toggle
id="component-card-menu-toggle"
as={IconButton}
src={MoreVert}
iconAs={Icon}
variant="primary"
alt={intl.formatMessage(messages.componentCardMenuAlt)}
data-testid="component-card-menu-toggle"
/>
<Dropdown.Menu>
<Dropdown.Item {...(canEdit ? { onClick: () => openComponentEditor(usageKey) } : { disabled: true })}>
<FormattedMessage {...messages.menuEdit} />
</Dropdown.Item>
<Dropdown.Item onClick={updateClipboardClick}>
<FormattedMessage {...messages.menuCopyToClipboard} />
</Dropdown.Item>
<Dropdown.Item onClick={confirmDelete}>
<FormattedMessage {...messages.menuDelete} />
</Dropdown.Item>
{collectionId && (
<Dropdown.Item onClick={removeFromCollection}>
<FormattedMessage {...messages.menuRemoveFromCollection} />
</Dropdown.Item>
)}
<Dropdown.Item onClick={showManageCollections}>
<FormattedMessage {...messages.menuAddToCollection} />
</Dropdown.Item>
</Dropdown.Menu>
<ComponentDeleter usageKey={usageKey} isConfirmingDelete={isConfirmingDelete} cancelDelete={cancelDelete} />
</Dropdown>
);
};
const ComponentCard = ({ hit }: ComponentCardProps) => {
const { showOnlyPublished } = useLibraryContext();
const { openComponentInfoSidebar } = useSidebarContext();

View File

@@ -0,0 +1,137 @@
import { useCallback, useContext } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Dropdown,
Icon,
IconButton,
useToggle,
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
import { useLibraryContext } from '../common/context/LibraryContext';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
import { useClipboard } from '../../generic/clipboard';
import { ToastContext } from '../../generic/toast-context';
import {
useAddComponentsToContainer,
useRemoveContainerChildren,
useRemoveItemsFromCollection,
} from '../data/apiHooks';
import { canEditComponent } from './ComponentEditorModal';
import ComponentDeleter from './ComponentDeleter';
import messages from './messages';
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
const intl = useIntl();
const {
libraryId,
collectionId,
unitId,
openComponentEditor,
} = useLibraryContext();
const {
sidebarComponentInfo,
openComponentInfoSidebar,
closeLibrarySidebar,
setSidebarAction,
} = useSidebarContext();
const canEdit = usageKey && canEditComponent(usageKey);
const { showToast } = useContext(ToastContext);
const addComponentToContainerMutation = useAddComponentsToContainer(unitId);
const removeCollectionComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId);
const removeContainerComponentsMutation = useRemoveContainerChildren(unitId);
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
const { copyToClipboard } = useClipboard();
const updateClipboardClick = () => {
copyToClipboard(usageKey);
};
const removeFromCollection = () => {
removeCollectionComponentsMutation.mutateAsync([usageKey]).then(() => {
if (sidebarComponentInfo?.id === usageKey) {
// Close sidebar if current component is open
closeLibrarySidebar();
}
showToast(intl.formatMessage(messages.removeComponentFromCollectionSuccess));
}).catch(() => {
showToast(intl.formatMessage(messages.removeComponentFromCollectionFailure));
});
};
const removeFromContainer = () => {
const restoreComponent = () => {
addComponentToContainerMutation.mutateAsync([usageKey]).then(() => {
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastSuccess));
}).catch(() => {
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastFailed));
});
};
removeContainerComponentsMutation.mutateAsync([usageKey]).then(() => {
if (sidebarComponentInfo?.id === usageKey) {
// Close sidebar if current component is open
closeLibrarySidebar();
}
showToast(
intl.formatMessage(messages.removeComponentFromContainerSuccess),
{
label: intl.formatMessage(messages.undoRemoveComponentFromContainerToastAction),
onClick: restoreComponent,
},
);
}).catch(() => {
showToast(intl.formatMessage(messages.removeComponentFromContainerFailure));
});
};
const showManageCollections = useCallback(() => {
setSidebarAction(SidebarActions.JumpToAddCollections);
openComponentInfoSidebar(usageKey);
}, [setSidebarAction, openComponentInfoSidebar, usageKey]);
return (
<Dropdown id="component-card-dropdown">
<Dropdown.Toggle
id="component-card-menu-toggle"
as={IconButton}
src={MoreVert}
iconAs={Icon}
variant="primary"
alt={intl.formatMessage(messages.componentCardMenuAlt)}
data-testid="component-card-menu-toggle"
/>
<Dropdown.Menu>
<Dropdown.Item {...(canEdit ? { onClick: () => openComponentEditor(usageKey) } : { disabled: true })}>
<FormattedMessage {...messages.menuEdit} />
</Dropdown.Item>
<Dropdown.Item onClick={updateClipboardClick}>
<FormattedMessage {...messages.menuCopyToClipboard} />
</Dropdown.Item>
{unitId && (
<Dropdown.Item onClick={removeFromContainer}>
<FormattedMessage {...messages.removeComponentFromUnitMenu} />
</Dropdown.Item>
)}
<Dropdown.Item onClick={confirmDelete}>
<FormattedMessage {...messages.menuDelete} />
</Dropdown.Item>
{collectionId && (
<Dropdown.Item onClick={removeFromCollection}>
<FormattedMessage {...messages.menuRemoveFromCollection} />
</Dropdown.Item>
)}
{!unitId && (
<Dropdown.Item onClick={showManageCollections}>
<FormattedMessage {...messages.menuAddToCollection} />
</Dropdown.Item>
)}
</Dropdown.Menu>
<ComponentDeleter usageKey={usageKey} isConfirmingDelete={isConfirmingDelete} cancelDelete={cancelDelete} />
</Dropdown>
);
};
export default ComponentMenu;

View File

@@ -53,9 +53,9 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => {
// Close sidebar if current component is open
closeLibrarySidebar();
}
showToast(intl.formatMessage(messages.removeComponentSucess));
showToast(intl.formatMessage(messages.removeComponentFromCollectionSuccess));
}).catch(() => {
showToast(intl.formatMessage(messages.removeComponentFailure));
showToast(intl.formatMessage(messages.removeComponentFromCollectionFailure));
});
};

View File

@@ -1 +1 @@
export { ComponentMenu as default } from './ComponentCard';
export { ComponentMenu as default } from './ComponentMenu';

View File

@@ -46,12 +46,12 @@ const messages = defineMessages({
defaultMessage: 'Remove from collection',
description: 'Menu item for remove an item from collection.',
},
removeComponentSucess: {
removeComponentFromCollectionSuccess: {
id: 'course-authoring.library-authoring.component.remove-from-collection-success',
defaultMessage: 'Item successfully removed',
description: 'Message for successful removal of an item from collection.',
},
removeComponentFailure: {
removeComponentFromCollectionFailure: {
id: 'course-authoring.library-authoring.component.remove-from-collection-failure',
defaultMessage: 'Failed to remove item',
description: 'Message for failure of removal of an item from collection.',
@@ -231,5 +231,35 @@ const messages = defineMessages({
defaultMessage: '+{count}',
description: 'Count shown when a container has more blocks than will fit on the card preview.',
},
removeComponentFromUnitMenu: {
id: 'course-authoring.library-authoring.unit.component.remove.button',
defaultMessage: 'Remove from unit',
description: 'Text of the menu item to remove a component from a unit',
},
removeComponentFromContainerSuccess: {
id: 'course-authoring.library-authoring.component.remove-from-container-success',
defaultMessage: 'Component successfully removed',
description: 'Message for successful removal of a component from container.',
},
removeComponentFromContainerFailure: {
id: 'course-authoring.library-authoring.component.remove-from-container-failure',
defaultMessage: 'Failed to remove component',
description: 'Message for failure of removal of a component from container.',
},
undoRemoveComponentFromContainerToastAction: {
id: 'course-authoring.library-authoring.component.undo-remove-from-container-toast-button',
defaultMessage: 'Undo',
description: 'Toast message to undo remove a component from container.',
},
undoRemoveComponentFromContainerToastSuccess: {
id: 'course-authoring.library-authoring.component.undo-remove-component-from-container-toast-text',
defaultMessage: 'Undo successful',
description: 'Message to display on undo delete component success',
},
undoRemoveComponentFromContainerToastFailed: {
id: 'course-authoring.library-authoring.component.undo-remove-component-from-container-failed',
defaultMessage: 'Failed to undo remove component operation',
description: 'Message to display on failure to undo delete component',
},
});
export default messages;

View File

@@ -1,10 +1,15 @@
import { initializeMocks } from '../../testUtils';
import * as api from './api';
let axiosMock;
describe('library data API', () => {
beforeEach(() => {
({ axiosMock } = initializeMocks());
});
describe('createLibraryBlock', () => {
it('should create library block', async () => {
const { axiosMock } = initializeMocks();
const libraryId = 'lib:org:1';
const url = api.getCreateLibraryBlockUrl(libraryId);
axiosMock.onPost(url).reply(200);
@@ -20,7 +25,6 @@ describe('library data API', () => {
describe('deleteLibraryBlock', () => {
it('should delete a library block', async () => {
const { axiosMock } = initializeMocks();
const usageKey = 'lib:org:1';
const url = api.getLibraryBlockMetadataUrl(usageKey);
axiosMock.onDelete(url).reply(200);
@@ -31,7 +35,6 @@ describe('library data API', () => {
describe('restoreLibraryBlock', () => {
it('should restore a soft-deleted library block', async () => {
const { axiosMock } = initializeMocks();
const usageKey = 'lib:org:1';
const url = api.getLibraryBlockRestoreUrl(usageKey);
axiosMock.onPost(url).reply(200);
@@ -42,7 +45,6 @@ describe('library data API', () => {
describe('commitLibraryChanges', () => {
it('should commit library changes', async () => {
const { axiosMock } = initializeMocks();
const libraryId = 'lib:org:1';
const url = api.getCommitLibraryChangesUrl(libraryId);
axiosMock.onPost(url).reply(200);
@@ -55,7 +57,6 @@ describe('library data API', () => {
describe('revertLibraryChanges', () => {
it('should revert library changes', async () => {
const { axiosMock } = initializeMocks();
const libraryId = 'lib:org:1';
const url = api.getCommitLibraryChangesUrl(libraryId);
axiosMock.onDelete(url).reply(200);
@@ -68,7 +69,6 @@ describe('library data API', () => {
describe('getBlockTypes', () => {
it('should get block types metadata', async () => {
const { axiosMock } = initializeMocks();
const libraryId = 'lib:org:1';
const url = api.getBlockTypesMetaDataUrl(libraryId);
axiosMock.onGet(url).reply(200);
@@ -80,7 +80,6 @@ describe('library data API', () => {
});
it('should create collection', async () => {
const { axiosMock } = initializeMocks();
const libraryId = 'lib:org:1';
const url = api.getLibraryCollectionsApiUrl(libraryId);
@@ -95,7 +94,6 @@ describe('library data API', () => {
});
it('should delete a container', async () => {
const { axiosMock } = initializeMocks();
const containerId = 'lct:org:lib1';
const url = api.getLibraryContainerApiUrl(containerId);
@@ -106,7 +104,6 @@ describe('library data API', () => {
});
it('should restore a container', async () => {
const { axiosMock } = initializeMocks();
const containerId = 'lct:org:lib1';
const url = api.getLibraryContainerRestoreApiUrl(containerId);
@@ -116,7 +113,6 @@ describe('library data API', () => {
});
it('should add components to unit', async () => {
const { axiosMock } = initializeMocks();
const componentId = 'lb:org:lib:html:1';
const containerId = 'lct:org:lib:unit:1';
const url = api.getLibraryContainerChildrenApiUrl(containerId);
@@ -128,7 +124,6 @@ describe('library data API', () => {
});
it('should update container children', async () => {
const { axiosMock } = initializeMocks();
const containerId = 'lct:org:lib1';
const url = api.getLibraryContainerChildrenApiUrl(containerId);
@@ -137,4 +132,14 @@ describe('library data API', () => {
await api.updateLibraryContainerChildren(containerId, ['test']);
expect(axiosMock.history.patch[0].url).toEqual(url);
});
it('should remove container children', async () => {
const containerId = 'lct:org:lib1';
const url = api.getLibraryContainerChildrenApiUrl(containerId);
axiosMock.onDelete(url).reply(200);
await api.removeLibraryContainerChildren(containerId, ['test']);
expect(axiosMock.history.delete[0].url).toEqual(url);
});
});

View File

@@ -684,3 +684,19 @@ export async function updateLibraryContainerChildren(
);
return camelCaseObject(data);
}
/**
* Remove components in `children` from library container.
*/
export async function removeLibraryContainerChildren(
containerId: string,
children: string[],
): Promise<LibraryBlockMetadata[]> {
const { data } = await getAuthenticatedHttpClient().delete(
getLibraryContainerChildrenApiUrl(containerId),
{
data: { usage_keys: children },
},
);
return camelCaseObject(data);
}

View File

@@ -30,6 +30,7 @@ import {
useContainerChildren,
useAddComponentsToContainer,
useUpdateContainerChildren,
useRemoveContainerChildren,
} from './apiHooks';
let axiosMock;
@@ -287,4 +288,24 @@ describe('library api hooks', () => {
expect(axiosMock.history.patch.length).toEqual(0);
});
});
it('should remove container children', async () => {
const containerId = 'lct:org:lib1';
const url = getLibraryContainerChildrenApiUrl(containerId);
axiosMock.onDelete(url).reply(200);
const { result } = renderHook(() => useRemoveContainerChildren(containerId), { wrapper });
await result.current.mutateAsync([]);
await waitFor(() => {
expect(axiosMock.history.delete[0].url).toEqual(url);
});
});
it('should not attempt request if containerId is not defined in remove children from container', async () => {
const { result } = renderHook(() => useRemoveContainerChildren(), { wrapper });
await result.current.mutateAsync([]);
await waitFor(() => {
expect(axiosMock.history.patch.length).toEqual(0);
});
});
});

View File

@@ -57,6 +57,7 @@ import {
getLibraryContainerChildren,
updateContainerCollections,
updateLibraryContainerChildren,
removeLibraryContainerChildren,
} from './api';
import { VersionSpec } from '../LibraryBlock';
@@ -732,3 +733,28 @@ export const useUpdateContainerChildren = (containerId?: string) => {
},
});
};
/**
* Remove components from container
*/
export const useRemoveContainerChildren = (containerId?: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (usageKeys: string[]) => {
if (!containerId) {
return undefined;
}
return removeLibraryContainerChildren(containerId, usageKeys);
},
onSettled: () => {
if (!containerId) {
return;
}
// NOTE: We invalidate the library query here because we need to update the container
// count in the library
const libraryId = getLibraryId(containerId);
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.container(containerId) });
},
});
};

View File

@@ -50,9 +50,7 @@ jest.mock('@dnd-kit/core', () => ({
describe('<LibraryUnitPage />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
({ axiosMock, mockShowToast } = initializeMocks());
});
afterEach(() => {
@@ -183,7 +181,7 @@ describe('<LibraryUnitPage />', () => {
expect(await screen.findByTestId('library-sidebar')).toBeInTheDocument();
});
it('should open and component sidebar on component selection', async () => {
it('should open and close component sidebar on component selection', async () => {
renderLibraryUnitPage();
const component = await screen.findByText('text block 0');
@@ -232,6 +230,109 @@ describe('<LibraryUnitPage />', () => {
await waitFor(() => expect(mockShowToast).toHaveBeenLastCalledWith('Failed to update components order'));
});
it('should remove a component & restore from component card', async () => {
const url = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId);
axiosMock.onDelete(url).reply(200);
renderLibraryUnitPage();
expect(await screen.findByText('text block 0')).toBeInTheDocument();
const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0];
fireEvent.click(menu);
const removeButton = await screen.getByText('Remove from unit');
fireEvent.click(removeButton);
await waitFor(() => {
expect(axiosMock.history.delete[0].url).toEqual(url);
});
await waitFor(() => expect(mockShowToast).toHaveBeenCalled());
// Get restore / undo func from the toast
// @ts-ignore
const restoreFn = mockShowToast.mock.calls[0][1].onClick;
const restoreUrl = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId);
axiosMock.onPost(restoreUrl).reply(200);
// restore collection
restoreFn();
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
});
expect(mockShowToast).toHaveBeenCalledWith('Undo successful');
});
it('should show error on remove a component', async () => {
const url = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId);
axiosMock.onDelete(url).reply(404);
renderLibraryUnitPage();
expect(await screen.findByText('text block 0')).toBeInTheDocument();
const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0];
fireEvent.click(menu);
const removeButton = await screen.getByText('Remove from unit');
fireEvent.click(removeButton);
await waitFor(() => {
expect(axiosMock.history.delete[0].url).toEqual(url);
});
expect(mockShowToast).toHaveBeenCalledWith('Failed to remove component');
});
it('should show error on restore removed component', async () => {
const url = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId);
axiosMock.onDelete(url).reply(200);
renderLibraryUnitPage();
expect(await screen.findByText('text block 0')).toBeInTheDocument();
const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0];
fireEvent.click(menu);
const removeButton = await screen.getByText('Remove from unit');
fireEvent.click(removeButton);
await waitFor(() => {
expect(axiosMock.history.delete[0].url).toEqual(url);
});
await waitFor(() => expect(mockShowToast).toHaveBeenCalled());
// Get restore / undo func from the toast
// @ts-ignore
const restoreFn = mockShowToast.mock.calls[0][1].onClick;
const restoreUrl = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId);
axiosMock.onPost(restoreUrl).reply(404);
// restore collection
restoreFn();
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
});
expect(mockShowToast).toHaveBeenCalledWith('Failed to undo remove component operation');
});
it('should remove a component from component sidebar', async () => {
const url = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId);
axiosMock.onDelete(url).reply(200);
renderLibraryUnitPage();
const component = await screen.findByText('text block 0');
userEvent.click(component);
const sidebar = await screen.findByTestId('library-sidebar');
const { findByRole, findByText } = within(sidebar);
const menu = await findByRole('button', { name: /component actions menu/i });
fireEvent.click(menu);
const removeButton = await findByText('Remove from unit');
fireEvent.click(removeButton);
await waitFor(() => {
expect(axiosMock.history.delete[0].url).toEqual(url);
});
await waitFor(() => expect(mockShowToast).toHaveBeenCalled());
});
it('should show editor on double click', async () => {
renderLibraryUnitPage();
const component = await screen.findByText('text block 0');