From bb88101255ce1515045be54102afd7466ef24c93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 8 Aug 2024 13:32:04 -0300 Subject: [PATCH] feat: add "copy to clipboard" feature to library authoring UI (#1197) --- .../components/ComponentCard.test.tsx | 126 ++++++++++++++++++ .../components/ComponentCard.tsx | 70 +++++----- src/library-authoring/components/messages.ts | 20 ++- src/search-manager/data/api.ts | 15 ++- 4 files changed, 197 insertions(+), 34 deletions(-) create mode 100644 src/library-authoring/components/ComponentCard.test.tsx 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; } /**