feat: copy & paste units

refactor: paste component

fix: lint issues and delete unused hook

test: add test

fix: update api for npm broadcast channel
This commit is contained in:
Navin Karkera
2024-01-25 20:21:28 +05:30
committed by Kristin Aoki
parent 2cb907e731
commit 815ddbe94e
22 changed files with 531 additions and 17 deletions

69
package-lock.json generated
View File

@@ -26,6 +26,7 @@
"@fortawesome/react-fontawesome": "0.2.0",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "4.36.1",
"broadcast-channel": "^7.0.0",
"classnames": "2.2.6",
"core-js": "3.8.1",
"email-validator": "2.0.4",
@@ -2022,18 +2023,20 @@
"integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA=="
},
"node_modules/@babel/runtime": {
"version": "7.21.0",
"license": "MIT",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.4.tgz",
"integrity": "sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==",
"dependencies": {
"regenerator-runtime": "^0.13.11"
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/runtime/node_modules/regenerator-runtime": {
"version": "0.13.11",
"license": "MIT"
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/@babel/template": {
"version": "7.22.15",
@@ -9273,6 +9276,20 @@
"node": ">=8"
}
},
"node_modules/broadcast-channel": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-7.0.0.tgz",
"integrity": "sha512-a2tW0Ia1pajcPBOGUF2jXlDnvE9d5/dg6BG9h60OmRUcZVr/veUrU8vEQFwwQIhwG3KVzYwSk3v2nRRGFgQDXQ==",
"dependencies": {
"@babel/runtime": "7.23.4",
"oblivious-set": "1.4.0",
"p-queue": "6.6.2",
"unload": "2.4.1"
},
"funding": {
"url": "https://github.com/sponsors/pubkey"
}
},
"node_modules/browser-process-hrtime": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
@@ -21972,6 +21989,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/oblivious-set": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.4.0.tgz",
"integrity": "sha512-szyd0ou0T8nsAqHtprRcP3WidfsN1TnAR5yWXf2mFCEr5ek3LEOkT6EZ/92Xfs74HIdyhG5WkGxIssMU0jBaeg==",
"engines": {
"node": ">=16"
}
},
"node_modules/obuf": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
@@ -22201,6 +22226,21 @@
"node": ">=6"
}
},
"node_modules/p-queue": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
"dependencies": {
"eventemitter3": "^4.0.4",
"p-timeout": "^3.2.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
@@ -22213,6 +22253,17 @@
"node": ">=8"
}
},
"node_modules/p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"dependencies": {
"p-finally": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"license": "MIT",
@@ -27382,6 +27433,14 @@
"node": ">= 10.0.0"
}
},
"node_modules/unload": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/unload/-/unload-2.4.1.tgz",
"integrity": "sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==",
"funding": {
"url": "https://github.com/sponsors/pubkey"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View File

@@ -53,6 +53,7 @@
"@fortawesome/react-fontawesome": "0.2.0",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "4.36.1",
"broadcast-channel": "^7.0.0",
"classnames": "2.2.6",
"core-js": "3.8.1",
"email-validator": "2.0.4",

View File

@@ -23,6 +23,8 @@ export const NOTIFICATION_MESSAGES = {
saving: 'Saving',
duplicating: 'Duplicating',
deleting: 'Deleting',
copying: 'Copying',
pasting: 'Pasting',
empty: '',
};

View File

@@ -95,6 +95,8 @@ const CourseOutline = ({ courseId }) => {
handleSubsectionDragAndDrop,
handleVideoSharingOptionChange,
handleUnitDragAndDrop,
handleCopyToClipboardClick,
handlePasteClipboardClick,
} = useCourseOutline({ courseId });
const [sections, setSections] = useState(sectionsList);
@@ -349,6 +351,7 @@ const CourseOutline = ({ courseId }) => {
section,
section.childInfo.children,
)}
onPasteClick={handlePasteClipboardClick}
>
<DraggableList
itemList={subsection.childInfo.children}
@@ -379,6 +382,7 @@ const CourseOutline = ({ courseId }) => {
subsection,
subsection.childInfo.children,
)}
onCopyToClipboardClick={handleCopyToClipboardClick}
/>
))}
</DraggableList>

