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:
73
package-lock.json
generated
73
package-lock.json
generated
@@ -41,7 +41,6 @@
|
||||
"@openedx/paragon": "^22.2.1",
|
||||
"@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",
|
||||
@@ -7742,36 +7741,6 @@
|
||||
"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/broadcast-channel/node_modules/@babel/runtime": {
|
||||
"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.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/broadcast-channel/node_modules/regenerator-runtime": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
|
||||
},
|
||||
"node_modules/browser-process-hrtime": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
|
||||
@@ -16214,14 +16183,6 @@
|
||||
"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",
|
||||
@@ -16393,21 +16354,6 @@
|
||||
"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",
|
||||
@@ -16420,17 +16366,6 @@
|
||||
"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",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
@@ -21774,14 +21709,6 @@
|
||||
"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",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"@openedx/paragon": "^22.2.1",
|
||||
"@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",
|
||||
|
||||
16
src/__mocks__/clipboardUnit.js
Normal file
16
src/__mocks__/clipboardUnit.js
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
content: {
|
||||
id: 67,
|
||||
userId: 3,
|
||||
created: '2024-01-16T13:09:11.540615Z',
|
||||
purpose: 'clipboard',
|
||||
status: 'ready',
|
||||
blockType: 'vertical',
|
||||
blockTypeDisplay: 'Unit',
|
||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
|
||||
displayName: 'Introduction: Video and Sequences',
|
||||
},
|
||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
||||
sourceContextTitle: 'Demonstration Course',
|
||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
||||
};
|
||||
16
src/__mocks__/clipboardXBlock.js
Normal file
16
src/__mocks__/clipboardXBlock.js
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
content: {
|
||||
id: 69,
|
||||
userId: 3,
|
||||
created: '2024-01-16T13:33:21.314439Z',
|
||||
purpose: 'clipboard',
|
||||
status: 'ready',
|
||||
blockType: 'html',
|
||||
blockTypeDisplay: 'Text',
|
||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/69/olx',
|
||||
displayName: 'Blank HTML Page',
|
||||
},
|
||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@html+block@html1',
|
||||
sourceContextTitle: 'Demonstration Course',
|
||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1',
|
||||
};
|
||||
2
src/__mocks__/index.js
Normal file
2
src/__mocks__/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as clipboardUnit } from './clipboardUnit';
|
||||
export { default as clipboardXBlock } from './clipboardXBlock';
|
||||
@@ -56,3 +56,14 @@ export const COURSE_BLOCK_NAMES = ({
|
||||
vertical: { id: 'vertical', name: 'Unit' },
|
||||
component: { id: 'component', name: 'Component' },
|
||||
});
|
||||
|
||||
export const STUDIO_CLIPBOARD_CHANNEL = 'studio_clipboard_channel';
|
||||
|
||||
export const CLIPBOARD_STATUS = {
|
||||
loading: 'loading',
|
||||
ready: 'ready',
|
||||
expired: 'expired',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
export const STRUCTURAL_XBLOCK_TYPES = ['vertical', 'sequential', 'chapter', 'course'];
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
@import "./publish-modal/PublishModal";
|
||||
@import "./drag-helper/SortableItem";
|
||||
@import "./xblock-status/XBlockStatus";
|
||||
@import "./paste-button/PasteButton";
|
||||
|
||||
div.row:has(> div > div.highlight) {
|
||||
animation: 5s glow;
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
getCourseBlockApiUrl,
|
||||
getCourseItemApiUrl,
|
||||
getXBlockBaseApiUrl,
|
||||
getClipboardUrl,
|
||||
} from './data/api';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import {
|
||||
@@ -37,16 +36,19 @@ import {
|
||||
courseSectionMock,
|
||||
courseSubsectionMock,
|
||||
} from './__mocks__';
|
||||
import { clipboardUnit } from '../__mocks__';
|
||||
import { executeThunk } from '../utils';
|
||||
import { COURSE_BLOCK_NAMES, VIDEO_SHARING_OPTIONS } from './constants';
|
||||
import CourseOutline from './CourseOutline';
|
||||
|
||||
import configureModalMessages from '../generic/configure-modal/messages';
|
||||
import pasteButtonMessages from '../generic/clipboard/paste-component/messages';
|
||||
import messages from './messages';
|
||||
import { getClipboardUrl } from '../generic/data/api';
|
||||
import headerMessages from './header-navigations/messages';
|
||||
import cardHeaderMessages from './card-header/messages';
|
||||
import enableHighlightsModalMessages from './enable-highlights-modal/messages';
|
||||
import statusBarMessages from './status-bar/messages';
|
||||
import pasteButtonMessages from './paste-button/messages';
|
||||
import subsectionMessages from './subsection-card/messages';
|
||||
import pageAlertMessages from './page-alerts/messages';
|
||||
import {
|
||||
@@ -55,7 +57,6 @@ import {
|
||||
moveSubsection,
|
||||
moveUnit,
|
||||
} from './drag-helper/utils';
|
||||
import messages from './messages';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
@@ -64,6 +65,13 @@ const courseId = '123';
|
||||
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
@@ -2080,7 +2088,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit copy & paste option works correctly', async () => {
|
||||
const { findAllByTestId, findAllByRole } = render(<RootWrapper />);
|
||||
const { findAllByTestId, queryByTestId, findAllByRole } = render(<RootWrapper />);
|
||||
// get first section -> first subsection -> first unit element
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
@@ -2091,27 +2099,11 @@ describe('<CourseOutline />', () => {
|
||||
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);
|
||||
}).reply(200, clipboardUnit);
|
||||
// check that initialUserClipboard state is empty
|
||||
const { initialUserClipboard } = store.getState().courseOutline;
|
||||
expect(initialUserClipboard).toBeUndefined();
|
||||
@@ -2125,19 +2117,19 @@ describe('<CourseOutline />', () => {
|
||||
await act(async () => fireEvent.click(copyButton));
|
||||
|
||||
// check that initialUserClipboard state is updated
|
||||
expect(store.getState().courseOutline.initialUserClipboard).toEqual(expectedClipboardContent);
|
||||
expect(store.getState().generic.clipboardData).toEqual(clipboardUnit);
|
||||
|
||||
[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
// find clipboard content label
|
||||
const clipboardLabel = await within(subsectionElement).findByText(
|
||||
pasteButtonMessages.clipboardContentLabel.defaultMessage,
|
||||
pasteButtonMessages.pasteButtonWhatsInClipboardText.defaultMessage,
|
||||
);
|
||||
await act(async () => fireEvent.mouseOver(clipboardLabel));
|
||||
|
||||
// find clipboard content popup link
|
||||
expect(
|
||||
subsectionElement.querySelector('#vertical-paste-button-overlay'),
|
||||
).toHaveAttribute('href', unit.studioUrl);
|
||||
// find clipboard content popover link
|
||||
const popoverContent = queryByTestId('popover-content');
|
||||
expect(popoverContent.tagName).toBe('A');
|
||||
expect(popoverContent).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${unit.studioUrl}`);
|
||||
|
||||
// check paste button functionality
|
||||
// mock api call
|
||||
|
||||
@@ -434,20 +434,6 @@ 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
|
||||
|
||||
@@ -8,6 +8,5 @@ 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;
|
||||
export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams;
|
||||
export const getPasteFileNotices = (state) => state.courseOutline.pasteFileNotices;
|
||||
|
||||
@@ -38,12 +38,6 @@ const slice = createSlice({
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
initialUserClipboard: {
|
||||
content: {},
|
||||
sourceUsageKey: null,
|
||||
sourceContexttitle: null,
|
||||
sourceEditUrl: null,
|
||||
},
|
||||
enableProctoredExams: false,
|
||||
pasteFileNotices: {},
|
||||
},
|
||||
@@ -52,7 +46,6 @@ const slice = createSlice({
|
||||
state.outlineIndexData = payload;
|
||||
state.sectionsList = payload.courseStructure?.childInfo?.children || [];
|
||||
state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive;
|
||||
state.initialUserClipboard = payload.initialUserClipboard;
|
||||
state.enableProctoredExams = payload.courseStructure?.enableProctoredExams;
|
||||
},
|
||||
updateOutlineIndexLoadingStatus: (state, { payload }) => {
|
||||
@@ -79,9 +72,6 @@ const slice = createSlice({
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
updateClipboardContent: (state, { payload }) => {
|
||||
state.initialUserClipboard = payload;
|
||||
},
|
||||
updateCourseActions: (state, { payload }) => {
|
||||
state.actions = {
|
||||
...state.actions,
|
||||
@@ -210,7 +200,6 @@ export const {
|
||||
reorderSectionList,
|
||||
reorderSubsectionList,
|
||||
reorderUnitList,
|
||||
updateClipboardContent,
|
||||
setPasteFileNotices,
|
||||
removePasteFileNotices,
|
||||
} = slice.actions;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { updateClipboardData } from '../../generic/data/slice';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
import {
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
setSectionOrderList,
|
||||
setVideoSharingOption,
|
||||
setCourseItemOrderList,
|
||||
copyBlockToClipboard,
|
||||
pasteBlock,
|
||||
dismissNotification,
|
||||
} from './api';
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
deleteUnit,
|
||||
duplicateSection,
|
||||
reorderSectionList,
|
||||
updateClipboardContent,
|
||||
setPasteFileNotices,
|
||||
} from './slice';
|
||||
|
||||
@@ -70,6 +69,7 @@ export function fetchCourseOutlineIndexQuery(courseId) {
|
||||
},
|
||||
} = outlineIndex;
|
||||
dispatch(fetchOutlineIndexSuccess(outlineIndex));
|
||||
dispatch(updateClipboardData(outlineIndex.initialUserClipboard));
|
||||
dispatch(updateStatusBar({
|
||||
courseReleaseDate,
|
||||
highlightsEnabledForMessaging,
|
||||
@@ -607,30 +607,6 @@ export function setUnitOrderListQuery(
|
||||
};
|
||||
}
|
||||
|
||||
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 }));
|
||||
|
||||
@@ -4,14 +4,14 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { copyToClipboard } from '../generic/data/thunks';
|
||||
import { getSavingStatus as getGenericSavingStatus } from '../generic/data/selectors';
|
||||
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,
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
setVideoSharingOptionQuery,
|
||||
setSubsectionOrderListQuery,
|
||||
setUnitOrderListQuery,
|
||||
setClipboardContent,
|
||||
pasteClipboardContent,
|
||||
dismissNotificationQuery,
|
||||
} from './data/thunk';
|
||||
@@ -81,6 +80,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
const currentSection = useSelector(getCurrentSection);
|
||||
const currentSubsection = useSelector(getCurrentSubsection);
|
||||
const isCustomRelativeDatesActive = useSelector(getCustomRelativeDatesActiveFlag);
|
||||
const genericSavingStatus = useSelector(getGenericSavingStatus);
|
||||
|
||||
const [isEnableHighlightsModalOpen, openEnableHighlightsModal, closeEnableHighlightsModal] = useToggle(false);
|
||||
const [isSectionsExpanded, setSectionsExpanded] = useState(true);
|
||||
@@ -91,12 +91,11 @@ 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 isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED;
|
||||
|
||||
const handleCopyToClipboardClick = (usageKey) => {
|
||||
dispatch(setClipboardContent(usageKey, clipboardBroadcastChannel.postMessage));
|
||||
dispatch(copyToClipboard(usageKey));
|
||||
};
|
||||
|
||||
const handlePasteClipboardClick = (parentLocator, sectionId) => {
|
||||
@@ -328,7 +327,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
isEnableHighlightsModalOpen,
|
||||
openEnableHighlightsModal,
|
||||
closeEnableHighlightsModal,
|
||||
isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED,
|
||||
isInternetConnectionAlertFailed: isSavingStatusFailed,
|
||||
handleInternetConnectionFailed,
|
||||
handleOpenHighlightsModal,
|
||||
isHighlightsModalOpen,
|
||||
@@ -358,6 +357,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
mfeProctoredExamSettingsUrl,
|
||||
handleDismissNotification,
|
||||
advanceSettingsUrl,
|
||||
genericSavingStatus,
|
||||
handleSectionDragAndDrop,
|
||||
handleSubsectionDragAndDrop,
|
||||
handleUnitDragAndDrop,
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Hyperlink, Icon, Button, OverlayTrigger,
|
||||
} from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
FileCopy as PasteIcon,
|
||||
Question as QuestionIcon,
|
||||
} from '@openedx/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;
|
||||
@@ -1,20 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
@@ -13,13 +13,12 @@ import { isEmpty } from 'lodash';
|
||||
|
||||
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 SortableItem from '../drag-helper/SortableItem';
|
||||
import { DragContext } from '../drag-helper/DragContextProvider';
|
||||
import { useCopyToClipboard, PasteComponent } from '../../generic/clipboard';
|
||||
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';
|
||||
|
||||
@@ -50,6 +49,7 @@ const SubsectionCard = ({
|
||||
const isScrolledToElement = locatorId === subsection.id;
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const namePrefix = 'subsection';
|
||||
const { sharedClipboardData, showPasteUnit } = useCopyToClipboard();
|
||||
|
||||
const {
|
||||
id,
|
||||
@@ -66,7 +66,7 @@ const SubsectionCard = ({
|
||||
|
||||
// re-create actions object for customizations
|
||||
const actions = { ...subsectionActions };
|
||||
// add actions to control display of move up & down menu buton.
|
||||
// add actions to control display of move up & down menu button.
|
||||
const moveUpDetails = getPossibleMoves(index, -1);
|
||||
const moveDownDetails = getPossibleMoves(index, 1);
|
||||
actions.allowMoveUp = !isEmpty(moveUpDetails);
|
||||
@@ -217,10 +217,11 @@ const SubsectionCard = ({
|
||||
>
|
||||
{intl.formatMessage(messages.newUnitButton)}
|
||||
</Button>
|
||||
{enableCopyPasteUnits && (
|
||||
<PasteButton
|
||||
{enableCopyPasteUnits && showPasteUnit && (
|
||||
<PasteComponent
|
||||
className="mt-4"
|
||||
text={intl.formatMessage(messages.pasteButton)}
|
||||
blockType={COURSE_BLOCK_NAMES.vertical.id}
|
||||
clipboardData={sharedClipboardData}
|
||||
onClick={handlePasteButtonClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import {
|
||||
act, render, fireEvent, within,
|
||||
@@ -26,6 +25,13 @@ jest.mock('react-router-dom', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const section = {
|
||||
id: '123',
|
||||
displayName: 'Section Name',
|
||||
|
||||
@@ -11,6 +11,7 @@ import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import AlertMessage from '../generic/alert-message';
|
||||
import { PasteComponent } from '../generic/clipboard';
|
||||
import ProcessingNotification from '../generic/processing-notification';
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
|
||||
@@ -27,6 +28,7 @@ import messages from './messages';
|
||||
import PublishControls from './sidebar/PublishControls';
|
||||
import LocationInfo from './sidebar/LocationInfo';
|
||||
import TagsSidebarControls from '../content-tags-drawer/tags-sidebar-controls';
|
||||
import { PasteNotificationAlert } from './clipboard';
|
||||
|
||||
const CourseUnit = ({ courseId }) => {
|
||||
const { blockId } = useParams();
|
||||
@@ -40,9 +42,13 @@ const CourseUnit = ({ courseId }) => {
|
||||
savingStatus,
|
||||
isTitleEditFormOpen,
|
||||
isErrorAlert,
|
||||
staticFileNotices,
|
||||
currentlyVisibleToStudents,
|
||||
isInternetConnectionAlertFailed,
|
||||
unitXBlockActions,
|
||||
sharedClipboardData,
|
||||
showPasteXBlock,
|
||||
showPasteUnit,
|
||||
handleTitleEditSubmit,
|
||||
headerNavigationsActions,
|
||||
handleTitleEdit,
|
||||
@@ -50,6 +56,7 @@ const CourseUnit = ({ courseId }) => {
|
||||
handleCreateNewCourseXBlock,
|
||||
handleConfigureSubmit,
|
||||
courseVerticalChildren,
|
||||
canPasteComponent,
|
||||
} = useCourseUnit({ courseId, blockId });
|
||||
|
||||
document.title = getPageHeadTitle('', unitTitle);
|
||||
@@ -103,6 +110,7 @@ const CourseUnit = ({ courseId }) => {
|
||||
sequenceId={sequenceId}
|
||||
unitId={blockId}
|
||||
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
|
||||
showPasteUnit={showPasteUnit}
|
||||
/>
|
||||
<Layout
|
||||
lg={[{ span: 8 }, { span: 4 }]}
|
||||
@@ -119,6 +127,12 @@ const CourseUnit = ({ courseId }) => {
|
||||
icon={WarningIcon}
|
||||
/>
|
||||
)}
|
||||
{staticFileNotices && (
|
||||
<PasteNotificationAlert
|
||||
staticFileNotices={staticFileNotices}
|
||||
courseId={courseId}
|
||||
/>
|
||||
)}
|
||||
<Stack gap={4} className="mb-4">
|
||||
{courseVerticalChildren.children.map(({
|
||||
name, blockId: id, blockType: type, shouldScroll, userPartitionInfo, validationMessages,
|
||||
@@ -142,6 +156,13 @@ const CourseUnit = ({ courseId }) => {
|
||||
blockId={blockId}
|
||||
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
|
||||
/>
|
||||
{showPasteXBlock && canPasteComponent && (
|
||||
<PasteComponent
|
||||
clipboardData={sharedClipboardData}
|
||||
onClick={handleCreateNewCourseXBlock}
|
||||
text={intl.formatMessage(messages.pasteButtonText)}
|
||||
/>
|
||||
)}
|
||||
</Layout.Element>
|
||||
<Layout.Element>
|
||||
<Stack gap={3}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { cloneDeep, set } from 'lodash';
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
postXBlockBaseApiUrl,
|
||||
} from './data/api';
|
||||
import {
|
||||
createNewCourseXBlock,
|
||||
deleteUnitItemQuery,
|
||||
editCourseUnitVisibilityAndData,
|
||||
fetchCourseSectionVerticalData,
|
||||
@@ -30,8 +31,16 @@ import {
|
||||
courseUnitIndexMock,
|
||||
courseUnitMock,
|
||||
courseVerticalChildrenMock,
|
||||
clipboardMockResponse,
|
||||
} from './__mocks__';
|
||||
import {
|
||||
clipboardUnit,
|
||||
clipboardXBlock,
|
||||
} from '../__mocks__';
|
||||
import { executeThunk } from '../utils';
|
||||
import deleteModalMessages from '../generic/delete-modal/messages';
|
||||
import pasteComponentMessages from '../generic/clipboard/paste-component/messages';
|
||||
import pasteNotificationsMessages from './clipboard/paste-notification/messages';
|
||||
import headerNavigationsMessages from './header-navigations/messages';
|
||||
import headerTitleMessages from './header-title/messages';
|
||||
import courseSequenceMessages from './course-sequence/messages';
|
||||
@@ -39,13 +48,12 @@ import sidebarMessages from './sidebar/messages';
|
||||
import { extractCourseUnitId } from './sidebar/utils';
|
||||
import CourseUnit from './CourseUnit';
|
||||
|
||||
import deleteModalMessages from '../generic/delete-modal/messages';
|
||||
import configureModalMessages from '../generic/configure-modal/messages';
|
||||
import courseXBlockMessages from './course-xblock/messages';
|
||||
import addComponentMessages from './add-component/messages';
|
||||
import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
|
||||
import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api';
|
||||
import messages from './messages';
|
||||
import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
@@ -55,6 +63,11 @@ const unitDisplayName = courseUnitIndexMock.metadata.display_name;
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
const userName = 'openedx';
|
||||
|
||||
const postXBlockBody = {
|
||||
parent_locator: blockId,
|
||||
staged_content: 'clipboard',
|
||||
};
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({ blockId }),
|
||||
@@ -86,6 +99,13 @@ jest.mock('@tanstack/react-query', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
@@ -104,7 +124,7 @@ describe('<CourseUnit />', () => {
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
global.localStorage.clear();
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
@@ -311,7 +331,7 @@ describe('<CourseUnit />', () => {
|
||||
await waitFor(async () => {
|
||||
units = getAllByTestId('course-unit-btn');
|
||||
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
|
||||
expect(units.length).toEqual(courseUnits.length);
|
||||
expect(units).toHaveLength(courseUnits.length);
|
||||
});
|
||||
|
||||
axiosMock
|
||||
@@ -1013,4 +1033,413 @@ describe('<CourseUnit />', () => {
|
||||
.getByText(sidebarMessages.visibilityStaffOnlyTitle.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Copy paste functionality', () => {
|
||||
it('should display "Copy Unit" action button after enabling copy-paste units', async () => {
|
||||
const { queryByText, queryByRole } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText(sidebarMessages.actionButtonCopyUnitTitle.defaultMessage)).toBeNull();
|
||||
expect(queryByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeNull();
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...courseUnitIndexMock,
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
expect(queryByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display clipboard information in popover when hovering over What\'s in clipboard text', async () => {
|
||||
const {
|
||||
queryByTestId, getByRole, getAllByLabelText, getByText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
|
||||
userEvent.click(xblockActionBtn);
|
||||
userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage }));
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardXBlock,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument();
|
||||
|
||||
const whatsInClipboardText = getByText(
|
||||
pasteComponentMessages.pasteButtonWhatsInClipboardText.defaultMessage,
|
||||
);
|
||||
|
||||
userEvent.hover(whatsInClipboardText);
|
||||
|
||||
const popoverContent = queryByTestId('popover-content');
|
||||
expect(popoverContent.tagName).toBe('A');
|
||||
expect(popoverContent).toHaveAttribute('href', clipboardXBlock.sourceEditUrl);
|
||||
expect(within(popoverContent).getByText(clipboardXBlock.content.displayName)).toBeInTheDocument();
|
||||
expect(within(popoverContent).getByText(clipboardXBlock.sourceContextTitle)).toBeInTheDocument();
|
||||
expect(within(popoverContent).getByText(clipboardXBlock.content.blockTypeDisplay)).toBeInTheDocument();
|
||||
|
||||
fireEvent.blur(whatsInClipboardText);
|
||||
await waitFor(() => expect(queryByTestId('popover-content')).toBeNull());
|
||||
|
||||
fireEvent.focus(whatsInClipboardText);
|
||||
await waitFor(() => expect(queryByTestId('popover-content')).toBeInTheDocument());
|
||||
|
||||
fireEvent.mouseLeave(whatsInClipboardText);
|
||||
await waitFor(() => expect(queryByTestId('popover-content')).toBeNull());
|
||||
|
||||
fireEvent.mouseEnter(whatsInClipboardText);
|
||||
await waitFor(() => expect(queryByTestId('popover-content')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should increase the number of course XBlocks after copying and pasting a block', async () => {
|
||||
const {
|
||||
getAllByTestId, getByRole, getAllByLabelText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
|
||||
userEvent.click(xblockActionBtn);
|
||||
userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage }));
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardXBlock,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...courseUnitIndexMock,
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
|
||||
userEvent.click(getByRole('button', { name: messages.pasteButtonText.defaultMessage }));
|
||||
|
||||
expect(getAllByTestId('course-xblock')).toHaveLength(2);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseVerticalChildrenApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseVerticalChildrenMock,
|
||||
children: [
|
||||
...courseVerticalChildrenMock.children,
|
||||
{
|
||||
name: 'Copy XBlock',
|
||||
block_id: '1234567890',
|
||||
block_type: 'drag-and-drop-v2',
|
||||
user_partition_info: {
|
||||
selectable_partitions: [],
|
||||
selected_partition_index: -1,
|
||||
selected_groups_label: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
|
||||
expect(getAllByTestId('course-xblock')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should display the "Paste component" button after copying a xblock to clipboard', async () => {
|
||||
const { getByRole, getAllByLabelText } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
|
||||
userEvent.click(xblockActionBtn);
|
||||
userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage }));
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardXBlock,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...courseUnitIndexMock,
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should copy a unit, paste it as a new unit, and update the course section vertical data', async () => {
|
||||
const {
|
||||
getAllByTestId, getByRole,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...courseUnitIndexMock,
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardUnit,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
|
||||
userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
|
||||
|
||||
let units = null;
|
||||
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
|
||||
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
|
||||
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
|
||||
...updatedAncestorsChild.child_info.children,
|
||||
courseUnitMock,
|
||||
]);
|
||||
|
||||
units = getAllByTestId('course-unit-btn');
|
||||
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
|
||||
expect(units).toHaveLength(courseUnits.length);
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl(), postXBlockBody)
|
||||
.reply(200, { dummy: 'value' });
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...updatedCourseSectionVerticalData,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
units = getAllByTestId('course-unit-btn');
|
||||
const updatedCourseUnits = updatedCourseSectionVerticalData
|
||||
.xblock_info.ancestor_info.ancestors[0].child_info.children;
|
||||
|
||||
expect(units.length).toEqual(updatedCourseUnits.length);
|
||||
expect(mockedUsedNavigate).toHaveBeenCalled();
|
||||
expect(mockedUsedNavigate)
|
||||
.toHaveBeenCalledWith(`/course/${courseId}/container/${blockId}/${updatedAncestorsChild.id}`, { replace: true });
|
||||
});
|
||||
|
||||
it('displays a notification about new files after pasting a component', async () => {
|
||||
const {
|
||||
queryByTestId, getByTestId, getByRole,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...courseUnitIndexMock,
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardUnit,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
|
||||
userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
|
||||
|
||||
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
|
||||
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
|
||||
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
|
||||
...updatedAncestorsChild.child_info.children,
|
||||
courseUnitMock,
|
||||
]);
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl(postXBlockBody))
|
||||
.reply(200, clipboardMockResponse);
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...updatedCourseSectionVerticalData,
|
||||
});
|
||||
|
||||
global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices));
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch);
|
||||
const newFilesAlert = getByTestId('has-new-files-alert');
|
||||
|
||||
expect(within(newFilesAlert)
|
||||
.getByText(pasteNotificationsMessages.hasNewFilesTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(within(newFilesAlert)
|
||||
.getByText(pasteNotificationsMessages.hasNewFilesDescription.defaultMessage)).toBeInTheDocument();
|
||||
expect(within(newFilesAlert)
|
||||
.getByText(pasteNotificationsMessages.hasNewFilesButtonText.defaultMessage)).toBeInTheDocument();
|
||||
clipboardMockResponse.staticFileNotices.newFiles.forEach((fileName) => {
|
||||
expect(within(newFilesAlert).getByText(fileName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
userEvent.click(within(newFilesAlert).getByText(/Dismiss/i));
|
||||
|
||||
expect(queryByTestId('has-new-files-alert')).toBeNull();
|
||||
});
|
||||
|
||||
it('displays a notification about conflicting errors after pasting a component', async () => {
|
||||
const {
|
||||
queryByTestId, getByTestId, getByRole,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...courseUnitIndexMock,
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardUnit,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
|
||||
userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
|
||||
|
||||
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
|
||||
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
|
||||
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
|
||||
...updatedAncestorsChild.child_info.children,
|
||||
courseUnitMock,
|
||||
]);
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl(postXBlockBody))
|
||||
.reply(200, clipboardMockResponse);
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...updatedCourseSectionVerticalData,
|
||||
});
|
||||
|
||||
global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices));
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch);
|
||||
const conflictingErrorsAlert = getByTestId('has-conflicting-errors-alert');
|
||||
|
||||
expect(within(conflictingErrorsAlert)
|
||||
.getByText(pasteNotificationsMessages.hasConflictingErrorsTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(within(conflictingErrorsAlert)
|
||||
.getByText(pasteNotificationsMessages.hasConflictingErrorsDescription.defaultMessage)).toBeInTheDocument();
|
||||
expect(within(conflictingErrorsAlert)
|
||||
.getByText(pasteNotificationsMessages.hasConflictingErrorsButtonText.defaultMessage)).toBeInTheDocument();
|
||||
clipboardMockResponse.staticFileNotices.conflictingFiles.forEach((fileName) => {
|
||||
expect(within(conflictingErrorsAlert).getByText(fileName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
userEvent.click(within(conflictingErrorsAlert).getByText(/Dismiss/i));
|
||||
|
||||
expect(queryByTestId('has-conflicting-errors-alert')).toBeNull();
|
||||
});
|
||||
|
||||
it('displays a notification about error files after pasting a component', async () => {
|
||||
const {
|
||||
queryByTestId, getByTestId, getByRole,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...courseUnitIndexMock,
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardUnit,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
|
||||
userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
|
||||
|
||||
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
|
||||
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
|
||||
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
|
||||
...updatedAncestorsChild.child_info.children,
|
||||
courseUnitMock,
|
||||
]);
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl(postXBlockBody))
|
||||
.reply(200, clipboardMockResponse);
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...updatedCourseSectionVerticalData,
|
||||
});
|
||||
|
||||
global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices));
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch);
|
||||
const errorFilesAlert = getByTestId('has-error-files-alert');
|
||||
|
||||
expect(within(errorFilesAlert)
|
||||
.getByText(pasteNotificationsMessages.hasErrorsTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(within(errorFilesAlert)
|
||||
.getByText(pasteNotificationsMessages.hasErrorsDescription.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
userEvent.click(within(errorFilesAlert).getByText(/Dismiss/i));
|
||||
|
||||
expect(queryByTestId('has-error-files')).toBeNull();
|
||||
});
|
||||
|
||||
it('should hide the "Paste component" block if canPasteComponent is false', async () => {
|
||||
const { queryByText, queryByRole } = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseVerticalChildrenApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseVerticalChildrenMock,
|
||||
canPasteComponent: false,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
|
||||
|
||||
expect(queryByRole('button', {
|
||||
name: messages.pasteButtonText.defaultMessage,
|
||||
})).not.toBeInTheDocument();
|
||||
expect(queryByText(
|
||||
pasteComponentMessages.pasteButtonWhatsInClipboardText.defaultMessage,
|
||||
)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
9
src/course-unit/__mocks__/clipboardResponse.js
Normal file
9
src/course-unit/__mocks__/clipboardResponse.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export default {
|
||||
locator: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
||||
courseKey: 'course-v1:edX+L153+3T2023',
|
||||
staticFileNotices: {
|
||||
newFiles: ['new_file_1', 'new_file_2', 'new_file_3'],
|
||||
conflictingFiles: ['conflicting_file_1', 'conflicting_file_2', 'conflicting_file_3'],
|
||||
errorFiles: ['error_file_1', 'error_file_2', 'error_file_3'],
|
||||
},
|
||||
};
|
||||
@@ -144,4 +144,5 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
isPublished: false,
|
||||
canPasteComponent: true,
|
||||
};
|
||||
|
||||
@@ -3,3 +3,4 @@ export { default as courseSectionVerticalMock } from './courseSectionVertical';
|
||||
export { default as courseUnitMock } from './courseUnit';
|
||||
export { default as courseCreateXblockMock } from './courseCreateXblock';
|
||||
export { default as courseVerticalChildrenMock } from './courseVerticalChildren';
|
||||
export { default as clipboardMockResponse } from './clipboardResponse';
|
||||
|
||||
2
src/course-unit/clipboard/index.js
Normal file
2
src/course-unit/clipboard/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as PasteNotificationAlert } from './paste-notification';
|
||||
@@ -0,0 +1,21 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
const ActionButton = ({ courseId, title }) => (
|
||||
<Button
|
||||
as={Link}
|
||||
to={`${getConfig().STUDIO_BASE_URL}/assets/${courseId}/`}
|
||||
size="sm"
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
);
|
||||
|
||||
ActionButton.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ActionButton;
|
||||
@@ -0,0 +1,22 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FILE_LIST_DEFAULT_VALUE } from '../constants';
|
||||
import FileList from './FileList';
|
||||
|
||||
const AlertContent = ({ fileList, text }) => (
|
||||
<>
|
||||
<span>{text}</span>
|
||||
<FileList fileList={fileList} />
|
||||
</>
|
||||
);
|
||||
|
||||
AlertContent.propTypes = {
|
||||
fileList: PropTypes.arrayOf(PropTypes.string),
|
||||
text: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
AlertContent.defaultProps = {
|
||||
fileList: FILE_LIST_DEFAULT_VALUE,
|
||||
};
|
||||
|
||||
export default AlertContent;
|
||||
@@ -0,0 +1,21 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FILE_LIST_DEFAULT_VALUE } from '../constants';
|
||||
|
||||
const FileList = ({ fileList }) => (
|
||||
<ul>
|
||||
{fileList.map((fileName) => (
|
||||
<li>{fileName}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
FileList.propTypes = {
|
||||
fileList: PropTypes.arrayOf(PropTypes.string),
|
||||
};
|
||||
|
||||
FileList.defaultProps = {
|
||||
fileList: FILE_LIST_DEFAULT_VALUE,
|
||||
};
|
||||
|
||||
export default FileList;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as AlertContent } from './AlertContent';
|
||||
export { default as FileList } from './FileList';
|
||||
export { default as ActionButton } from './ActionButton';
|
||||
@@ -0,0 +1,7 @@
|
||||
export const FILE_LIST_DEFAULT_VALUE = [];
|
||||
|
||||
export const initialNotificationAlertsState = {
|
||||
conflictingFilesAlert: true,
|
||||
errorFilesAlert: true,
|
||||
newFilesAlert: true,
|
||||
};
|
||||
107
src/course-unit/clipboard/paste-notification/index.jsx
Normal file
107
src/course-unit/clipboard/paste-notification/index.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon,
|
||||
Warning as WarningIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import AlertMessage from '../../../generic/alert-message';
|
||||
import { ActionButton, AlertContent } from './components';
|
||||
import { getAlertStatus } from './utils';
|
||||
import { initialNotificationAlertsState } from './constants';
|
||||
import messages from './messages';
|
||||
|
||||
const PastNotificationAlert = ({ staticFileNotices, courseId }) => {
|
||||
const intl = useIntl();
|
||||
const [notificationAlerts, toggleNotificationAlerts] = useState(initialNotificationAlertsState);
|
||||
const { conflictingFiles, errorFiles, newFiles } = staticFileNotices;
|
||||
|
||||
const hasConflictingErrors = getAlertStatus(conflictingFiles, 'conflictingFilesAlert', notificationAlerts);
|
||||
const hasErrorFiles = getAlertStatus(errorFiles, 'errorFilesAlert', notificationAlerts);
|
||||
const hasNewFiles = getAlertStatus(newFiles, 'newFilesAlert', notificationAlerts);
|
||||
|
||||
const handleCloseNotificationAlert = (alertKey) => {
|
||||
toggleNotificationAlerts((prevAlerts) => ({
|
||||
...prevAlerts,
|
||||
[alertKey]: false,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasConflictingErrors && (
|
||||
<AlertMessage
|
||||
data-testid="has-conflicting-errors-alert"
|
||||
title={intl.formatMessage(messages.hasConflictingErrorsTitle)}
|
||||
onClose={() => handleCloseNotificationAlert('conflictingFilesAlert')}
|
||||
description={(
|
||||
<AlertContent
|
||||
fileList={conflictingFiles}
|
||||
text={intl.formatMessage(messages.hasConflictingErrorsDescription)}
|
||||
/>
|
||||
)}
|
||||
variant="warning"
|
||||
icon={WarningIcon}
|
||||
dismissible
|
||||
actions={[
|
||||
<ActionButton
|
||||
courseId={courseId}
|
||||
title={intl.formatMessage(messages.hasConflictingErrorsButtonText)}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{hasErrorFiles && (
|
||||
<AlertMessage
|
||||
data-testid="has-error-files-alert"
|
||||
title={intl.formatMessage(messages.hasErrorsTitle)}
|
||||
onClose={() => handleCloseNotificationAlert('errorFilesAlert')}
|
||||
description={(
|
||||
<AlertContent
|
||||
fileList={errorFiles}
|
||||
text={intl.formatMessage(messages.hasErrorsDescription)}
|
||||
/>
|
||||
)}
|
||||
variant="danger"
|
||||
icon={ErrorIcon}
|
||||
dismissible
|
||||
/>
|
||||
)}
|
||||
{hasNewFiles && (
|
||||
<AlertMessage
|
||||
data-testid="has-new-files-alert"
|
||||
title={intl.formatMessage(messages.hasNewFilesTitle)}
|
||||
onClose={() => handleCloseNotificationAlert('newFilesAlert')}
|
||||
description={(
|
||||
<AlertContent
|
||||
fileList={newFiles}
|
||||
text={intl.formatMessage(messages.hasNewFilesDescription)}
|
||||
/>
|
||||
)}
|
||||
variant="info"
|
||||
icon={InfoIcon}
|
||||
dismissible
|
||||
actions={[
|
||||
<ActionButton
|
||||
courseId={courseId}
|
||||
title={intl.formatMessage(messages.hasNewFilesButtonText)}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
PastNotificationAlert.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
staticFileNotices: PropTypes.shape({
|
||||
conflictingFiles: PropTypes.arrayOf(PropTypes.string),
|
||||
errorFiles: PropTypes.arrayOf(PropTypes.string),
|
||||
newFiles: PropTypes.arrayOf(PropTypes.string),
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default PastNotificationAlert;
|
||||
52
src/course-unit/clipboard/paste-notification/messages.js
Normal file
52
src/course-unit/clipboard/paste-notification/messages.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
hasConflictingErrorsTitle: {
|
||||
id: 'course-authoring.course-unit.paste-notification.has-conflicting-errors.title',
|
||||
defaultMessage: 'Files need to be updated manually.',
|
||||
description: 'Title for a notification indicating that files need manual updates '
|
||||
+ 'due to a conflict in the clipboard.',
|
||||
},
|
||||
hasConflictingErrorsDescription: {
|
||||
id: 'course-authoring.course-unit.paste-notification.has-conflicting-errors.description',
|
||||
defaultMessage: 'The following files must be updated manually for components to work as intended:',
|
||||
description: 'Description for the notification indicating which files need manual '
|
||||
+ 'updates due to a clipboard conflict.',
|
||||
},
|
||||
hasConflictingErrorsButtonText: {
|
||||
id: 'course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text',
|
||||
defaultMessage: 'Upload files',
|
||||
description: 'Button text prompting users to upload files to resolve a clipboard conflict.',
|
||||
},
|
||||
hasErrorsTitle: {
|
||||
id: 'course-authoring.course-unit.paste-notification.has-errors.title',
|
||||
defaultMessage: 'Some errors occurred',
|
||||
description: 'Title for a notification indicating that some errors occurred, likely '
|
||||
+ 'related to file conflicts.',
|
||||
},
|
||||
hasErrorsDescription: {
|
||||
id: 'course-authoring.course-unit.paste-notification.has-errors.description',
|
||||
defaultMessage: 'The following required files could not be added to the course:',
|
||||
description: 'Description for the notification indicating which required files '
|
||||
+ 'couldn\'t be added to the course due to errors.',
|
||||
},
|
||||
hasNewFilesTitle: {
|
||||
id: 'course-authoring.course-unit.paste-notification.has-new-files.title',
|
||||
defaultMessage: 'New file(s) added to Files & Uploads.',
|
||||
description: 'Title for a notification indicating that new files have been added to '
|
||||
+ 'the Files & Uploads section.',
|
||||
},
|
||||
hasNewFilesDescription: {
|
||||
id: 'course-authoring.course-unit.paste-notification.has-new-files.description',
|
||||
defaultMessage: 'The following required files were imported to this course:',
|
||||
description: 'Description for the notification indicating which required files '
|
||||
+ 'were imported to the course.',
|
||||
},
|
||||
hasNewFilesButtonText: {
|
||||
id: 'course-authoring.course-unit.paste-notification.has-new-files.button.text',
|
||||
defaultMessage: 'View files',
|
||||
description: 'Button text prompting users to view new files imported to the course.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
12
src/course-unit/clipboard/paste-notification/utils.js
Normal file
12
src/course-unit/clipboard/paste-notification/utils.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Gets the status of an alert based on the length of a fileList.
|
||||
*
|
||||
* @param {Array<string>} fileList - The list of files.
|
||||
* @param {string} alertKey - The key associated with the alert in the alertState.
|
||||
* @param {Object} alertState - The state object containing alert statuses.
|
||||
* @returns {boolean|null} - The status of the alert. Returns `true` if the fileList has length,
|
||||
* `false` if it does not, and `null` if fileList is not defined.
|
||||
*/
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const getAlertStatus = (fileList, alertKey, alertState) => (
|
||||
fileList?.length ? fileList && alertState[alertKey] : null);
|
||||
@@ -35,7 +35,7 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sequence-navigation-tabs .btn:not(.sequence-navigation-tabs-new-unit-btn) {
|
||||
.sequence-navigation-tabs .btn:not(.sequence-navigation-tabs-action-btn) {
|
||||
flex-basis: 100%;
|
||||
min-width: 2rem;
|
||||
}
|
||||
@@ -63,7 +63,7 @@
|
||||
|
||||
.sequence-navigation-prev-btn,
|
||||
.sequence-navigation-next-btn,
|
||||
.sequence-navigation-tabs-new-unit-btn {
|
||||
.sequence-navigation-tabs-action-btn {
|
||||
min-width: 12.5rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const Sequence = ({
|
||||
sequenceId,
|
||||
unitId,
|
||||
handleCreateNewCourseXBlock,
|
||||
showPasteUnit,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { IN_PROGRESS, FAILED, SUCCESSFUL } = RequestStatus;
|
||||
@@ -28,6 +29,7 @@ const Sequence = ({
|
||||
unitId={unitId}
|
||||
courseId={courseId}
|
||||
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
|
||||
showPasteUnit={showPasteUnit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,6 +63,7 @@ Sequence.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
sequenceId: PropTypes.string,
|
||||
handleCreateNewCourseXBlock: PropTypes.func.isRequired,
|
||||
showPasteUnit: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
Sequence.defaultProps = {
|
||||
|
||||
@@ -29,6 +29,10 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-unit.sequence.navigation.menu',
|
||||
defaultMessage: '{current} of {total}',
|
||||
},
|
||||
pasteAsNewUnitLink: {
|
||||
id: 'course-authoring.course-unit.sequence.navigation.menu.copy-unit.past-unit-link',
|
||||
defaultMessage: 'Paste as new unit',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -25,6 +25,7 @@ const SequenceNavigation = ({
|
||||
sequenceId,
|
||||
className,
|
||||
handleCreateNewCourseXBlock,
|
||||
showPasteUnit,
|
||||
}) => {
|
||||
const sequenceStatus = useSelector(getSequenceStatus);
|
||||
const {
|
||||
@@ -45,6 +46,7 @@ const SequenceNavigation = ({
|
||||
unitIds={sequence.unitIds || []}
|
||||
unitId={unitId}
|
||||
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
|
||||
showPasteUnit={showPasteUnit}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -110,6 +112,7 @@ SequenceNavigation.propTypes = {
|
||||
className: PropTypes.string,
|
||||
sequenceId: PropTypes.string,
|
||||
handleCreateNewCourseXBlock: PropTypes.func.isRequired,
|
||||
showPasteUnit: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
SequenceNavigation.defaultProps = {
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, Dropdown } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Plus as PlusIcon } from '@openedx/paragon/icons/';
|
||||
import { Plus as PlusIcon, ContentPasteGo as ContentPasteGoIcon } from '@openedx/paragon/icons/';
|
||||
|
||||
import messages from '../messages';
|
||||
import UnitButton from './UnitButton';
|
||||
|
||||
const SequenceNavigationDropdown = ({ unitId, unitIds, handleClick }) => {
|
||||
const SequenceNavigationDropdown = ({
|
||||
unitId,
|
||||
unitIds,
|
||||
handleAddNewSequenceUnit,
|
||||
handlePasteNewSequenceUnit,
|
||||
showPasteUnit,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
@@ -32,10 +38,20 @@ const SequenceNavigationDropdown = ({ unitId, unitIds, handleClick }) => {
|
||||
as={Dropdown.Item}
|
||||
variant="outline-primary"
|
||||
iconBefore={PlusIcon}
|
||||
onClick={handleClick}
|
||||
onClick={handleAddNewSequenceUnit}
|
||||
>
|
||||
{intl.formatMessage(messages.newUnitBtnText)}
|
||||
</Button>
|
||||
{showPasteUnit && (
|
||||
<Button
|
||||
as={Dropdown.Item}
|
||||
variant="outline-primary"
|
||||
iconBefore={ContentPasteGoIcon}
|
||||
onClick={handlePasteNewSequenceUnit}
|
||||
>
|
||||
{intl.formatMessage(messages.pasteAsNewUnitLink)}
|
||||
</Button>
|
||||
)}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
@@ -44,7 +60,9 @@ const SequenceNavigationDropdown = ({ unitId, unitIds, handleClick }) => {
|
||||
SequenceNavigationDropdown.propTypes = {
|
||||
unitId: PropTypes.string.isRequired,
|
||||
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
handleClick: PropTypes.func.isRequired,
|
||||
handleAddNewSequenceUnit: PropTypes.func.isRequired,
|
||||
handlePasteNewSequenceUnit: PropTypes.func.isRequired,
|
||||
showPasteUnit: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default SequenceNavigationDropdown;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { Plus as PlusIcon } from '@openedx/paragon/icons';
|
||||
import { Plus as PlusIcon, ContentPasteGo as ContentPasteGoIcon } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { changeEditTitleFormOpen, updateQueryPendingStatus } from '../../data/slice';
|
||||
@@ -12,7 +12,9 @@ import { useIndexOfLastVisibleChild } from '../hooks';
|
||||
import SequenceNavigationDropdown from './SequenceNavigationDropdown';
|
||||
import UnitButton from './UnitButton';
|
||||
|
||||
const SequenceNavigationTabs = ({ unitIds, unitId, handleCreateNewCourseXBlock }) => {
|
||||
const SequenceNavigationTabs = ({
|
||||
unitIds, unitId, handleCreateNewCourseXBlock, showPasteUnit,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
@@ -34,6 +36,14 @@ const SequenceNavigationTabs = ({ unitIds, unitId, handleCreateNewCourseXBlock }
|
||||
});
|
||||
};
|
||||
|
||||
const handlePasteNewSequenceUnit = () => {
|
||||
dispatch(updateQueryPendingStatus(true));
|
||||
handleCreateNewCourseXBlock({ parentLocator: sequenceId, stagedContent: 'clipboard' }, ({ courseKey, locator }) => {
|
||||
navigate(`/course/${courseKey}/container/${locator}/${sequenceId}`, courseId);
|
||||
dispatch(changeEditTitleFormOpen(true));
|
||||
}, unitId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sequence-navigation-tabs-wrapper">
|
||||
<div className="sequence-navigation-tabs-container d-flex" ref={containerRef}>
|
||||
@@ -49,20 +59,32 @@ const SequenceNavigationTabs = ({ unitIds, unitId, handleCreateNewCourseXBlock }
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
className="sequence-navigation-tabs-new-unit-btn"
|
||||
className="sequence-navigation-tabs-action-btn"
|
||||
variant="outline-primary"
|
||||
iconBefore={PlusIcon}
|
||||
onClick={handleAddNewSequenceUnit}
|
||||
>
|
||||
{intl.formatMessage(messages.newUnitBtnText)}
|
||||
</Button>
|
||||
{showPasteUnit && (
|
||||
<Button
|
||||
className="sequence-navigation-tabs-action-btn"
|
||||
variant="outline-primary"
|
||||
iconBefore={ContentPasteGoIcon}
|
||||
onClick={handlePasteNewSequenceUnit}
|
||||
>
|
||||
{intl.formatMessage(messages.pasteAsNewUnitLink)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{shouldDisplayDropdown && (
|
||||
<SequenceNavigationDropdown
|
||||
unitId={unitId}
|
||||
unitIds={unitIds}
|
||||
handleClick={handleAddNewSequenceUnit}
|
||||
handleAddNewSequenceUnit={handleAddNewSequenceUnit}
|
||||
handlePasteNewSequenceUnit={handlePasteNewSequenceUnit}
|
||||
showPasteUnit={showPasteUnit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -73,6 +95,7 @@ SequenceNavigationTabs.propTypes = {
|
||||
unitId: PropTypes.string.isRequired,
|
||||
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
handleCreateNewCourseXBlock: PropTypes.func.isRequired,
|
||||
showPasteUnit: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default SequenceNavigationTabs;
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ActionRow, Card, Dropdown, Icon, IconButton, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { EditOutline as EditIcon, MoreVert as MoveVertIcon } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { getCanEdit, getCourseId } from 'CourseAuthoring/course-unit/data/selectors';
|
||||
import DeleteModal from '../../generic/delete-modal/DeleteModal';
|
||||
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
|
||||
import { scrollToElement } from '../../course-outline/utils';
|
||||
import { COURSE_BLOCK_NAMES } from '../../constants';
|
||||
import { getCourseId } from '../data/selectors';
|
||||
import { copyToClipboard } from '../../generic/data/thunks';
|
||||
import { COMPONENT_TYPES } from '../constants';
|
||||
import XBlockMessages from './xblock-messages/XBlockMessages';
|
||||
import messages from './messages';
|
||||
@@ -24,7 +25,9 @@ const CourseXBlock = ({
|
||||
const courseXBlockElementRef = useRef(null);
|
||||
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
|
||||
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const canEdit = useSelector(getCanEdit);
|
||||
const courseId = useSelector(getCourseId);
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -90,15 +93,17 @@ const CourseXBlock = ({
|
||||
iconAs={Icon}
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item>
|
||||
{intl.formatMessage(messages.blockLabelButtonCopy)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={() => unitXBlockActions.handleDuplicate(id)}>
|
||||
{intl.formatMessage(messages.blockLabelButtonDuplicate)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
{intl.formatMessage(messages.blockLabelButtonMove)}
|
||||
</Dropdown.Item>
|
||||
{canEdit && (
|
||||
<Dropdown.Item onClick={() => dispatch(copyToClipboard(id))}>
|
||||
{intl.formatMessage(messages.blockLabelButtonCopyToClipboard)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item onClick={openConfigureModal}>
|
||||
{intl.formatMessage(messages.blockLabelButtonManageAccess)}
|
||||
</Dropdown.Item>
|
||||
|
||||
@@ -26,6 +26,10 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Move',
|
||||
description: 'The xblock move button text',
|
||||
},
|
||||
blockLabelButtonCopyToClipboard: {
|
||||
id: 'course-authoring.course-unit.xblock.button.copyToClipboard.label',
|
||||
defaultMessage: 'Copy to clipboard',
|
||||
},
|
||||
blockLabelButtonManageAccess: {
|
||||
id: 'course-authoring.course-unit.xblock.button.manageAccess.label',
|
||||
defaultMessage: 'Manage access',
|
||||
|
||||
@@ -11,7 +11,6 @@ export const getCourseUnitApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/con
|
||||
export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`;
|
||||
export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`;
|
||||
export const getCourseVerticalChildrenApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container/vertical/${itemId}/children`;
|
||||
|
||||
export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`;
|
||||
|
||||
/**
|
||||
@@ -60,13 +59,13 @@ export async function getCourseSectionVerticalData(unitId) {
|
||||
* @param {Object} options - The options for creating the XBlock.
|
||||
* @param {string} options.type - The type of the XBlock.
|
||||
* @param {string} [options.category] - The category of the XBlock. Defaults to the type if not provided.
|
||||
* @param {string} options.parentLocator - The parent locator of the XBlock.
|
||||
* @param {string} [options.displayName] - The display name for the XBlock.
|
||||
* @param {string} [options.boilerplate] - The boilerplate for the XBlock.
|
||||
* @returns {Promise<Object>} A Promise that resolves to the created XBlock data.
|
||||
* @param {string} options.parentLocator - The parent locator.
|
||||
* @param {string} [options.displayName] - The display name.
|
||||
* @param {string} [options.boilerplate] - The boilerplate.
|
||||
* @param {string} [options.stagedContent] - The staged content.
|
||||
*/
|
||||
export async function createCourseXblock({
|
||||
type, category, parentLocator, displayName, boilerplate,
|
||||
type, category, parentLocator, displayName, boilerplate, stagedContent,
|
||||
}) {
|
||||
const body = {
|
||||
type,
|
||||
@@ -74,6 +73,7 @@ export async function createCourseXblock({
|
||||
category: category || type,
|
||||
parent_locator: parentLocator,
|
||||
display_name: displayName,
|
||||
staged_content: stagedContent,
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
export const getCourseUnitData = (state) => state.courseUnit.unit;
|
||||
export const getCanEdit = (state) => state.courseUnit.canEdit;
|
||||
export const getStaticFileNotices = (state) => state.courseUnit.staticFileNotices;
|
||||
export const getCourseUnit = (state) => state.courseUnit;
|
||||
export const getSavingStatus = (state) => state.courseUnit.savingStatus;
|
||||
export const getLoadingStatus = (state) => state.courseUnit.loadingStatus;
|
||||
export const getSequenceStatus = (state) => state.courseUnit.sequenceStatus;
|
||||
@@ -7,3 +14,9 @@ export const getCourseSectionVertical = (state) => state.courseUnit.courseSectio
|
||||
export const getCourseId = (state) => state.courseDetail.courseId;
|
||||
export const getSequenceId = (state) => state.courseUnit.sequenceId;
|
||||
export const getCourseVerticalChildren = (state) => state.courseUnit.courseVerticalChildren;
|
||||
const getLoadingStatuses = (state) => state.courseUnit.loadingStatus;
|
||||
export const getIsLoading = createSelector(
|
||||
[getLoadingStatuses],
|
||||
loadingStatus => Object.values(loadingStatus)
|
||||
.some((status) => status === RequestStatus.IN_PROGRESS),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ const slice = createSlice({
|
||||
savingStatus: '',
|
||||
isQueryPending: false,
|
||||
isTitleEditFormOpen: false,
|
||||
canEdit: true,
|
||||
loadingStatus: {
|
||||
fetchUnitLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
courseSectionVerticalLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
@@ -16,7 +17,8 @@ const slice = createSlice({
|
||||
},
|
||||
unit: {},
|
||||
courseSectionVertical: {},
|
||||
courseVerticalChildren: {},
|
||||
courseVerticalChildren: { children: [], isPublished: true },
|
||||
staticFileNotices: {},
|
||||
},
|
||||
reducers: {
|
||||
fetchCourseItemSuccess: (state, { payload }) => {
|
||||
@@ -95,6 +97,9 @@ const slice = createSlice({
|
||||
}),
|
||||
};
|
||||
},
|
||||
fetchStaticFileNoticesSuccess: (state, { payload }) => {
|
||||
state.staticFileNotices = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -115,6 +120,7 @@ export const {
|
||||
updateCourseVerticalChildrenLoadingStatus,
|
||||
deleteXBlock,
|
||||
duplicateXBlock,
|
||||
fetchStaticFileNoticesSuccess,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import { updateModel, updateModels } from '../../generic/model-store';
|
||||
import { updateClipboardData } from '../../generic/data/slice';
|
||||
import {
|
||||
getCourseUnitData,
|
||||
editUnitDisplayName,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
updateQueryPendingStatus,
|
||||
deleteXBlock,
|
||||
duplicateXBlock,
|
||||
fetchStaticFileNoticesSuccess,
|
||||
} from './slice';
|
||||
import { getNotificationMessage } from './utils';
|
||||
|
||||
@@ -68,6 +70,9 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) {
|
||||
modelType: 'units',
|
||||
models: courseSectionVerticalData.units,
|
||||
}));
|
||||
dispatch(fetchStaticFileNoticesSuccess(JSON.parse(localStorage.getItem('staticFileNotices'))));
|
||||
localStorage.removeItem('staticFileNotices');
|
||||
dispatch(updateClipboardData(courseSectionVerticalData.userClipboard));
|
||||
dispatch(fetchSequenceSuccess({ sequenceId }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -139,9 +144,14 @@ export function editCourseUnitVisibilityAndData(itemId, type, isVisible, groupAc
|
||||
export function createNewCourseXBlock(body, callback, blockId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.adding));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
|
||||
if (body.stagedContent) {
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting));
|
||||
} else {
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.adding));
|
||||
}
|
||||
|
||||
try {
|
||||
await createCourseXblock(body).then(async (result) => {
|
||||
if (result) {
|
||||
@@ -150,6 +160,14 @@ export function createNewCourseXBlock(body, callback, blockId) {
|
||||
const courseSectionVerticalData = await getCourseSectionVerticalData(formattedResult.locator);
|
||||
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
|
||||
}
|
||||
if (body.stagedContent) {
|
||||
localStorage.setItem('staticFileNotices', JSON.stringify(formattedResult.staticFileNotices));
|
||||
dispatch(fetchStaticFileNoticesSuccess(formattedResult.staticFileNotices));
|
||||
|
||||
if (body.parentLocator.includes('vertical')) {
|
||||
localStorage.removeItem('staticFileNotices');
|
||||
}
|
||||
}
|
||||
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
|
||||
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
|
||||
dispatch(hideProcessingNotification());
|
||||
@@ -162,8 +180,6 @@ export function createNewCourseXBlock(body, callback, blockId) {
|
||||
const courseUnit = await getCourseUnitData(currentBlockId);
|
||||
dispatch(fetchCourseItemSuccess(courseUnit));
|
||||
}
|
||||
const courseUnit = await getCourseUnitData(blockId);
|
||||
dispatch(fetchCourseItemSuccess(courseUnit));
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch(hideProcessingNotification());
|
||||
@@ -195,6 +211,8 @@ export function deleteUnitItemQuery(itemId, xblockId) {
|
||||
try {
|
||||
await deleteUnitItem(xblockId);
|
||||
dispatch(deleteXBlock(xblockId));
|
||||
const { userClipboard } = await getCourseSectionVerticalData(itemId);
|
||||
dispatch(updateClipboardData(userClipboard));
|
||||
const courseUnit = await getCourseUnitData(itemId);
|
||||
dispatch(fetchCourseItemSuccess(courseUnit));
|
||||
dispatch(hideProcessingNotification());
|
||||
|
||||
@@ -20,10 +20,14 @@ import {
|
||||
getLoadingStatus,
|
||||
getSavingStatus,
|
||||
getSequenceStatus,
|
||||
getStaticFileNotices,
|
||||
getCanEdit,
|
||||
} from './data/selectors';
|
||||
import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice';
|
||||
import { PUBLISH_TYPES } from './constants';
|
||||
|
||||
import { useCopyToClipboard } from '../generic/clipboard';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -36,10 +40,14 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
const sequenceStatus = useSelector(getSequenceStatus);
|
||||
const { draftPreviewLink, publishedPreviewLink } = useSelector(getCourseSectionVertical);
|
||||
const courseVerticalChildren = useSelector(getCourseVerticalChildren);
|
||||
const staticFileNotices = useSelector(getStaticFileNotices);
|
||||
const navigate = useNavigate();
|
||||
const isTitleEditFormOpen = useSelector(state => state.courseUnit.isTitleEditFormOpen);
|
||||
const isQueryPending = useSelector(state => state.courseUnit.isQueryPending);
|
||||
const canEdit = useSelector(getCanEdit);
|
||||
const { currentlyVisibleToStudents } = courseUnit;
|
||||
const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(canEdit);
|
||||
const { canPasteComponent } = courseVerticalChildren;
|
||||
|
||||
const unitTitle = courseUnit.metadata?.displayName || '';
|
||||
const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id;
|
||||
@@ -117,11 +125,15 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
savingStatus,
|
||||
isQueryPending,
|
||||
isErrorAlert,
|
||||
staticFileNotices,
|
||||
currentlyVisibleToStudents,
|
||||
isLoading: loadingStatus.fetchUnitLoadingStatus === RequestStatus.IN_PROGRESS
|
||||
|| loadingStatus.courseSectionVerticalLoadingStatus === RequestStatus.IN_PROGRESS,
|
||||
isTitleEditFormOpen,
|
||||
isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED,
|
||||
sharedClipboardData,
|
||||
showPasteXBlock,
|
||||
showPasteUnit,
|
||||
handleInternetConnectionFailed,
|
||||
unitXBlockActions,
|
||||
headerNavigationsActions,
|
||||
@@ -130,5 +142,6 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
handleCreateNewCourseXBlock,
|
||||
handleConfigureSubmit,
|
||||
courseVerticalChildren,
|
||||
canPasteComponent,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,6 +9,10 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-unit.general.alert.unpublished-version.description',
|
||||
defaultMessage: 'Note: The last published version of this unit is live. By publishing changes you will change the student experience.',
|
||||
},
|
||||
pasteButtonText: {
|
||||
id: 'course-authoring.course-unit.paste-component.btn.text',
|
||||
defaultMessage: 'Paste component',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -50,6 +50,14 @@
|
||||
line-height: $headings-line-height;
|
||||
}
|
||||
}
|
||||
|
||||
.course-unit-sidebar-footer__divider {
|
||||
margin: map-get($spacers, 3\.5) map-get($spacers, 0) map-get($spacers, 3\.5);
|
||||
}
|
||||
|
||||
.course-unit-sidebar-footer__discard-changes__btn + .course-unit-sidebar-footer__divider {
|
||||
margin: map-get($spacers, 2) map-get($spacers, 0) map-get($spacers, 3\.5);
|
||||
}
|
||||
}
|
||||
|
||||
.course-unit-sidebar-date {
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { getCourseUnitData } from '../../../data/selectors';
|
||||
import { Divider } from '../../../../generic/divider';
|
||||
import { getCanEdit, getCourseUnitData } from '../../../data/selectors';
|
||||
import { copyToClipboard } from '../../../../generic/data/thunks';
|
||||
import messages from '../../messages';
|
||||
|
||||
const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const {
|
||||
id,
|
||||
published,
|
||||
hasChanges,
|
||||
enableCopyPasteUnits,
|
||||
} = useSelector(getCourseUnitData);
|
||||
const canEdit = useSelector(getCanEdit);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -22,14 +27,26 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
|
||||
</Button>
|
||||
)}
|
||||
{(published && hasChanges) && (
|
||||
<Button size="sm" variant="link" onClick={openDiscardModal} className="mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
onClick={openDiscardModal}
|
||||
className="course-unit-sidebar-footer__discard-changes__btn mt-2"
|
||||
>
|
||||
{intl.formatMessage(messages.actionButtonDiscardChangesTitle)}
|
||||
</Button>
|
||||
)}
|
||||
{enableCopyPasteUnits && (
|
||||
<Button size="sm" className="mt-2" variant="outline-primary">
|
||||
{intl.formatMessage(messages.actionButtonCopyUnitTitle)}
|
||||
</Button>
|
||||
{enableCopyPasteUnits && canEdit && (
|
||||
<>
|
||||
<Divider className="course-unit-sidebar-footer__divider" />
|
||||
<Button
|
||||
onClick={() => dispatch(copyToClipboard(id))}
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
>
|
||||
{intl.formatMessage(messages.actionButtonCopyUnitTitle)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import initializeStore from '../../../../store';
|
||||
import { executeThunk } from '../../../../utils';
|
||||
import { clipboardUnit } from '../../../../__mocks__';
|
||||
import { getCourseUnitApiUrl } from '../../../data/api';
|
||||
import { getClipboardUrl } from '../../../../generic/data/api';
|
||||
import { fetchCourseUnitQuery } from '../../../data/thunk';
|
||||
import { copyToClipboard } from '../../../../generic/data/thunks';
|
||||
import { courseUnitIndexMock } from '../../../__mocks__';
|
||||
import messages from '../../messages';
|
||||
import ActionButtons from './ActionButtons';
|
||||
|
||||
jest.mock('../../../../generic/data/thunks', () => ({
|
||||
...jest.requireActual('../../../../generic/data/thunks'),
|
||||
copyToClipboard: jest.fn().mockImplementation(() => () => {}),
|
||||
}));
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
const courseId = '123';
|
||||
|
||||
const renderComponent = (props = {}) => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<ActionButtons {...props} />
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<ActionButtons />', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
.reply(200, { ...courseUnitIndexMock, enable_copy_paste_units: true });
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
axiosMock
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
});
|
||||
|
||||
it('render ActionButtons component with Copy to clipboard', () => {
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
const copyXBlockBtn = getByRole('button', { name: messages.actionButtonCopyUnitTitle.defaultMessage });
|
||||
expect(copyXBlockBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('click on the Copy to clipboard button updates clipboardData', async () => {
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
const copyXBlockBtn = getByRole('button', { name: messages.actionButtonCopyUnitTitle.defaultMessage });
|
||||
|
||||
userEvent.click(copyXBlockBtn);
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledWith(courseUnitIndexMock.id);
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -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