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

Implement copy/paste.

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

View File

@@ -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}>

View File

@@ -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();
});
});
});

View 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'],
},
};

View File

@@ -144,4 +144,5 @@ module.exports = {
},
],
isPublished: false,
canPasteComponent: true,
};

View File

@@ -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';

View File

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
export { default as AlertContent } from './AlertContent';
export { default as FileList } from './FileList';
export { default as ActionButton } from './ActionButton';

View File

@@ -0,0 +1,7 @@
export const FILE_LIST_DEFAULT_VALUE = [];
export const initialNotificationAlertsState = {
conflictingFilesAlert: true,
errorFilesAlert: true,
newFilesAlert: true,
};

View 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;

View 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;

View 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);

View File

@@ -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;
}

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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',

View File

@@ -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()

View File

@@ -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),
);

View File

@@ -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 {

View File

@@ -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());

View File

@@ -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,
};
};

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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>
</>
)}
</>
);

View File

@@ -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();
});
});