diff --git a/src/library-authoring/components/ComponentCard.test.tsx b/src/library-authoring/components/ComponentCard.test.tsx
new file mode 100644
index 000000000..0041f5e9a
--- /dev/null
+++ b/src/library-authoring/components/ComponentCard.test.tsx
@@ -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 = () => (
+
+
+
+
+
+
+
+);
+
+describe('', () => {
+ 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();
+
+ 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();
+
+ // 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();
+
+ // 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();
+ });
+ });
+});
diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx
index ce9ef594a..9bae1303f 100644
--- a/src/library-authoring/components/ComponentCard.tsx
+++ b/src/library-authoring/components/ComponentCard.tsx
@@ -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 = () => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-);
+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 (
+
+
+
+
+ {intl.formatMessage(messages.menuEdit)}
+
+
+ {intl.formatMessage(messages.menuCopyToClipboard)}
+
+
+ {intl.formatMessage(messages.menuAddToCollection)}
+
+
+
+ );
+};
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={(
-
+
)}
/>
diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts
index 1e80f26c7..7802cd479 100644
--- a/src/library-authoring/components/messages.ts
+++ b/src/library-authoring/components/messages.ts
@@ -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;
diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts
index a16055df6..42d04981e 100644
--- a/src/search-manager/data/api.ts
+++ b/src/search-manager/data/api.ts
@@ -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 ... highlights */
formatted: { displayName: string, content?: ContentDetails };
created: number;
modified: number;
- last_published: number;
+ lastPublished: number | null;
}
/**