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:
@@ -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,
|
||||
};
|
||||
};
|
||||
61
src/generic/clipboard/hooks/useCopyToClipboard.js
Normal file
61
src/generic/clipboard/hooks/useCopyToClipboard.js
Normal 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;
|
||||
122
src/generic/clipboard/hooks/useCopyToClipboard.test.jsx
Normal file
122
src/generic/clipboard/hooks/useCopyToClipboard.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
2
src/generic/clipboard/index.js
Normal file
2
src/generic/clipboard/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as useCopyToClipboard } from './hooks/useCopyToClipboard';
|
||||
export { default as PasteComponent } from './paste-component';
|
||||
46
src/generic/clipboard/paste-component/PasteComponent.scss
Normal file
46
src/generic/clipboard/paste-component/PasteComponent.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as WhatsInClipboard } from './WhatsInClipboard';
|
||||
export { default as PasteButton } from './PasteButton';
|
||||
export { default as PopoverContent } from './PopoverContent';
|
||||
11
src/generic/clipboard/paste-component/constants.js
Normal file
11
src/generic/clipboard/paste-component/constants.js
Normal 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,
|
||||
};
|
||||
65
src/generic/clipboard/paste-component/index.jsx
Normal file
65
src/generic/clipboard/paste-component/index.jsx
Normal 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;
|
||||
16
src/generic/clipboard/paste-component/messages.js
Normal file
16
src/generic/clipboard/paste-component/messages.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
16
src/generic/divider/Divider.jsx
Normal file
16
src/generic/divider/Divider.jsx
Normal 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;
|
||||
5
src/generic/divider/Divider.scss
Normal file
5
src/generic/divider/Divider.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.divider {
|
||||
border-top: $border-width solid $light-400;
|
||||
height: 0;
|
||||
margin: $spacer map-get($spacers, 0);
|
||||
}
|
||||
2
src/generic/divider/index.jsx
Normal file
2
src/generic/divider/index.jsx
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as Divider } from './Divider';
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user