Skip to content

Commit bb88101

Browse files
authored
feat: add "copy to clipboard" feature to library authoring UI (#1197)
1 parent a7645af commit bb88101

File tree

4 files changed

+197
-34
lines changed

4 files changed

+197
-34
lines changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import React from 'react';
2+
import { AppProvider } from '@edx/frontend-platform/react';
3+
import { initializeMockApp } from '@edx/frontend-platform';
4+
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
5+
import { IntlProvider } from '@edx/frontend-platform/i18n';
6+
import { render, fireEvent, waitFor } from '@testing-library/react';
7+
import MockAdapter from 'axios-mock-adapter';
8+
import type { Store } from 'redux';
9+
10+
import { ToastProvider } from '../../generic/toast-context';
11+
import { getClipboardUrl } from '../../generic/data/api';
12+
import { ContentHit } from '../../search-manager';
13+
import initializeStore from '../../store';
14+
import ComponentCard from './ComponentCard';
15+
16+
let store: Store;
17+
let axiosMock: MockAdapter;
18+
19+
const contentHit: ContentHit = {
20+
id: '1',
21+
usageKey: 'lb:org1:demolib:html:a1fa8bdd-dc67-4976-9bf5-0ea75a9bca3d',
22+
type: 'library_block',
23+
blockId: 'a1fa8bdd-dc67-4976-9bf5-0ea75a9bca3d',
24+
contextKey: 'lb:org1:Demo_Course',
25+
org: 'org1',
26+
breadcrumbs: [{ displayName: 'Demo Lib' }],
27+
displayName: 'Text Display Name',
28+
formatted: {
29+
displayName: 'Text Display Formated Name',
30+
content: {
31+
htmlContent: 'This is a text: ID=1',
32+
},
33+
},
34+
tags: {
35+
level0: ['1', '2', '3'],
36+
},
37+
blockType: 'text',
38+
created: 1722434322294,
39+
modified: 1722434322294,
40+
lastPublished: null,
41+
};
42+
43+
const RootWrapper = () => (
44+
<AppProvider store={store}>
45+
<IntlProvider locale="en">
46+
<ToastProvider>
47+
<ComponentCard
48+
contentHit={contentHit}
49+
blockTypeDisplayName="text"
50+
/>
51+
</ToastProvider>
52+
</IntlProvider>
53+
</AppProvider>
54+
);
55+
56+
describe('<ComponentCard />', () => {
57+
beforeEach(() => {
58+
initializeMockApp({
59+
authenticatedUser: {
60+
userId: 3,
61+
username: 'abc123',
62+
administrator: true,
63+
roles: [],
64+
},
65+
});
66+
store = initializeStore();
67+
68+
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
69+
});
70+
71+
afterEach(() => {
72+
jest.clearAllMocks();
73+
axiosMock.restore();
74+
});
75+
76+
it('should render the card with title and description', () => {
77+
const { getByText } = render(<RootWrapper />);
78+
79+
expect(getByText('Text Display Formated Name')).toBeInTheDocument();
80+
expect(getByText('This is a text: ID=1')).toBeInTheDocument();
81+
});
82+
83+
it('should call the updateClipboard function when the copy button is clicked', async () => {
84+
axiosMock.onPost(getClipboardUrl()).reply(200, {});
85+
const { getByRole, getByTestId, getByText } = render(<RootWrapper />);
86+
87+
// Open menu
88+
expect(getByTestId('component-card-menu-toggle')).toBeInTheDocument();
89+
fireEvent.click(getByTestId('component-card-menu-toggle'));
90+
91+
// Click copy to clipboard
92+
expect(getByRole('button', { name: 'Copy to clipboard' })).toBeInTheDocument();
93+
fireEvent.click(getByRole('button', { name: 'Copy to clipboard' }));
94+
95+
expect(axiosMock.history.post.length).toBe(1);
96+
expect(axiosMock.history.post[0].data).toBe(
97+
JSON.stringify({ usage_key: contentHit.usageKey }),
98+
);
99+
100+
await waitFor(() => {
101+
expect(getByText('Component copied to clipboard')).toBeInTheDocument();
102+
});
103+
});
104+
105+
it('should show error message if the api call fails', async () => {
106+
axiosMock.onPost(getClipboardUrl()).reply(400);
107+
const { getByRole, getByTestId, getByText } = render(<RootWrapper />);
108+
109+
// Open menu
110+
expect(getByTestId('component-card-menu-toggle')).toBeInTheDocument();
111+
fireEvent.click(getByTestId('component-card-menu-toggle'));
112+
113+
// Click copy to clipboard
114+
expect(getByRole('button', { name: 'Copy to clipboard' })).toBeInTheDocument();
115+
fireEvent.click(getByRole('button', { name: 'Copy to clipboard' }));
116+
117+
expect(axiosMock.history.post.length).toBe(1);
118+
expect(axiosMock.history.post[0].data).toBe(
119+
JSON.stringify({ usage_key: contentHit.usageKey }),
120+
);
121+
122+
await waitFor(() => {
123+
expect(getByText('Failed to copy component to clipboard')).toBeInTheDocument();
124+
});
125+
});
126+
});

src/library-authoring/components/ComponentCard.tsx

Lines changed: 40 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import React, { useMemo } from 'react';
1+
import React, { useContext, useMemo } from 'react';
2+
import { useIntl } from '@edx/frontend-platform/i18n';
23
import {
34
ActionRow,
45
Card,
@@ -9,10 +10,11 @@ import {
910
Stack,
1011
} from '@openedx/paragon';
1112
import { MoreVert } from '@openedx/paragon/icons';
12-
import { FormattedMessage } from '@edx/frontend-platform/i18n';
1313

1414
import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils';
15+
import { updateClipboard } from '../../generic/data/api';
1516
import TagCount from '../../generic/tag-count';
17+
import { ToastContext } from '../../generic/toast-context';
1618
import { type ContentHit, Highlight } from '../../search-manager';
1719
import messages from './messages';
1820

@@ -21,39 +23,47 @@ type ComponentCardProps = {
2123
blockTypeDisplayName: string,
2224
};
2325

24-
const ComponentCardMenu = () => (
25-
<Dropdown>
26-
<Dropdown.Toggle
27-
as={IconButton}
28-
src={MoreVert}
29-
iconAs={Icon}
30-
variant="primary"
31-
/>
32-
<Dropdown.Menu>
33-
<Dropdown.Item disabled>
34-
<FormattedMessage
35-
{...messages.menuEdit}
36-
/>
37-
</Dropdown.Item>
38-
<Dropdown.Item disabled>
39-
<FormattedMessage
40-
{...messages.menuCopyToClipboard}
41-
/>
42-
</Dropdown.Item>
43-
<Dropdown.Item disabled>
44-
<FormattedMessage
45-
{...messages.menuAddToCollection}
46-
/>
47-
</Dropdown.Item>
48-
</Dropdown.Menu>
49-
</Dropdown>
50-
);
26+
const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
27+
const intl = useIntl();
28+
const { showToast } = useContext(ToastContext);
29+
const updateClipboardClick = () => {
30+
updateClipboard(usageKey)
31+
.then(() => showToast(intl.formatMessage(messages.copyToClipboardSuccess)))
32+
.catch(() => showToast(intl.formatMessage(messages.copyToClipboardError)));
33+
};
34+
35+
return (
36+
<Dropdown id="component-card-dropdown">
37+
<Dropdown.Toggle
38+
id="component-card-menu-toggle"
39+
as={IconButton}
40+
src={MoreVert}
41+
iconAs={Icon}
42+
variant="primary"
43+
alt={intl.formatMessage(messages.componentCardMenuAlt)}
44+
data-testid="component-card-menu-toggle"
45+
/>
46+
<Dropdown.Menu>
47+
<Dropdown.Item disabled>
48+
{intl.formatMessage(messages.menuEdit)}
49+
</Dropdown.Item>
50+
<Dropdown.Item onClick={updateClipboardClick}>
51+
{intl.formatMessage(messages.menuCopyToClipboard)}
52+
</Dropdown.Item>
53+
<Dropdown.Item disabled>
54+
{intl.formatMessage(messages.menuAddToCollection)}
55+
</Dropdown.Item>
56+
</Dropdown.Menu>
57+
</Dropdown>
58+
);
59+
};
5160

5261
const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps) => {
5362
const {
5463
blockType,
5564
formatted,
5665
tags,
66+
usageKey,
5767
} = contentHit;
5868
const description = formatted?.content?.htmlContent ?? '';
5969
const displayName = formatted?.displayName ?? '';
@@ -77,7 +87,7 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps
7787
}
7888
actions={(
7989
<ActionRow>
80-
<ComponentCardMenu />
90+
<ComponentCardMenu usageKey={usageKey} />
8191
</ActionRow>
8292
)}
8393
/>
Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,41 @@
11
import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n';
2+
23
import type { defineMessages as defineMessagesType } from 'react-intl';
34

