feat: Menu option to delete a component + small fixes (#1408)
* feat: menu option to delete a component * feat: close component sidebar if it's open when that component id deleted * feat: hide unsupported block types from the "Add Content" menu * fix: expand and internationalize the "component usage" text
This commit is contained in:
@@ -25,13 +25,13 @@ describe('<AddContentContainer />', () => {
|
||||
initializeMocks();
|
||||
mockClipboardEmpty.applyMock();
|
||||
render();
|
||||
expect(screen.getByRole('button', { name: /collection/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /text/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /problem/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /open reponse/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /drag drop/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /video/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /advanced \/ other/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /collection/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /text/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /problem/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /open reponse/i })).not.toBeInTheDocument(); // Excluded from MVP
|
||||
expect(screen.queryByRole('button', { name: /drag drop/i })).not.toBeInTheDocument(); // Excluded from MVP
|
||||
expect(screen.queryByRole('button', { name: /video/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /advanced \/ other/i })).not.toBeInTheDocument(); // Excluded from MVP
|
||||
expect(screen.queryByRole('button', { name: /copy from clipboard/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -199,7 +199,8 @@ const AddContentContainer = () => {
|
||||
<Stack direction="vertical">
|
||||
{!collectionId && <AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />}
|
||||
<hr className="w-100 bg-gray-500" />
|
||||
{contentTypes.map((contentType) => (
|
||||
{/* Note: for MVP we are hiding the unuspported types, not just disabling them. */}
|
||||
{contentTypes.filter(ct => !ct.disabled).map((contentType) => (
|
||||
<AddContentButton
|
||||
key={`add-content-${contentType.blockType}`}
|
||||
contentType={contentType}
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('<ComponentDetails />', () => {
|
||||
render(mockLibraryBlockMetadata.usageKeyNeverPublished);
|
||||
expect(await screen.findByText('Component Usage')).toBeInTheDocument();
|
||||
// TODO: replace with actual data when implement course list
|
||||
expect(screen.queryByText('This will show the courses that use this component.')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/This will show the courses that use this component./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the component history', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Stack } from '@openedx/paragon';
|
||||
|
||||
import AlertError from '../../generic/alert-error';
|
||||
@@ -10,8 +10,6 @@ import { ComponentAdvancedInfo } from './ComponentAdvancedInfo';
|
||||
import messages from './messages';
|
||||
|
||||
const ComponentDetails = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { sidebarComponentUsageKey: usageKey } = useLibraryContext();
|
||||
|
||||
// istanbul ignore if: this should never happen
|
||||
@@ -38,18 +36,16 @@ const ComponentDetails = () => {
|
||||
<Stack gap={3}>
|
||||
<div>
|
||||
<h3 className="h5">
|
||||
{intl.formatMessage(messages.detailsTabUsageTitle)}
|
||||
<FormattedMessage {...messages.detailsTabUsageTitle} />
|
||||
</h3>
|
||||
<small>This will show the courses that use this component.</small>
|
||||
<small><FormattedMessage {...messages.detailsTabUsagePlaceholder} /></small>
|
||||
</div>
|
||||
<hr className="w-100" />
|
||||
<div>
|
||||
<h3 className="h5">
|
||||
{intl.formatMessage(messages.detailsTabHistoryTitle)}
|
||||
<FormattedMessage {...messages.detailsTabHistoryTitle} />
|
||||
</h3>
|
||||
<HistoryWidget
|
||||
{...componentMetadata}
|
||||
/>
|
||||
<HistoryWidget {...componentMetadata} />
|
||||
</div>
|
||||
<ComponentAdvancedInfo />
|
||||
</Stack>
|
||||
|
||||
@@ -106,6 +106,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Component Usage',
|
||||
description: 'Title for the Component Usage container in the details tab',
|
||||
},
|
||||
detailsTabUsagePlaceholder: {
|
||||
id: 'course-authoring.library-authoring.component.details-tab.usage-placeholder',
|
||||
defaultMessage: 'This will show the courses that use this component. Feature coming soon.',
|
||||
description: 'Explanation/placeholder for the future "Component Usage" feature',
|
||||
},
|
||||
detailsTabHistoryTitle: {
|
||||
id: 'course-authoring.library-authoring.component.details-tab.history-title',
|
||||
defaultMessage: 'Component History',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Dropdown,
|
||||
Icon,
|
||||
IconButton,
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { AddCircleOutline, MoreVert } from '@openedx/paragon/icons';
|
||||
|
||||
@@ -18,6 +19,7 @@ import { useRemoveComponentsFromCollection } from '../data/apiHooks';
|
||||
import BaseComponentCard from './BaseComponentCard';
|
||||
import { canEditComponent } from './ComponentEditorModal';
|
||||
import messages from './messages';
|
||||
import ComponentDeleter from './ComponentDeleter';
|
||||
|
||||
type ComponentCardProps = {
|
||||
contentHit: ContentHit,
|
||||
@@ -37,6 +39,8 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
|
||||
const removeComponentsMutation = useRemoveComponentsFromCollection(libraryId, collectionId);
|
||||
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
|
||||
|
||||
const updateClipboardClick = () => {
|
||||
updateClipboard(usageKey)
|
||||
.then((clipboardData) => {
|
||||
@@ -76,6 +80,9 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
<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} />
|
||||
@@ -85,6 +92,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
<FormattedMessage {...messages.menuAddToCollection} />
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
<ComponentDeleter usageKey={usageKey} isConfirmingDelete={isConfirmingDelete} cancelDelete={cancelDelete} />
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
68
src/library-authoring/components/ComponentDeleter.test.tsx
Normal file
68
src/library-authoring/components/ComponentDeleter.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { getLibraryId } from '../../generic/key-utils';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
initializeMocks,
|
||||
waitFor,
|
||||
} from '../../testUtils';
|
||||
import { LibraryProvider } from '../common/context';
|
||||
import { mockContentLibrary, mockDeleteLibraryBlock, mockLibraryBlockMetadata } from '../data/api.mocks';
|
||||
import ComponentDeleter from './ComponentDeleter';
|
||||
|
||||
mockContentLibrary.applyMock(); // Not required, but avoids 404 errors in the logs when <LibraryProvider> loads data
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
const mockDelete = mockDeleteLibraryBlock.applyMock();
|
||||
|
||||
const usageKey = mockLibraryBlockMetadata.usageKeyPublished;
|
||||
|
||||
const renderArgs = {
|
||||
extraWrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<LibraryProvider libraryId={getLibraryId(usageKey)}>{children}</LibraryProvider>
|
||||
),
|
||||
};
|
||||
|
||||
describe('<ComponentDeleter />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('is invisible when isConfirmingDelete is false', async () => {
|
||||
const mockCancel = jest.fn();
|
||||
render(<ComponentDeleter usageKey={usageKey} isConfirmingDelete={false} cancelDelete={mockCancel} />, renderArgs);
|
||||
|
||||
const modal = screen.queryByRole('dialog', { name: 'Delete Component' });
|
||||
expect(modal).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should shows a confirmation prompt the card with title and description', async () => {
|
||||
const mockCancel = jest.fn();
|
||||
render(<ComponentDeleter usageKey={usageKey} isConfirmingDelete cancelDelete={mockCancel} />, renderArgs);
|
||||
|
||||
const modal = screen.getByRole('dialog', { name: 'Delete Component' });
|
||||
expect(modal).toBeVisible();
|
||||
|
||||
// It should mention the component's name in the confirm dialog:
|
||||
await screen.findByText('Introduction to Testing 2');
|
||||
|
||||
// Clicking cancel will cancel:
|
||||
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
|
||||
fireEvent.click(cancelButton);
|
||||
expect(mockCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes the block when confirmed', async () => {
|
||||
const mockCancel = jest.fn();
|
||||
render(<ComponentDeleter usageKey={usageKey} isConfirmingDelete cancelDelete={mockCancel} />, renderArgs);
|
||||
|
||||
const modal = screen.getByRole('dialog', { name: 'Delete Component' });
|
||||
expect(modal).toBeVisible();
|
||||
|
||||
const deleteButton = screen.getByRole('button', { name: 'Delete' });
|
||||
fireEvent.click(deleteButton);
|
||||
await waitFor(() => {
|
||||
expect(mockDelete).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockCancel).toHaveBeenCalled(); // In order to close the modal, this also gets called.
|
||||
});
|
||||
});
|
||||
84
src/library-authoring/components/ComponentDeleter.tsx
Normal file
84
src/library-authoring/components/ComponentDeleter.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
AlertModal,
|
||||
Button,
|
||||
} from '@openedx/paragon';
|
||||
import { Warning } from '@openedx/paragon/icons';
|
||||
|
||||
import { useLibraryContext } from '../common/context';
|
||||
import { useDeleteLibraryBlock, useLibraryBlockMetadata } from '../data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* Helper component to load and display the name of the block.
|
||||
*
|
||||
* This needs to be a separate component so that we only query the metadata of
|
||||
* the block when needed (when this is displayed), not on every card shown in
|
||||
* the search results.
|
||||
*/
|
||||
const BlockName = (props: { usageKey: string }) => {
|
||||
const { data: blockMetadata } = useLibraryBlockMetadata(props.usageKey);
|
||||
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <>{blockMetadata?.displayName}</> ?? <FormattedMessage {...messages.deleteComponentNamePlaceholder} />;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
usageKey: string;
|
||||
/** If true, show a confirmation modal that asks the user if they want to delete this component. */
|
||||
isConfirmingDelete: boolean;
|
||||
cancelDelete: () => void;
|
||||
}
|
||||
|
||||
const ComponentDeleter = ({ usageKey, ...props }: Props) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
sidebarComponentUsageKey,
|
||||
closeLibrarySidebar,
|
||||
} = useLibraryContext();
|
||||
|
||||
const deleteComponentMutation = useDeleteLibraryBlock();
|
||||
const doDelete = React.useCallback(() => {
|
||||
deleteComponentMutation.mutateAsync({ usageKey });
|
||||
props.cancelDelete();
|
||||
// Close the sidebar if it's still open showing the deleted component:
|
||||
if (usageKey === sidebarComponentUsageKey) {
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
}, [usageKey, sidebarComponentUsageKey, closeLibrarySidebar]);
|
||||
|
||||
if (!props.isConfirmingDelete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertModal
|
||||
title={intl.formatMessage(messages.deleteComponentWarningTitle)}
|
||||
isOpen
|
||||
onClose={props.cancelDelete}
|
||||
variant="warning"
|
||||
icon={Warning}
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button variant="tertiary" onClick={props.cancelDelete}><FormattedMessage {...messages.deleteComponentCancelButton} /></Button>
|
||||
<Button variant="danger" onClick={doDelete}><FormattedMessage {...messages.deleteComponentButton} /></Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
{...messages.deleteComponentConfirm}
|
||||
values={{
|
||||
componentName: (
|
||||
<strong><BlockName usageKey={usageKey} /></strong>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</AlertModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentDeleter;
|
||||
@@ -26,6 +26,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Copy to clipboard',
|
||||
description: 'Menu item for copy a component.',
|
||||
},
|
||||
menuDelete: {
|
||||
id: 'course-authoring.library-authoring.component.menu.delete',
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Menu item for deleting a component.',
|
||||
},
|
||||
menuAddToCollection: {
|
||||
id: 'course-authoring.library-authoring.component.menu.add',
|
||||
defaultMessage: 'Add to collection',
|
||||
@@ -56,6 +61,31 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Failed to copy component to clipboard',
|
||||
description: 'Message for failed to copy component to clipboard.',
|
||||
},
|
||||
deleteComponentWarningTitle: {
|
||||
id: 'course-authoring.library-authoring.component.delete-confirmation-title',
|
||||
defaultMessage: 'Delete Component',
|
||||
description: 'Title text for the warning displayed before deleting a component',
|
||||
},
|
||||
deleteComponentNamePlaceholder: {
|
||||
id: 'course-authoring.library-authoring.component.delete-confirmation-placeholder',
|
||||
defaultMessage: 'this component',
|
||||
description: 'Text shown in place of the component\'s title while we\'re loading the title',
|
||||
},
|
||||
deleteComponentConfirm: {
|
||||
id: 'course-authoring.library-authoring.component.delete-confirmation-text',
|
||||
defaultMessage: 'Delete {componentName} permanently? If this component has been used in a course, those copies won\'t be deleted, but they will no longer receive updates from the library.',
|
||||
description: 'Confirmation text to display before deleting a component',
|
||||
},
|
||||
deleteComponentCancelButton: {
|
||||
id: 'course-authoring.library-authoring.component.cancel-delete-button',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Button to cancel deletion of a component',
|
||||
},
|
||||
deleteComponentButton: {
|
||||
id: 'course-authoring.library-authoring.component.confirm-delete-button',
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Button to confirm deletion of a component',
|
||||
},
|
||||
deleteCollection: {
|
||||
id: 'course-authoring.library-authoring.collection.delete-menu-text',
|
||||
defaultMessage: 'Delete',
|
||||
|
||||
@@ -48,6 +48,8 @@ export async function mockContentLibrary(libraryId: string): Promise<api.Content
|
||||
throw createAxiosError({ code: 500, message: 'Internal Error.', path: api.getContentLibraryApiUrl(libraryId) });
|
||||
case mockContentLibrary.libraryId:
|
||||
return mockContentLibrary.libraryData;
|
||||
case mockContentLibrary.libraryId2:
|
||||
return { ...mockContentLibrary.libraryData, id: mockContentLibrary.libraryId2, slug: 'TEST2' };
|
||||
case mockContentLibrary.libraryIdReadOnly:
|
||||
return {
|
||||
...mockContentLibrary.libraryData,
|
||||
@@ -148,6 +150,7 @@ mockContentLibrary.libraryData = {
|
||||
created: '2024-06-26T14:19:59Z',
|
||||
updated: '2024-07-20T17:36:51Z',
|
||||
} satisfies api.ContentLibrary;
|
||||
mockContentLibrary.libraryId2 = 'lib:Axim:TEST2';
|
||||
mockContentLibrary.libraryIdReadOnly = 'lib:Axim:readOnly';
|
||||
mockContentLibrary.libraryIdThatNeverLoads = 'lib:Axim:infiniteLoading';
|
||||
mockContentLibrary.library404 = 'lib:Axim:error404';
|
||||
@@ -229,6 +232,17 @@ mockCreateLibraryBlock.applyMock = () => (
|
||||
jest.spyOn(api, 'createLibraryBlock').mockImplementation(mockCreateLibraryBlock)
|
||||
);
|
||||
|
||||
/**
|
||||
* Mock for `deleteLibraryBlock()`
|
||||
*/
|
||||
export async function mockDeleteLibraryBlock(): ReturnType<typeof api.deleteLibraryBlock> {
|
||||
// no-op
|
||||
}
|
||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||
mockDeleteLibraryBlock.applyMock = () => (
|
||||
jest.spyOn(api, 'deleteLibraryBlock').mockImplementation(mockDeleteLibraryBlock)
|
||||
);
|
||||
|
||||
/**
|
||||
* Mock for `getXBlockFields()`
|
||||
*
|
||||
|
||||
@@ -18,6 +18,17 @@ 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);
|
||||
await api.deleteLibraryBlock({ usageKey });
|
||||
expect(axiosMock.history.delete[0].url).toEqual(url);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commitLibraryChanges', () => {
|
||||
it('should commit library changes', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
|
||||
@@ -183,6 +183,10 @@ export interface CreateBlockDataRequest {
|
||||
definitionId: string;
|
||||
}
|
||||
|
||||
export interface DeleteBlockDataRequest {
|
||||
usageKey: string;
|
||||
}
|
||||
|
||||
export interface CollectionMetadata {
|
||||
key: string;
|
||||
title: string;
|
||||
@@ -257,6 +261,11 @@ export async function createLibraryBlock({
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function deleteLibraryBlock({ usageKey }: DeleteBlockDataRequest): Promise<void> {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
await client.delete(getLibraryBlockMetadataUrl(usageKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update library metadata.
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type UpdateXBlockFieldsRequest,
|
||||
getContentLibrary,
|
||||
createLibraryBlock,
|
||||
deleteLibraryBlock,
|
||||
getContentLibraryV2List,
|
||||
commitLibraryChanges,
|
||||
revertLibraryChanges,
|
||||
@@ -137,6 +138,22 @@ export const useCreateLibraryBlock = () => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this mutation to delete a block in a library
|
||||
*/
|
||||
export const useDeleteLibraryBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteLibraryBlock,
|
||||
onSettled: (_data, _error, variables) => {
|
||||
const libraryId = getLibraryId(variables.usageKey);
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) });
|
||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
invalidateComponentData(queryClient, libraryId, variables.usageKey);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateLibraryMetadata = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
Reference in New Issue
Block a user