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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
137
src/library-authoring/components/ComponentMenu.tsx
Normal file
137
src/library-authoring/components/ComponentMenu.tsx
Normal 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;
|
||||
@@ -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));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { ComponentMenu as default } from './ComponentCard';
|
||||
export { ComponentMenu as default } from './ComponentMenu';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user