45
// frontend-platform currently doesn't provide types... do it ourselves.
56
const defineMessages = _defineMessages as typeof defineMessagesType;
67

78
const messages = defineMessages({
9+
componentCardMenuAlt: {
10+
id: 'course-authoring.library-authoring.component.menu',
11+
defaultMessage: 'Component actions menu',
12+
description: 'Alt/title text for the component card menu button.',
13+
},
814
menuEdit: {
915
id: 'course-authoring.library-authoring.component.menu.edit',
1016
defaultMessage: 'Edit',
1117
description: 'Menu item for edit a component.',
1218
},
1319
menuCopyToClipboard: {
1420
id: 'course-authoring.library-authoring.component.menu.copy',
15-
defaultMessage: 'Copy to Clipboard',
21+
defaultMessage: 'Copy to clipboard',
1622
description: 'Menu item for copy a component.',
1723
},
1824
menuAddToCollection: {
1925
id: 'course-authoring.library-authoring.component.menu.add',
20-
defaultMessage: 'Add to Collection',
26+
defaultMessage: 'Add to collection',
2127
description: 'Menu item for add a component to collection.',
2228
},
29+
copyToClipboardSuccess: {
30+
id: 'course-authoring.library-authoring.component.copyToClipboardSuccess',
31+
defaultMessage: 'Component copied to clipboard',
32+
description: 'Message for successful copy component to clipboard.',
33+
},
34+
copyToClipboardError: {
35+
id: 'course-authoring.library-authoring.component.copyToClipboardError',
36+
defaultMessage: 'Failed to copy component to clipboard',
37+
description: 'Message for failed to copy component to clipboard.',
38+
},
2339
});
2440

