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:
committed by
Kristin Aoki
parent
2cb907e731
commit
815ddbe94e
69
package-lock.json
generated
69
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -23,6 +23,8 @@ export const NOTIFICATION_MESSAGES = {
|
||||
saving: 'Saving',
|
||||
duplicating: 'Duplicating',
|
||||
deleting: 'Deleting',
|
||||
copying: 'Copying',
|
||||
pasting: 'Pasting',
|
||||
empty: '',
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,3 +10,4 @@
|
||||
@import "./configure-modal/ConfigureModal";
|
||||
@import "./drag-helper/ConditionalSortableElement";
|
||||
@import "./xblock-status/XBlockStatus";
|
||||
@import "./paste-button/PasteButton";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
115
src/course-outline/paste-button/PasteButton.jsx
Normal file
115
src/course-outline/paste-button/PasteButton.jsx
Normal 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;
|
||||
20
src/course-outline/paste-button/PasteButton.scss
Normal file
20
src/course-outline/paste-button/PasteButton.scss
Normal 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;
|
||||
}
|
||||
14
src/course-outline/paste-button/messages.js
Normal file
14
src/course-outline/paste-button/messages.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
46
src/generic/broadcast-channel/hooks.js
Normal file
46
src/generic/broadcast-channel/hooks.js
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user