feat: [FC-0044] Course unit - Copy/paste functionality (#884)

Implement copy/paste.

Co-authored-by: monteri <36768631+monteri@users.noreply.github.com>
Co-authored-by: ihor-romaniuk <ihor.romaniuk@raccoongang.com>
This commit is contained in:
Peter Kulko
2024-04-24 21:27:29 +03:00
committed by GitHub
parent bef6796da4
commit 5686dee43b
69 changed files with 1621 additions and 404 deletions

View File

@@ -1,46 +0,0 @@
import {
useCallback, useEffect, useMemo, useRef,
} from 'react';
import { BroadcastChannel } from 'broadcast-channel';
const channelInstances = {};
export const getSingletonChannel = (name) => {
if (!channelInstances[name]) {
channelInstances[name] = new BroadcastChannel(name);
}
return channelInstances[name];
};
export const useBroadcastChannel = (channelName, onMessageReceived) => {
const channel = useMemo(() => getSingletonChannel(channelName), [channelName]);
const isSubscribed = useRef(false);
useEffect(() => {
if (!isSubscribed.current || process.env.NODE_ENV !== 'development') {
// BroadcastChannel api from npm has minor difference from native BroadcastChannel
// Native BroadcastChannel passes event to onmessage callback and to
// access data we need to use `event.data`, but npm BroadcastChannel
// directly passes data as seen below
channel.onmessage = (data) => onMessageReceived(data);
}
return () => {
if (isSubscribed.current || process.env.NODE_ENV !== 'development') {
channel.close();
isSubscribed.current = true;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const postMessage = useCallback(
(message) => {
channel?.postMessage(message);
},
[channel],
);
return {
postMessage,
};
};

View File

@@ -0,0 +1,61 @@
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { CLIPBOARD_STATUS, STRUCTURAL_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../../constants';
import { getClipboardData } from '../../data/selectors';
/**
* Custom React hook for managing clipboard functionality.
*
* @param {boolean} canEdit - Flag indicating whether the clipboard is editable.
* @returns {Object} - An object containing state variables and functions related to clipboard functionality.
* @property {boolean} showPasteUnit - Flag indicating whether the "Paste Unit" button should be visible.
* @property {boolean} showPasteXBlock - Flag indicating whether the "Paste XBlock" button should be visible.
* @property {Object} sharedClipboardData - The shared clipboard data object.
*/
const useCopyToClipboard = (canEdit = true) => {
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
const [showPasteUnit, setShowPasteUnit] = useState(false);
const [showPasteXBlock, setShowPasteXBlock] = useState(false);
const [sharedClipboardData, setSharedClipboardData] = useState({});
const clipboardData = useSelector(getClipboardData);
// Function to refresh the paste button's visibility
const refreshPasteButton = (data) => {
const isPasteable = canEdit && data?.content && data.content.status !== CLIPBOARD_STATUS.expired;
const isPasteableXBlock = isPasteable && !STRUCTURAL_XBLOCK_TYPES.includes(data.content.blockType);
const isPasteableUnit = isPasteable && data.content.blockType === 'vertical';
setShowPasteXBlock(!!isPasteableXBlock);
setShowPasteUnit(!!isPasteableUnit);
};
useEffect(() => {
// Handle updates to clipboard data
if (canEdit) {
refreshPasteButton(clipboardData);
setSharedClipboardData(clipboardData);
clipboardBroadcastChannel.postMessage(clipboardData);
} else {
setShowPasteXBlock(false);
setShowPasteUnit(false);
}
}, [clipboardData, canEdit, clipboardBroadcastChannel]);
useEffect(() => {
// Handle messages from the broadcast channel
clipboardBroadcastChannel.onmessage = (event) => {
setSharedClipboardData(event.data);
refreshPasteButton(event.data);
};
// Cleanup function for the BroadcastChannel when the hook is unmounted
return () => {
clipboardBroadcastChannel.close();
};
}, [clipboardBroadcastChannel]);
return { showPasteUnit, showPasteXBlock, sharedClipboardData };
};
export default useCopyToClipboard;

View File

@@ -0,0 +1,122 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { Provider } from 'react-redux';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../../../store';
import { executeThunk } from '../../../utils';
import { clipboardUnit, clipboardXBlock } from '../../../__mocks__';
import { copyToClipboard } from '../../data/thunks';
import { getClipboardUrl } from '../../data/api';
import useCopyToClipboard from './useCopyToClipboard';
let axiosMock;
let store;
const unitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
const xblockId = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4';
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const wrapper = ({ children }) => (
<Provider store={store}>
<IntlProvider locale="en">
{children}
</IntlProvider>
</Provider>
);
describe('useCopyToClipboard', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('initializes correctly', () => {
const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
expect(result.current.showPasteUnit).toBe(false);
expect(result.current.showPasteXBlock).toBe(false);
});
describe('clipboard data update effect', () => {
it('returns falsy flags if canEdit = false', async () => {
const { result } = renderHook(() => useCopyToClipboard(false), { wrapper });
axiosMock
.onPost(getClipboardUrl())
.reply(200, clipboardUnit);
axiosMock
.onGet(getClipboardUrl())
.reply(200, clipboardUnit);
await act(async () => {
await executeThunk(copyToClipboard(unitId), store.dispatch);
});
expect(result.current.showPasteUnit).toBe(false);
expect(result.current.showPasteXBlock).toBe(false);
});
it('returns flag to display the Paste Unit button', async () => {
const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
axiosMock
.onPost(getClipboardUrl())
.reply(200, clipboardUnit);
axiosMock
.onGet(getClipboardUrl())
.reply(200, clipboardUnit);
await act(async () => {
await executeThunk(copyToClipboard(unitId), store.dispatch);
});
expect(result.current.showPasteUnit).toBe(true);
expect(result.current.showPasteXBlock).toBe(false);
});
it('returns flag to display the Paste XBlock button', async () => {
const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
axiosMock
.onPost(getClipboardUrl())
.reply(200, clipboardXBlock);
axiosMock
.onGet(getClipboardUrl())
.reply(200, clipboardXBlock);
await act(async () => {
await executeThunk(copyToClipboard(xblockId), store.dispatch);
});
expect(result.current.showPasteUnit).toBe(false);
expect(result.current.showPasteXBlock).toBe(true);
});
});
describe('broadcast channel message handling', () => {
it('updates states correctly on receiving a broadcast message', async () => {
const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit });
expect(result.current.showPasteUnit).toBe(true);
expect(result.current.showPasteXBlock).toBe(false);
clipboardBroadcastChannelMock.onmessage({ data: clipboardXBlock });
expect(result.current.showPasteUnit).toBe(false);
expect(result.current.showPasteXBlock).toBe(true);
});
});
});

View File

@@ -0,0 +1,2 @@
export { default as useCopyToClipboard } from './hooks/useCopyToClipboard';
export { default as PasteComponent } from './paste-component';

View File

@@ -0,0 +1,46 @@
.whats-in-clipboard {
cursor: help;
width: fit-content;
margin-left: auto;
.whats-in-clipboard-icon {
width: 1.125rem;
height: 1.125rem;
margin-bottom: 1px;
}
.whats-in-clipboard-text {
font-size: $font-size-sm;
}
}
.clipboard-popover {
min-width: 21.25rem;
.clipboard-popover-title {
&:hover {
text-decoration: none;
color: initial;
}
&.popover-header {
border: none;
}
.clipboard-popover-icon {
float: right;
}
}
.clipboard-popover-detail-block-type {
display: block;
font-size: $font-size-sm;
line-height: 1.313rem;
color: $gray-700;
}
.clipboard-popover-detail-course-name {
font-style: italic;
}
}

View File

@@ -0,0 +1,36 @@
import PropsTypes from 'prop-types';
import { useParams } from 'react-router-dom';
import { Button } from '@openedx/paragon';
import { ContentCopy as ContentCopyIcon } from '@openedx/paragon/icons';
const PasteButton = ({ onClick, text, className }) => {
const { blockId } = useParams();
const handlePasteXBlockComponent = () => {
onClick({ stagedContent: 'clipboard', parentLocator: blockId }, null, blockId);
};
return (
<Button
className={className}
iconBefore={ContentCopyIcon}
variant="outline-primary"
block
onClick={handlePasteXBlockComponent}
>
{text}
</Button>
);
};
PasteButton.propTypes = {
onClick: PropsTypes.func.isRequired,
text: PropsTypes.string.isRequired,
className: PropsTypes.string,
};
PasteButton.defaultProps = {
className: undefined,
};
export default PasteButton;

View File

@@ -0,0 +1,47 @@
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, Popover, Stack } from '@openedx/paragon';
import { OpenInNew as OpenInNewIcon } from '@openedx/paragon/icons';
import messages from '../messages';
import { clipboardPropsTypes } from '../constants';
const PopoverContent = ({ clipboardData }) => {
const intl = useIntl();
const { sourceEditUrl, content, sourceContextTitle } = clipboardData;
return (
<Popover.Title
className="clipboard-popover-title"
data-testid="popover-content"
as={sourceEditUrl ? Link : 'div'}
to={sourceEditUrl || null}
target="_blank"
>
<Stack>
<Stack className="justify-content-between" direction="horizontal">
<strong>{content.displayName}</strong>
{sourceEditUrl && (
<Icon className="clipboard-popover-icon m-0" src={OpenInNewIcon} />
)}
</Stack>
<div>
<small className="clipboard-popover-detail-block-type">
{content.blockTypeDisplay}
</small>
<span className="mr-1">{intl.formatMessage(messages.popoverContentText)}</span>
<span className="clipboard-popover-detail-course-name">
{sourceContextTitle}
</span>
</div>
</Stack>
</Popover.Title>
);
};
PopoverContent.propTypes = {
clipboardData: PropTypes.shape(clipboardPropsTypes).isRequired,
};
export default PopoverContent;

View File

@@ -0,0 +1,58 @@
import { useRef } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon } from '@openedx/paragon';
import { Question as QuestionIcon } from '@openedx/paragon/icons';
import messages from '../messages';
const WhatsInClipboard = ({
handlePopoverToggle, togglePopover, popoverElementRef,
}) => {
const intl = useIntl();
const triggerElementRef = useRef(null);
const handleKeyDown = ({ key }) => {
if (key === 'Tab') {
popoverElementRef.current?.focus();
handlePopoverToggle(true);
}
};
return (
<div
className="whats-in-clipboard mt-2 d-flex align-items-center"
data-testid="whats-in-clipboard"
onMouseEnter={() => handlePopoverToggle(true)}
onMouseLeave={() => handlePopoverToggle(false)}
onFocus={() => togglePopover(true)}
onBlur={() => togglePopover(false)}
>
<Icon
className="whats-in-clipboard-icon mr-1"
src={QuestionIcon}
/>
<p
/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */
tabIndex="0"
role="presentation"
ref={triggerElementRef}
className="whats-in-clipboard-text m-0"
onKeyDown={handleKeyDown}
>
{intl.formatMessage(messages.pasteButtonWhatsInClipboardText)}
</p>
</div>
);
};
WhatsInClipboard.propTypes = {
handlePopoverToggle: PropTypes.func.isRequired,
togglePopover: PropTypes.func.isRequired,
popoverElementRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]).isRequired,
};
export default WhatsInClipboard;