2541
export default messages;

src/search-manager/data/api.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ function formatTagsFilter(tagsFilter?: string[]): string[] {
8080
return filters;
8181
}
8282

83+
/**
84+
* The tags that are associated with a search result, at various levels of the tag hierarchy.
85+
*/
86+
interface ContentHitTags {
87+
taxonomy?: string[];
88+
level0?: string[];
89+
level1?: string[];
90+
level2?: string[];
91+
level3?: string[];
92+
}
93+
8394
/**
8495
* Information about a single XBlock returned in the search results
8596
* Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py
@@ -101,13 +112,13 @@ export interface ContentHit {
101112
* - After that is the name and usage key of any parent Section/Subsection/Unit/etc.
102113
*/
103114
breadcrumbs: [{ displayName: string }, ...Array<{ displayName: string, usageKey: string }>];
104-
tags: Record<'taxonomy' | 'level0' | 'level1' | 'level2' | 'level3', string[]>;
115+
tags: ContentHitTags;
105116
content?: ContentDetails;
106117
/** Same fields with <mark>...</mark> highlights */
107118
formatted: { displayName: string, content?: ContentDetails };
108119
created: number;
109120
modified: number;
110-
last_published: number;
121+
lastPublished: number | null;
111122
}
112123

113124
/**

0 commit comments

Comments
 (0)