feat: add "copy to clipboard" feature to library authoring UI (#1197)
This commit is contained in:
126
src/library-authoring/components/ComponentCard.test.tsx
Normal file
126
src/library-authoring/components/ComponentCard.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import type { Store } from 'redux';
|
||||
|
||||
import { ToastProvider } from '../../generic/toast-context';
|
||||
import { getClipboardUrl } from '../../generic/data/api';
|
||||
import { ContentHit } from '../../search-manager';
|
||||
import initializeStore from '../../store';
|
||||
import ComponentCard from './ComponentCard';
|
||||
|
||||
let store: Store;
|
||||
let axiosMock: MockAdapter;
|
||||
|
||||
const contentHit: ContentHit = {
|
||||
id: '1',
|
||||
usageKey: 'lb:org1:demolib:html:a1fa8bdd-dc67-4976-9bf5-0ea75a9bca3d',
|
||||
type: 'library_block',
|
||||
blockId: 'a1fa8bdd-dc67-4976-9bf5-0ea75a9bca3d',
|
||||
contextKey: 'lb:org1:Demo_Course',
|
||||
org: 'org1',
|
||||
breadcrumbs: [{ displayName: 'Demo Lib' }],
|
||||
displayName: 'Text Display Name',
|
||||
formatted: {
|
||||
displayName: 'Text Display Formated Name',
|
||||
content: {
|
||||
htmlContent: 'This is a text: ID=1',
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
level0: ['1', '2', '3'],
|
||||
},
|
||||
blockType: 'text',
|
||||
created: 1722434322294,
|
||||
modified: 1722434322294,
|
||||
lastPublished: null,
|
||||
};
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<ToastProvider>
|
||||
<ComponentCard
|
||||
contentHit={contentHit}
|
||||
blockTypeDisplayName="text"
|
||||
/>
|
||||
</ToastProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<ComponentCard />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
axiosMock.restore();
|
||||
});
|
||||
|
||||
it('should render the card with title and description', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
|
||||
expect(getByText('Text Display Formated Name')).toBeInTheDocument();
|
||||
expect(getByText('This is a text: ID=1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call the updateClipboard function when the copy button is clicked', async () => {
|
||||
axiosMock.onPost(getClipboardUrl()).reply(200, {});
|
||||
const { getByRole, getByTestId, getByText } = render(<RootWrapper />);
|
||||
|
||||
// Open menu
|
||||
expect(getByTestId('component-card-menu-toggle')).toBeInTheDocument();
|
||||
fireEvent.click(getByTestId('component-card-menu-toggle'));
|
||||
|
||||
// Click copy to clipboard
|
||||
expect(getByRole('button', { name: 'Copy to clipboard' })).toBeInTheDocument();
|
||||
fireEvent.click(getByRole('button', { name: 'Copy to clipboard' }));
|
||||
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(
|
||||
JSON.stringify({ usage_key: contentHit.usageKey }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Component copied to clipboard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error message if the api call fails', async () => {
|
||||
axiosMock.onPost(getClipboardUrl()).reply(400);
|
||||
const { getByRole, getByTestId, getByText } = render(<RootWrapper />);
|
||||
|
||||
// Open menu
|
||||
expect(getByTestId('component-card-menu-toggle')).toBeInTheDocument();
|
||||
fireEvent.click(getByTestId('component-card-menu-toggle'));
|
||||
|
||||
// Click copy to clipboard
|
||||
expect(getByRole('button', { name: 'Copy to clipboard' })).toBeInTheDocument();
|
||||
fireEvent.click(getByRole('button', { name: 'Copy to clipboard' }));
|
||||
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(
|
||||
JSON.stringify({ usage_key: contentHit.usageKey }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Failed to copy component to clipboard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useContext, useMemo } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
Card,
|
||||
@@ -9,10 +10,11 @@ import {
|
||||
Stack,
|
||||
} from '@openedx/paragon';
|
||||
import { MoreVert } from '@openedx/paragon/icons';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils';
|
||||
import { updateClipboard } from '../../generic/data/api';
|
||||
import TagCount from '../../generic/tag-count';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { type ContentHit, Highlight } from '../../search-manager';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -21,39 +23,47 @@ type ComponentCardProps = {
|
||||
blockTypeDisplayName: string,
|
||||
};
|
||||
|
||||
const ComponentCardMenu = () => (
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle
|
||||
as={IconButton}
|
||||
src={MoreVert}
|
||||
iconAs={Icon}
|
||||
variant="primary"
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item disabled>
|
||||
<FormattedMessage
|
||||
{...messages.menuEdit}
|
||||
/>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item disabled>
|
||||
<FormattedMessage
|
||||
{...messages.menuCopyToClipboard}
|
||||
/>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item disabled>
|
||||
<FormattedMessage
|
||||
{...messages.menuAddToCollection}
|
||||
/>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
const intl = useIntl();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const updateClipboardClick = () => {
|
||||
updateClipboard(usageKey)
|
||||
.then(() => showToast(intl.formatMessage(messages.copyToClipboardSuccess)))
|
||||
.catch(() => showToast(intl.formatMessage(messages.copyToClipboardError)));
|
||||
};
|
||||
|
||||
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 disabled>
|
||||
{intl.formatMessage(messages.menuEdit)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={updateClipboardClick}>
|
||||
{intl.formatMessage(messages.menuCopyToClipboard)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item disabled>
|
||||
{intl.formatMessage(messages.menuAddToCollection)}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps) => {
|
||||
const {
|
||||
blockType,
|
||||
formatted,
|
||||
tags,
|
||||
usageKey,
|
||||
} = contentHit;
|
||||
const description = formatted?.content?.htmlContent ?? '';
|
||||
const displayName = formatted?.displayName ?? '';
|
||||
@@ -77,7 +87,7 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps
|
||||
}
|
||||
actions={(
|
||||
<ActionRow>
|
||||
<ComponentCardMenu />
|
||||
<ComponentCardMenu usageKey={usageKey} />
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import type { defineMessages as defineMessagesType } from 'react-intl';
|
||||
|
||||
// frontend-platform currently doesn't provide types... do it ourselves.
|
||||
const defineMessages = _defineMessages as typeof defineMessagesType;
|
||||
|
||||
const messages = defineMessages({
|
||||
componentCardMenuAlt: {
|
||||
id: 'course-authoring.library-authoring.component.menu',
|
||||
defaultMessage: 'Component actions menu',
|
||||
description: 'Alt/title text for the component card menu button.',
|
||||
},
|
||||
menuEdit: {
|
||||
id: 'course-authoring.library-authoring.component.menu.edit',
|
||||
defaultMessage: 'Edit',
|
||||
@@ -12,14 +18,24 @@ const messages = defineMessages({
|
||||
},
|
||||
menuCopyToClipboard: {
|
||||
id: 'course-authoring.library-authoring.component.menu.copy',
|
||||
defaultMessage: 'Copy to Clipboard',
|
||||
defaultMessage: 'Copy to clipboard',
|
||||
description: 'Menu item for copy a component.',
|
||||
},
|
||||
menuAddToCollection: {
|
||||
id: 'course-authoring.library-authoring.component.menu.add',
|
||||
defaultMessage: 'Add to Collection',
|
||||
defaultMessage: 'Add to collection',
|
||||
description: 'Menu item for add a component to collection.',
|
||||
},
|
||||
copyToClipboardSuccess: {
|
||||
id: 'course-authoring.library-authoring.component.copyToClipboardSuccess',
|
||||
defaultMessage: 'Component copied to clipboard',
|
||||
description: 'Message for successful copy component to clipboard.',
|
||||
},
|
||||
copyToClipboardError: {
|
||||
id: 'course-authoring.library-authoring.component.copyToClipboardError',
|
||||
defaultMessage: 'Failed to copy component to clipboard',
|
||||
description: 'Message for failed to copy component to clipboard.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -80,6 +80,17 @@ function formatTagsFilter(tagsFilter?: string[]): string[] {
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags that are associated with a search result, at various levels of the tag hierarchy.
|
||||
*/
|
||||
interface ContentHitTags {
|
||||
taxonomy?: string[];
|
||||
level0?: string[];
|
||||
level1?: string[];
|
||||
level2?: string[];
|
||||
level3?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a single XBlock returned in the search results
|
||||
* Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py
|
||||
@@ -101,13 +112,13 @@ export interface ContentHit {
|
||||
* - After that is the name and usage key of any parent Section/Subsection/Unit/etc.
|
||||
*/
|
||||
breadcrumbs: [{ displayName: string }, ...Array<{ displayName: string, usageKey: string }>];
|
||||
tags: Record<'taxonomy' | 'level0' | 'level1' | 'level2' | 'level3', string[]>;
|
||||
tags: ContentHitTags;
|
||||
content?: ContentDetails;
|
||||
/** Same fields with <mark>...</mark> highlights */
|
||||
formatted: { displayName: string, content?: ContentDetails };
|
||||
created: number;
|
||||
modified: number;
|
||||
last_published: number;
|
||||
lastPublished: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user