View File

@@ -0,0 +1,3 @@
export { default as WhatsInClipboard } from './WhatsInClipboard';
export { default as PasteButton } from './PasteButton';
export { default as PopoverContent } from './PopoverContent';

View File

@@ -0,0 +1,11 @@
import PropTypes from 'prop-types';
/* eslint-disable import/prefer-default-export */
export const clipboardPropsTypes = {
sourceEditUrl: PropTypes.string.isRequired,
content: PropTypes.shape({
displayName: PropTypes.string.isRequired,
blockTypeDisplay: PropTypes.string.isRequired,
}).isRequired,
sourceContextTitle: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,65 @@
import { useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { OverlayTrigger, Popover } from '@openedx/paragon';
import { PopoverContent, PasteButton, WhatsInClipboard } from './components';
import { clipboardPropsTypes } from './constants';
const PasteComponent = ({
onClick, clipboardData, text, className,
}) => {
const [showPopover, togglePopover] = useState(false);
const popoverElementRef = useRef(null);
const handlePopoverToggle = (isOpen) => togglePopover(isOpen);
const renderPopover = (props) => (
<div role="link" ref={popoverElementRef} tabIndex="0">
<Popover
className="clipboard-popover"
id="popover-positioned"
onMouseEnter={() => handlePopoverToggle(true)}
onMouseLeave={() => handlePopoverToggle(false)}
onFocus={() => handlePopoverToggle(true)}
onBlur={() => handlePopoverToggle(false)}
{...props}
>
{clipboardData && (
<PopoverContent clipboardData={clipboardData} />
)}
</Popover>
</div>
);
return (
<>
<PasteButton className={className} onClick={onClick} text={text} />
<OverlayTrigger
show={showPopover}
overlay={renderPopover}
>
<WhatsInClipboard
handlePopoverToggle={handlePopoverToggle}
togglePopover={togglePopover}
popoverElementRef={popoverElementRef}
/>
</OverlayTrigger>
</>
);
};
PasteComponent.propTypes = {
onClick: PropTypes.func.isRequired,
text: PropTypes.string.isRequired,
clipboardData: PropTypes.shape(clipboardPropsTypes),
blockType: PropTypes.string,
className: PropTypes.string,
};
PasteComponent.defaultProps = {
clipboardData: null,
blockType: null,
className: undefined,
};
export default PasteComponent;

View File

@@ -0,0 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
popoverContentText: {
id: 'course-authoring.generic.paste-component.popover.content.text',
defaultMessage: 'From:',
description: 'The popover content label before the source course name of the copied content.',
},
pasteButtonWhatsInClipboardText: {
id: 'course-authoring.generic.paste-component.paste-button.whats-in-clipboard.text',
defaultMessage: "What's in my clipboard?",
description: 'The popover trigger button text of the info about copied content.',
},
});
export default messages;