View File

@@ -10,3 +10,4 @@
@import "./configure-modal/ConfigureModal";
@import "./drag-helper/ConditionalSortableElement";
@import "./xblock-status/XBlockStatus";
@import "./paste-button/PasteButton";

View File

@@ -16,6 +16,7 @@ import {
getCourseBlockApiUrl,
getCourseItemApiUrl,
getXBlockBaseApiUrl,
getClipboardUrl,
} from './data/api';
import { RequestStatus } from '../data/constants';
import {
@@ -42,6 +43,8 @@ import cardHeaderMessages from './card-header/messages';
import enableHighlightsModalMessages from './enable-highlights-modal/messages';
import statusBarMessages from './status-bar/messages';
import configureModalMessages from './configure-modal/messages';
import pasteButtonMessages from './paste-button/messages';
import subsectionMessages from './subsection-card/messages';
let axiosMock;
let store;
@@ -1337,4 +1340,79 @@ describe('<CourseOutline />', () => {
expect(within(sectionElement).queryByText(section.displayName)).toBeInTheDocument();
});
});
it('check whether unit copy & paste option works correctly', async () => {
const { findAllByTestId } = render(<RootWrapper />);
// get first section -> first subsection -> first unit element
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [sectionElement] = await findAllByTestId('section-card');
const [subsection] = section.childInfo.children;
let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
const expectedClipboardContent = {
content: {
blockType: 'vertical',
blockTypeDisplay: 'Unit',
created: '2024-01-29T07:58:36.844249Z',
displayName: unit.displayName,
id: 15,
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/15/olx',
purpose: 'clipboard',
status: 'ready',
userId: 3,
},
sourceUsageKey: unit.id,
sourceContexttitle: courseOutlineIndexMock.courseStructure.displayName,
sourceEditUrl: unit.studioUrl,
};
// mock api call
axiosMock
.onPost(getClipboardUrl(), {
usage_key: unit.id,
}).reply(200, expectedClipboardContent);
// check that initialUserClipboard state is empty
const { initialUserClipboard } = store.getState().courseOutline;
expect(initialUserClipboard).toBeUndefined();
// find menu button and click on it to open menu
const menu = await within(unitElement).findByTestId('unit-card-header__menu-button');
await act(async () => fireEvent.click(menu));
// move first unit back to second position to test move down option
const copyButton = await within(unitElement).findByText(cardHeaderMessages.menuCopy.defaultMessage);
await act(async () => fireEvent.click(copyButton));
// check that initialUserClipboard state is updated
expect(store.getState().courseOutline.initialUserClipboard).toEqual(expectedClipboardContent);
[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
// find clipboard content label
const clipboardLabel = await within(subsectionElement).findByText(
pasteButtonMessages.clipboardContentLabel.defaultMessage,
);
await act(async () => fireEvent.mouseOver(clipboardLabel));
// find clipboard content popup link
expect(
subsectionElement.querySelector('#vertical-paste-button-overlay'),
).toHaveAttribute('href', unit.studioUrl);
// check paste button functionality
// mock api call
axiosMock
.onPost(getXBlockBaseApiUrl(), {
parent_locator: subsection.id,
staged_content: 'clipboard',
}).reply(200, { dummy: 'value' });
const pasteBtn = await within(subsectionElement).findByText(subsectionMessages.pasteButton.defaultMessage);
await act(async () => fireEvent.click(pasteBtn));
[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const lastUnitElement = (await within(subsectionElement).findAllByTestId('unit-card')).slice(-1)[0];
expect(lastUnitElement).toHaveTextContent(unit.displayName);
});
});

View File

