Skip to content

Commit

Permalink
feat: add copy to clipboard feature to library authoring
Browse files Browse the repository at this point in the history
  • Loading branch information
rpenido committed Aug 2, 2024
1 parent cba85ab commit a12c942
Show file tree
Hide file tree
Showing 4 changed files with 192 additions and 34 deletions.
126 changes: 126 additions & 0 deletions src/library-authoring/components/ComponentCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
70 changes: 40 additions & 30 deletions src/library-authoring/components/ComponentCard.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';

Expand All @@ -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="component-card-menu-toggle" // FixMe: Add alt text
data-testid="component-card-menu-toggle"
/>
<Dropdown.Menu>
<Dropdown.Item disabled>
{intl.formatMessage(messages.menuEdit)}
</Dropdown.Item>
<Dropdown.Item onClick={updateClipboardClick} data-testid="component-card-menu-copy-clipboard">
{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 ?? '';
Expand All @@ -77,7 +87,7 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps
}
actions={(
<ActionRow>
<ComponentCardMenu />
<ComponentCardMenu usageKey={usageKey} />
</ActionRow>
)}
/>
Expand Down
15 changes: 13 additions & 2 deletions src/library-authoring/components/messages.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
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.
Expand All @@ -12,14 +13,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;
15 changes: 13 additions & 2 deletions src/search-manager/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

/**
Expand Down

0 comments on commit a12c942

Please sign in to comment.