View File

@@ -8,6 +8,7 @@ export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCreateOrRerunCourseUrl = () => new URL('course/', getApiBaseUrl()).href;
export const getCourseRerunUrl = (courseId) => new URL(`/api/contentstore/v1/course_rerun/${courseId}`, getApiBaseUrl()).href;
export const getOrganizationsUrl = () => new URL('organizations', getApiBaseUrl()).href;
export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`;
export const getTagsCountApiUrl = (contentPattern) => new URL(`api/content_tagging/v1/object_tag_counts/${contentPattern}/?count_implicit`, getApiBaseUrl()).href;
/**
@@ -45,6 +46,29 @@ export async function createOrRerunCourse(courseData) {
return camelCaseObject(data);
}
/**
* Retrieves user's clipboard.
* @returns {Promise<Object>} - A Promise that resolves clipboard data.
*/
export async function getClipboard() {
const { data } = await getAuthenticatedHttpClient()
.get(getClipboardUrl());
return camelCaseObject(data);
}
/**
* Updates user's clipboard.
* @param {string} usageKey - The ID of the block.
* @returns {Promise<Object>} - A Promise that resolves clipboard data.
*/
export async function updateClipboard(usageKey) {
const { data } = await getAuthenticatedHttpClient()
.post(getClipboardUrl(), { usage_key: usageKey });
return camelCaseObject(data);
}
/**
* Gets the tags count of multiple content by id separated by commas or a pattern using a '*' wildcard.
* @param {string} contentPattern

View File

@@ -5,3 +5,4 @@ export const getCourseData = (state) => state.generic.createOrRerunCourse.course
export const getCourseRerunData = (state) => state.generic.createOrRerunCourse.courseRerunData;
export const getRedirectUrlObj = (state) => state.generic.createOrRerunCourse.redirectUrlObj;
export const getPostErrors = (state) => state.generic.createOrRerunCourse.postErrors;
export const getClipboardData = (state) => state.generic.clipboardData;

View File

@@ -18,6 +18,7 @@ const slice = createSlice({
redirectUrlObj: {},
postErrors: {},
},
clipboardData: null,
},
reducers: {
fetchOrganizations: (state, { payload }) => {
@@ -41,6 +42,9 @@ const slice = createSlice({
updatePostErrors: (state, { payload }) => {
state.createOrRerunCourse.postErrors = payload;
},
updateClipboardData: (state, { payload }) => {
state.clipboardData = payload;
},
},
});
@@ -52,6 +56,7 @@ export const {
updateSavingStatus,
updateCourseData,
updateRedirectUrlObj,
updateClipboardData,
} = slice.actions;
export const {

View File

@@ -1,5 +1,11 @@
import { logError } from '@edx/frontend-platform/logging';
import { CLIPBOARD_STATUS, NOTIFICATION_MESSAGES } from '../../constants';
import {
hideProcessingNotification,
showProcessingNotification,
} from '../processing-notification/data/slice';
import { RequestStatus } from '../../data/constants';
import { createOrRerunCourse, getOrganizations, getCourseRerun } from './api';
import {
fetchOrganizations,
updatePostErrors,
@@ -7,7 +13,15 @@ import {
updateRedirectUrlObj,
updateCourseRerunData,
updateSavingStatus,
updateClipboardData,
} from './slice';
import {
createOrRerunCourse,
getOrganizations,
getCourseRerun,
updateClipboard,
getClipboard,
} from './api';
export function fetchOrganizationsQuery() {
return async (dispatch) => {
@@ -49,3 +63,33 @@ export function updateCreateOrRerunCourseQuery(courseData) {
}
};
}
export function copyToClipboard(usageKey) {
const POLL_INTERVAL_MS = 1000; // Timeout duration for polling in milliseconds
return async (dispatch) => {
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying));
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
try {
let clipboardData = await updateClipboard(usageKey);
while (clipboardData.content?.status === CLIPBOARD_STATUS.loading) {
// eslint-disable-next-line no-await-in-loop,no-promise-executor-return
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
clipboardData = await getClipboard(); // eslint-disable-line no-await-in-loop
}
if (clipboardData.content?.status === CLIPBOARD_STATUS.ready) {
dispatch(updateClipboardData(clipboardData));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} else {
throw new Error(`Unexpected clipboard status "${clipboardData.content?.status}" in successful API response.`);
}
} catch (error) {
logError('Error copying to clipboard:', error);
} finally {
dispatch(hideProcessingNotification());
}
};
}

View File

@@ -0,0 +1,16 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
const Divider = ({ className, ...props }) => (
<div className={classNames('divider', className)} {...props} />
);
Divider.propTypes = {
className: PropTypes.string,
};
Divider.defaultProps = {
className: undefined,
};
export default Divider;

View File

@@ -0,0 +1,5 @@
.divider {
border-top: $border-width solid $light-400;
height: 0;
margin: $spacer map-get($spacers, 0);
}

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as Divider } from './Divider';

View File

@@ -6,6 +6,8 @@
@import "./create-or-rerun-course/CreateOrRerunCourseForm";
@import "./WysiwygEditor";
@import "./course-stepper/CouseStepper";
@import "./divider/Divider";
@import "./clipboard/paste-component/PasteComponent";
@import "./tag-count/TagCount";
@import "./modal-dropzone/ModalDropzone";
@import "./configure-modal/ConfigureModal";