@@ -261,6 +261,7 @@ module.exports = {
ancestorHasStaffLock: true,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
enableCopyPasteUnits: true,
userPartitionInfo: {
selectablePartitions: [
{
@@ -292,6 +293,7 @@ module.exports = {
ancestorHasStaffLock: true,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
enableCopyPasteUnits: true,
userPartitionInfo: {
selectablePartitions: [
{
@@ -391,7 +393,7 @@ module.exports = {
},
ancestor_has_staff_lock: false,
staff_only_message: false,
enable_copy_paste_units: false,
enable_copy_paste_units: true,
has_partition_group_components: false,
user_partition_info: {
selectable_partitions: [

View File

@@ -35,9 +35,12 @@ const CardHeader = ({
onClickDuplicate,
onClickMoveUp,
onClickMoveDown,
onClickCopy,
titleComponent,
namePrefix,
actions,
enableCopyPasteUnits,
isVertical,
}) => {
const intl = useIntl();
const [searchParams] = useSearchParams();
@@ -126,6 +129,11 @@ const CardHeader = ({
>
{intl.formatMessage(messages.menuConfigure)}
</Dropdown.Item>
{isVertical && enableCopyPasteUnits && (
<Dropdown.Item onClick={onClickCopy}>
{intl.formatMessage(messages.menuCopy)}
</Dropdown.Item>
)}
{actions.duplicable && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-duplicate-button`}
@@ -168,6 +176,12 @@ const CardHeader = ({
);
};
CardHeader.defaultProps = {
enableCopyPasteUnits: false,
isVertical: false,
onClickCopy: null,
};
CardHeader.propTypes = {
title: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
@@ -185,6 +199,7 @@ CardHeader.propTypes = {
onClickDuplicate: PropTypes.func.isRequired,
onClickMoveUp: PropTypes.func.isRequired,
onClickMoveDown: PropTypes.func.isRequired,
onClickCopy: PropTypes.func,
titleComponent: PropTypes.node.isRequired,
namePrefix: PropTypes.string.isRequired,
actions: PropTypes.shape({
@@ -195,6 +210,8 @@ CardHeader.propTypes = {
allowMoveUp: PropTypes.bool,
allowMoveDown: PropTypes.bool,
}).isRequired,
enableCopyPasteUnits: PropTypes.bool,
isVertical: PropTypes.bool,
};
export default CardHeader;

View File

@@ -57,6 +57,10 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.card.menu.delete',
defaultMessage: 'Delete',
},
menuCopy: {
id: 'course-authoring.course-outline.card.menu.delete',
defaultMessage: 'Copy to clipboard',
},
});
export default messages;

View File

@@ -28,6 +28,7 @@ export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${rein
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`;
/**
* @typedef {Object} courseOutline
@@ -412,3 +413,32 @@ export async function setVideoSharingOption(courseId, videoSharingOption) {
return data;
}
/**
* Copy block to clipboard
* @param {string} usageKey
* @returns {Promise<Object>}
*/
export async function copyBlockToClipboard(usageKey) {
const { data } = await getAuthenticatedHttpClient()
.post(getClipboardUrl(), {
usage_key: usageKey,
});
return camelCaseObject(data);
}
/**
* Paste block to clipboard
* @param {string} parentLocator
* @returns {Promise<Object>}
*/
export async function pasteBlock(parentLocator) {
const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(), {
parent_locator: parentLocator,
staged_content: 'clipboard',
});
return data;
}

View File

@@ -8,3 +8,4 @@ export const getCurrentSection = (state) => state.courseOutline.currentSection;
export const getCurrentSubsection = (state) => state.courseOutline.currentSubsection;
export const getCourseActions = (state) => state.courseOutline.actions;
export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive;
export const getInitialUserClipboard = (state) => state.courseOutline.initialUserClipboard;

View File

@@ -38,12 +38,19 @@ const slice = createSlice({
childAddable: true,
duplicable: true,
},
initialUserClipboard: {
content: {},
sourceUsageKey: null,
sourceContexttitle: null,
sourceEditUrl: null,
},
},
reducers: {
fetchOutlineIndexSuccess: (state, { payload }) => {
state.outlineIndexData = payload;
state.sectionsList = payload.courseStructure?.childInfo?.children || [];
state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive;
state.initialUserClipboard = payload.initialUserClipboard;
},
updateOutlineIndexLoadingStatus: (state, { payload }) => {
state.loadingStatus = {
@@ -69,6 +76,9 @@ const slice = createSlice({
...payload,
};
},
updateClipboardContent: (state, { payload }) => {
state.initialUserClipboard = payload;
},
updateCourseActions: (state, { payload }) => {
state.actions = {
...state.actions,
@@ -205,6 +215,7 @@ export const {
reorderSectionList,
reorderSubsectionList,
reorderUnitList,
updateClipboardContent,
} = slice.actions;
export const {

View File

@@ -28,6 +28,8 @@ import {
setSectionOrderList,
setVideoSharingOption,
setCourseItemOrderList,
copyBlockToClipboard,
pasteBlock,
} from './api';
import {
addSection,
@@ -49,6 +51,7 @@ import {
reorderSectionList,
reorderSubsectionList,
reorderUnitList,
updateClipboardContent,
} from './slice';
export function fetchCourseOutlineIndexQuery(courseId) {
@@ -371,7 +374,7 @@ export function deleteCourseUnitQuery(unitId, subsectionId, sectionId) {
function duplicateCourseItemQuery(itemId, parentLocator, duplicateFn) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating));
try {
await duplicateCourseItem(itemId, parentLocator).then(async (result) => {
@@ -560,3 +563,47 @@ export function setUnitOrderListQuery(sectionId, subsectionId, unitListIds, rest
}
};
}
export function setClipboardContent(usageKey, broadcastClipboard) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying));
try {
await copyBlockToClipboard(usageKey).then(async (result) => {
const status = result?.content?.status;
if (status === 'ready') {
dispatch(updateClipboardContent(result));
broadcastClipboard(result);
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
} else {
throw new Error(`Unexpected clipboard status "${status}" in successful API response.`);
}
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function pasteClipboardContent(parentLocator, sectionId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting));
try {
await pasteBlock(parentLocator).then(async (result) => {
if (result) {
dispatch(fetchCourseSectionQuery(sectionId, true));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
}
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}

View File

@@ -6,10 +6,12 @@ import { getConfig } from '@edx/frontend-platform';
import { RequestStatus } from '../data/constants';
import { COURSE_BLOCK_NAMES } from './constants';
import { useBroadcastChannel } from '../generic/broadcast-channel/hooks';
import {
setCurrentItem,
setCurrentSection,
updateSavingStatus,
updateClipboardContent,
} from './data/slice';
import {
getLoadingStatus,
@@ -48,6 +50,8 @@ import {
setVideoSharingOptionQuery,
setSubsectionOrderListQuery,
setUnitOrderListQuery,
setClipboardContent,
pasteClipboardContent,
} from './data/thunk';
const useCourseOutline = ({ courseId }) => {
@@ -74,6 +78,17 @@ const useCourseOutline = ({ courseId }) => {
const [isPublishModalOpen, openPublishModal, closePublishModal] = useToggle(false);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const clipboardBroadcastChannel = useBroadcastChannel('studio_clipboard_channel', (message) => {
dispatch(updateClipboardContent(message));
});
const handleCopyToClipboardClick = (usageKey) => {
dispatch(setClipboardContent(usageKey, clipboardBroadcastChannel.postMessage));
};
const handlePasteClipboardClick = (parentLocator, sectionId) => {
dispatch(pasteClipboardContent(parentLocator, sectionId));
};
const handleNewSectionSubmit = () => {
dispatch(addNewSectionQuery(courseStructure.id));
@@ -289,6 +304,8 @@ const useCourseOutline = ({ courseId }) => {
handleSubsectionDragAndDrop,
handleVideoSharingOptionChange,
handleUnitDragAndDrop,
handleCopyToClipboardClick,
handlePasteClipboardClick,
};
};

View File

@@ -0,0 +1,115 @@
import { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import {
Hyperlink, Icon, Button, OverlayTrigger,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
FileCopy as PasteIcon,
Question as QuestionIcon,
} from '@edx/paragon/icons';
import { getInitialUserClipboard } from '../data/selectors';
import messages from './messages';
const PasteButton = ({
text,
blockType,
onClick,
}) => {
const intl = useIntl();
const initialUserClipboard = useSelector(getInitialUserClipboard);
const {
content,
sourceContextTitle,
sourceEditUrl,
} = initialUserClipboard || {};
// Show button only if clipboard has content
const showPasteButton = (
content?.status === 'ready'
&& content?.blockType === blockType
);
const [show, setShow] = useState(false);
const handleOnMouseEnter = () => {
setShow(true);
};
const handleOnMouseLeave = () => {
setShow(false);
};
const ref = useRef(null);
if (!showPasteButton) {
return null;
}
const renderBlockLink = (props) => (
<Hyperlink
id={`${blockType}-paste-button-overlay`}
className="d-flex bg-white p-3 text-decoration-none popup-link shadow mb-2 zindex-2"
target="_blank"
destination={sourceEditUrl}
onMouseEnter={handleOnMouseEnter}
onMouseLeave={handleOnMouseLeave}
onFocus={handleOnMouseEnter}
onBlur={handleOnMouseLeave}
{...props}
>
<div className="text-gray mr-5 mw-xs">
<h4>
{content?.displayName}<br />
<span className="micro text-gray-400">
{content?.blockTypeDisplay}
</span>
</h4>
<span className="x-small">
{intl.formatMessage(messages.clipboardContentFromLabel)}
<em>{sourceContextTitle}</em>
</span>
</div>
</Hyperlink>
);
return (
<>
<Button
className="mt-4"
variant="outline-primary"
iconBefore={PasteIcon}
block
onClick={onClick}
>
{text}
</Button>
<OverlayTrigger
key={`${blockType}-paste-button-overlay`}
show={show}
placement="top"
container={ref}
overlay={renderBlockLink}
>
<div
className="float-right d-inline-flex align-items-center x-small mt-2 cursor-help"
ref={ref}
onMouseEnter={handleOnMouseEnter}
onMouseLeave={handleOnMouseLeave}
onFocus={handleOnMouseEnter}
onBlur={handleOnMouseLeave}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex="0"
>
<Icon className="mr-1" size="sm" src={QuestionIcon} />
{intl.formatMessage(messages.clipboardContentLabel)}
</div>
</OverlayTrigger>
</>
);
};
PasteButton.propTypes = {
text: PropTypes.string.isRequired,
blockType: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
};
export default PasteButton;

View File

@@ -0,0 +1,20 @@
// adds bottom arrow to popup link
.popup-link {
position: relative;
&::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
width: 0;
height: 0;
border-top: solid .5rem white;
border-left: solid .5rem transparent;
border-right: solid .5rem transparent;
}
}
.cursor-help {
cursor: help !important;
}

View File

@@ -0,0 +1,14 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
clipboardContentFromLabel: {
id: 'course-authoring.course-outline.paste-button.whats-in-clipboard.from-label',
defaultMessage: 'From: ',
},
clipboardContentLabel: {
id: 'course-authoring.course-outline.paste-button.whats-in-clipboard.label',
defaultMessage: 'What\'s in my clipboard?',
},
});
export default messages;

View File

@@ -9,11 +9,13 @@ import classNames from 'classnames';
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
import { RequestStatus } from '../../data/constants';
import { COURSE_BLOCK_NAMES } from '../constants';
import CardHeader from '../card-header/CardHeader';
import BaseTitleWithStatusBadge from '../card-header/BaseTitleWithStatusBadge';
import ConditionalSortableElement from '../drag-helper/ConditionalSortableElement';
import TitleButton from '../card-header/TitleButton';
import XBlockStatus from '../xblock-status/XBlockStatus';
import PasteButton from '../paste-button/PasteButton';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
import messages from './messages';
@@ -33,6 +35,7 @@ const SubsectionCard = ({
onNewUnitSubmit,
onOrderChange,
onOpenConfigureModal,
onPasteClick,
}) => {
const currentRef = useRef(null);
const intl = useIntl();
@@ -51,6 +54,7 @@ const SubsectionCard = ({
visibilityState,
actions: subsectionActions,
isHeaderVisible = true,
enableCopyPasteUnits = false,
} = subsection;
// re-create actions object for customizations
@@ -95,6 +99,7 @@ const SubsectionCard = ({
};
const handleNewButtonClick = () => onNewUnitSubmit(id);
const handlePasteButtonClick = () => onPasteClick(id, section.id);
const titleComponent = (
<TitleButton
@@ -180,16 +185,25 @@ const SubsectionCard = ({
>
{children}
{actions.childAddable && (
<Button
data-testid="new-unit-button"
className="mt-4"
variant="outline-primary"
iconBefore={IconAdd}
block
onClick={handleNewButtonClick}
>
{intl.formatMessage(messages.newUnitButton)}
</Button>
<>
<Button
data-testid="new-unit-button"
className="mt-4"
variant="outline-primary"
iconBefore={IconAdd}
block
onClick={handleNewButtonClick}
>
{intl.formatMessage(messages.newUnitButton)}
</Button>
{enableCopyPasteUnits && (
<PasteButton
text={intl.formatMessage(messages.pasteButton)}
blockType={COURSE_BLOCK_NAMES.vertical.id}
onClick={handlePasteButtonClick}
/>
)}
</>
)}
</div>
)}
@@ -218,6 +232,7 @@ SubsectionCard.propTypes = {
hasChanges: PropTypes.bool.isRequired,
visibilityState: PropTypes.string.isRequired,
shouldScroll: PropTypes.bool,
enableCopyPasteUnits: PropTypes.bool,
actions: PropTypes.shape({
deletable: PropTypes.bool.isRequired,
draggable: PropTypes.bool.isRequired,
@@ -239,6 +254,7 @@ SubsectionCard.propTypes = {
canMoveItem: PropTypes.func.isRequired,
onOrderChange: PropTypes.func.isRequired,
onOpenConfigureModal: PropTypes.func.isRequired,
onPasteClick: PropTypes.func.isRequired,
};
export default SubsectionCard;

View File

@@ -5,6 +5,10 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.subsection.button.new-unit',
defaultMessage: 'New unit',
},
pasteButton: {
id: 'course-authoring.course-outline.subsection.button.new-unit',
defaultMessage: 'Paste unit',
},
});
export default messages;

View File

@@ -28,6 +28,7 @@ const UnitCard = ({
onDuplicateSubmit,
getTitleLink,
onOrderChange,
onCopyToClipboardClick,
}) => {
const currentRef = useRef(null);
const dispatch = useDispatch();
@@ -42,6 +43,7 @@ const UnitCard = ({
visibilityState,
actions: unitActions,
isHeaderVisible = true,
enableCopyPasteUnits = false,
} = unit;
// re-create actions object for customizations
@@ -80,6 +82,10 @@ const UnitCard = ({
onOrderChange(index, index + 1);
};
const handleCopyClick = () => {
onCopyToClipboardClick(unit.id);
};
const titleComponent = (
<TitleLink
titleLink={getTitleLink(id)}
@@ -148,6 +154,9 @@ const UnitCard = ({
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}
isVertical
enableCopyPasteUnits={enableCopyPasteUnits}
onClickCopy={handleCopyClick}
/>
<div className="unit-card__content item-children" data-testid="unit-card__content">
<XBlockStatus
@@ -176,6 +185,7 @@ UnitCard.propTypes = {
duplicable: PropTypes.bool.isRequired,
}).isRequired,
isHeaderVisible: PropTypes.bool,
enableCopyPasteUnits: PropTypes.bool,
}).isRequired,
subsection: PropTypes.shape({
id: PropTypes.string.isRequired,
@@ -205,6 +215,7 @@ UnitCard.propTypes = {
onOrderChange: PropTypes.func.isRequired,
isSelfPaced: PropTypes.bool.isRequired,
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
onCopyToClipboardClick: PropTypes.func.isRequired,
};
export default UnitCard;

View File

@@ -10,6 +10,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import initializeStore from '../../store';
import UnitCard from './UnitCard';
import cardMessages from '../card-header/messages';
// eslint-disable-next-line no-unused-vars
let axiosMock;
@@ -119,4 +120,17 @@ describe('<UnitCard />', () => {
expect(within(element).queryByTestId('unit-card-header__menu-duplicate-button')).not.toBeInTheDocument();
expect(within(element).queryByTestId('unit-card-header__menu-delete-button')).not.toBeInTheDocument();
});
it('shows copy option based on enableCopyPasteUnits flag', async () => {
const { findByTestId } = renderComponent({
unit: {
...unit,
enableCopyPasteUnits: true,
},
});
const element = await findByTestId('unit-card');
const menu = await within(element).findByTestId('unit-card-header__menu-button');
await act(async () => fireEvent.click(menu));
expect(within(element).queryByText(cardMessages.menuCopy.defaultMessage)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,46 @@
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,
};
};