diff --git a/src/constants.js b/src/constants.js index 3b7e3a5d13..93c07d4c65 100644 --- a/src/constants.js +++ b/src/constants.js @@ -25,6 +25,7 @@ export const NOTIFICATION_MESSAGES = { duplicating: 'Duplicating', deleting: 'Deleting', copying: 'Copying', + pasting: 'Pasting', discardChanges: 'Discarding changes', publishing: 'Publishing', hidingFromStudents: 'Hiding from students', diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index e6572632d0..1ece6c811e 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -24,6 +24,7 @@ import Sequence from './course-sequence'; import Sidebar from './sidebar'; import { useCourseUnit } from './hooks'; import messages from './messages'; +import { PasteNotificationAlert, PasteComponent } from './clipboard'; const CourseUnit = ({ courseId }) => { const { blockId } = useParams(); @@ -37,9 +38,13 @@ const CourseUnit = ({ courseId }) => { savingStatus, isEditTitleFormOpen, isErrorAlert, + staticFileNotices, currentlyVisibleToStudents, isInternetConnectionAlertFailed, unitXBlockActions, + sharedClipboardData, + showPasteXBlock, + showPasteUnit, handleTitleEditSubmit, headerNavigationsActions, handleTitleEdit, @@ -100,6 +105,7 @@ const CourseUnit = ({ courseId }) => { sequenceId={sequenceId} unitId={blockId} handleCreateNewCourseXBlock={handleCreateNewCourseXBlock} + showPasteUnit={showPasteUnit} /> { icon={WarningIcon} /> )} + {courseVerticalChildren.children.map(({ name, blockId: id, shouldScroll, userPartitionInfo, @@ -137,6 +147,12 @@ const CourseUnit = ({ courseId }) => { blockId={blockId} handleCreateNewCourseXBlock={handleCreateNewCourseXBlock} /> + {showPasteXBlock && ( + + )} diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss index cbf2b03df3..d82b00ddb3 100644 --- a/src/course-unit/CourseUnit.scss +++ b/src/course-unit/CourseUnit.scss @@ -3,3 +3,4 @@ @import "./add-component/AddComponent"; @import "./sidebar/Sidebar"; @import "./course-xblock/CourseXblock"; +@import "./clipboard/paste-component/PasteComponent"; diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 731180c37b..e5c31786b6 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -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,8 @@ import { postXBlockBaseApiUrl, } from './data/api'; import { + copyToClipboard, + createNewCourseXBlock, deleteUnitItemQuery, editCourseUnitVisibilityAndData, fetchCourseSectionVerticalData, @@ -25,24 +27,28 @@ import { } from './data/thunk'; import initializeStore from '../store'; import { + clipboardUnit, + clipboardXBlock, courseCreateXblockMock, courseSectionVerticalMock, courseUnitIndexMock, courseUnitMock, courseVerticalChildrenMock, + clipboardMockResponse, } from './__mocks__'; import { executeThunk } from '../utils'; +import deleteModalMessages from '../generic/delete-modal/messages'; +import pasteComponentMessages from './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'; import addComponentMessages from './add-component/messages'; import sidebarMessages from './sidebar/messages'; import { extractCourseUnitId } from './sidebar/utils'; -import CourseUnit from './CourseUnit'; -import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants'; - -import deleteModalMessages from '../generic/delete-modal/messages'; import courseXBlockMessages from './course-xblock/messages'; +import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants'; +import CourseUnit from './CourseUnit'; import messages from './messages'; import configureModalMessages from '../generic/configure-modal/messages'; @@ -54,12 +60,24 @@ const unitDisplayName = courseUnitIndexMock.metadata.display_name; const mockedUsedNavigate = jest.fn(); const userName = 'edx'; +const postXBlockBody = { + parent_locator: blockId, + staged_content: 'clipboard', +}; + jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useParams: () => ({ blockId }), useNavigate: () => mockedUsedNavigate, })); +const clipboardBroadcastChannelMock = { + postMessage: jest.fn(), + close: jest.fn(), +}; + +global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); + const RootWrapper = () => ( @@ -209,7 +227,7 @@ describe('', () => { 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 @@ -784,4 +802,389 @@ describe('', () => { .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(); + + await waitFor(() => { + expect(queryByText(sidebarMessages.actionButtonCopyUnitTitle.defaultMessage)).toBeNull(); + expect(queryByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.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(); + + 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: pasteComponentMessages.pasteComponentButtonText.defaultMessage })).toBeInTheDocument(); + + const whatsInClipboardText = getByText( + pasteComponentMessages.pasteComponentWhatsInClipboardText.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(); + + 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); + await executeThunk(copyToClipboard(blockId), store.dispatch); + + userEvent.click(getByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.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', + }, + ], + }); + + 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(); + + 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); + await executeThunk(copyToClipboard(blockId), store.dispatch); + expect(getByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.defaultMessage })).toBeInTheDocument(); + }); + + it('should copy a unit, paste it as a new unit, and update the course section vertical data', async () => { + const { + getAllByTestId, getByTestId, getByRole, getByText, + } = render(); + + axiosMock + .onGet(getCourseUnitApiUrl(courseId)) + .reply(200, { + ...courseUnitIndexMock, + enable_copy_paste_units: true, + }); + + await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); + + userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); + + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + user_clipboard: clipboardUnit, + }); + + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + await executeThunk(copyToClipboard(blockId), store.dispatch); + + userEvent.click(getByTestId('dropdown-paste-unit')); + + userEvent.click(getByText(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, getByText, + } = render(); + + axiosMock + .onGet(getCourseUnitApiUrl(courseId)) + .reply(200, { + ...courseUnitIndexMock, + enable_copy_paste_units: true, + }); + + await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); + + userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); + + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + user_clipboard: clipboardUnit, + }); + + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + await executeThunk(copyToClipboard(blockId), store.dispatch); + + userEvent.click(getByTestId('dropdown-paste-unit')); + + userEvent.click(getByText(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, + }); + + 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, getByText, + } = render(); + + axiosMock + .onGet(getCourseUnitApiUrl(courseId)) + .reply(200, { + ...courseUnitIndexMock, + enable_copy_paste_units: true, + }); + + await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); + + userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); + + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + user_clipboard: clipboardUnit, + }); + + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + await executeThunk(copyToClipboard(blockId), store.dispatch); + + userEvent.click(getByTestId('dropdown-paste-unit')); + + userEvent.click(getByText(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, + }); + + 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, getByText, + } = render(); + + axiosMock + .onGet(getCourseUnitApiUrl(courseId)) + .reply(200, { + ...courseUnitIndexMock, + enable_copy_paste_units: true, + }); + + await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); + + userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); + + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + user_clipboard: clipboardUnit, + }); + + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + await executeThunk(copyToClipboard(blockId), store.dispatch); + + userEvent.click(getByTestId('dropdown-paste-unit')); + + userEvent.click(getByText(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, + }); + + 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(); + }); + }); }); diff --git a/src/course-unit/__mocks__/clipboardResponse.js b/src/course-unit/__mocks__/clipboardResponse.js new file mode 100644 index 0000000000..30a4248c1b --- /dev/null +++ b/src/course-unit/__mocks__/clipboardResponse.js @@ -0,0 +1,9 @@ +module.exports = { + 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'], + }, +}; diff --git a/src/course-unit/__mocks__/index.js b/src/course-unit/__mocks__/index.js index 3eb7f43643..88072ae83e 100644 --- a/src/course-unit/__mocks__/index.js +++ b/src/course-unit/__mocks__/index.js @@ -5,3 +5,4 @@ export { default as courseCreateXblockMock } from './courseCreateXblock'; export { default as courseVerticalChildrenMock } from './courseVerticalChildren'; export { default as clipboardUnit } from './clipboardUnit'; export { default as clipboardXBlock } from './clipboardXBlock'; +export { default as clipboardMockResponse } from './clipboardResponse'; diff --git a/src/course-unit/clipboard/useClipboard.jsx b/src/course-unit/clipboard/hooks/useClipboard.jsx similarity index 84% rename from src/course-unit/clipboard/useClipboard.jsx rename to src/course-unit/clipboard/hooks/useClipboard.jsx index fd5bd77071..91e9c63491 100644 --- a/src/course-unit/clipboard/useClipboard.jsx +++ b/src/course-unit/clipboard/hooks/useClipboard.jsx @@ -1,14 +1,15 @@ import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; -import { getClipboardData } from '../data/selectors'; -import { CLIPBOARD_STATUS, NOT_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../constants'; +import { getClipboardData } from '../../data/selectors'; +import { CLIPBOARD_STATUS, NOT_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../constants'; const useCopyToClipboard = (canEdit = true) => { const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL)); const [showPasteUnit, setShowPasteUnit] = useState(false); const [showPasteXBlock, setShowPasteXBlock] = useState(false); const clipboardData = useSelector(getClipboardData); + const [sharedClipboardData, setSharedClipboardData] = useState({}); // Function to refresh the paste button's visibility const refreshPasteButton = (data) => { @@ -24,6 +25,7 @@ const useCopyToClipboard = (canEdit = true) => { // Handle updates to clipboard data if (canEdit) { refreshPasteButton(clipboardData); + setSharedClipboardData(clipboardData); clipboardBroadcastChannel.postMessage(clipboardData); } else { setShowPasteXBlock(false); @@ -34,6 +36,7 @@ const useCopyToClipboard = (canEdit = true) => { useEffect(() => { // Handle messages from the broadcast channel clipboardBroadcastChannel.onmessage = (event) => { + setSharedClipboardData(event.data); refreshPasteButton(event.data); }; @@ -43,7 +46,7 @@ const useCopyToClipboard = (canEdit = true) => { }; }, [clipboardBroadcastChannel]); - return { showPasteUnit, showPasteXBlock }; + return { showPasteUnit, showPasteXBlock, sharedClipboardData }; }; export default useCopyToClipboard; diff --git a/src/course-unit/clipboard/useClipboard.test.jsx b/src/course-unit/clipboard/hooks/useClipboard.test.jsx similarity index 93% rename from src/course-unit/clipboard/useClipboard.test.jsx rename to src/course-unit/clipboard/hooks/useClipboard.test.jsx index d2fb66e323..049cd52477 100644 --- a/src/course-unit/clipboard/useClipboard.test.jsx +++ b/src/course-unit/clipboard/hooks/useClipboard.test.jsx @@ -5,11 +5,11 @@ 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 { copyToClipboard } from '../data/thunk'; -import { getClipboardUrl } from '../data/api'; -import { clipboardUnit, clipboardXBlock } from '../__mocks__'; +import initializeStore from '../../../store'; +import { executeThunk } from '../../../utils'; +import { copyToClipboard } from '../../data/thunk'; +import { getClipboardUrl } from '../../data/api'; +import { clipboardUnit, clipboardXBlock } from '../../__mocks__'; import useClipboard from './useClipboard'; let axiosMock; diff --git a/src/course-unit/clipboard/index.js b/src/course-unit/clipboard/index.js new file mode 100644 index 0000000000..4b2f009321 --- /dev/null +++ b/src/course-unit/clipboard/index.js @@ -0,0 +1,3 @@ +export { default as PasteComponent } from './paste-component'; +export { default as PasteNotificationAlert } from './paste-notification'; +export { default as useCopyToClipboard } from './hooks/useClipboard'; diff --git a/src/course-unit/clipboard/paste-component/PasteComponent.scss b/src/course-unit/clipboard/paste-component/PasteComponent.scss new file mode 100644 index 0000000000..c68ed4e4c6 --- /dev/null +++ b/src/course-unit/clipboard/paste-component/PasteComponent.scss @@ -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; + } +} diff --git a/src/course-unit/clipboard/paste-component/components/PasteComponentButton.jsx b/src/course-unit/clipboard/paste-component/components/PasteComponentButton.jsx new file mode 100644 index 0000000000..1a2eddb2b3 --- /dev/null +++ b/src/course-unit/clipboard/paste-component/components/PasteComponentButton.jsx @@ -0,0 +1,33 @@ +import PropsTypes from 'prop-types'; +import { useParams } from 'react-router-dom'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button } from '@edx/paragon'; +import { ContentCopy as ContentCopyIcon } from '@edx/paragon/icons'; + +import messages from '../messages'; + +const PasteComponentButton = ({ handleCreateNewCourseXBlock }) => { + const intl = useIntl(); + const { blockId } = useParams(); + + const handlePasteXBlockComponent = () => { + handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId }, null, blockId); + }; + + return ( + + ); +}; + +PasteComponentButton.propTypes = { + handleCreateNewCourseXBlock: PropsTypes.func.isRequired, +}; + +export default PasteComponentButton; diff --git a/src/course-unit/clipboard/paste-component/components/PopoverContent.jsx b/src/course-unit/clipboard/paste-component/components/PopoverContent.jsx new file mode 100644 index 0000000000..d58c86818c --- /dev/null +++ b/src/course-unit/clipboard/paste-component/components/PopoverContent.jsx @@ -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 '@edx/paragon'; +import { OpenInNew as OpenInNewIcon } from '@edx/paragon/icons'; + +import messages from '../messages'; +import { clipboardPropsTypes } from '../constants'; + +const PopoverContent = ({ clipboardData }) => { + const intl = useIntl(); + const { sourceEditUrl, content, sourceContextTitle } = clipboardData; + + return ( + + + + {content.displayName} + {sourceEditUrl && ( + + )} + +
+ + {content.blockTypeDisplay} + + {intl.formatMessage(messages.popoverContentText)} + + {sourceContextTitle} + +
+
+
+ ); +}; + +PopoverContent.propTypes = { + clipboardData: PropTypes.shape(clipboardPropsTypes).isRequired, +}; + +export default PopoverContent; diff --git a/src/course-unit/clipboard/paste-component/components/WhatsInClipboard.jsx b/src/course-unit/clipboard/paste-component/components/WhatsInClipboard.jsx new file mode 100644 index 0000000000..5187057f8d --- /dev/null +++ b/src/course-unit/clipboard/paste-component/components/WhatsInClipboard.jsx @@ -0,0 +1,58 @@ +import { useRef } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Icon } from '@edx/paragon'; +import { Question as QuestionIcon } from '@edx/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 ( +
handlePopoverToggle(true)} + onMouseLeave={() => handlePopoverToggle(false)} + onFocus={() => togglePopover(true)} + onBlur={() => togglePopover(false)} + > + +

+ {intl.formatMessage(messages.pasteComponentWhatsInClipboardText)} +

+
+ ); +}; + +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; diff --git a/src/course-unit/clipboard/paste-component/components/index.js b/src/course-unit/clipboard/paste-component/components/index.js new file mode 100644 index 0000000000..86980f4b9b --- /dev/null +++ b/src/course-unit/clipboard/paste-component/components/index.js @@ -0,0 +1,3 @@ +export { default as WhatsInClipboard } from './WhatsInClipboard'; +export { default as PasteComponentButton } from './PasteComponentButton'; +export { default as PopoverContent } from './PopoverContent'; diff --git a/src/course-unit/clipboard/paste-component/constants.js b/src/course-unit/clipboard/paste-component/constants.js new file mode 100644 index 0000000000..454f332c84 --- /dev/null +++ b/src/course-unit/clipboard/paste-component/constants.js @@ -0,0 +1,12 @@ +import PropTypes from 'prop-types'; + +export const clipboardPropsTypes = { + sourceEditUrl: PropTypes.string.isRequired, + content: PropTypes.shape({ + displayName: PropTypes.string.isRequired, + blockTypeDisplay: PropTypes.string.isRequired, + }).isRequired, + sourceContextTitle: PropTypes.string.isRequired, +}; + +export const OVERLAY_TRIGGERS = ['hover', 'focus']; diff --git a/src/course-unit/clipboard/paste-component/index.jsx b/src/course-unit/clipboard/paste-component/index.jsx new file mode 100644 index 0000000000..2742a3b411 --- /dev/null +++ b/src/course-unit/clipboard/paste-component/index.jsx @@ -0,0 +1,59 @@ +import { useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { OverlayTrigger, Popover } from '@edx/paragon'; + +import { PopoverContent, PasteComponentButton, WhatsInClipboard } from './components'; +import { clipboardPropsTypes, OVERLAY_TRIGGERS } from './constants'; + +const PasteComponent = ({ handleCreateNewCourseXBlock, clipboardData }) => { + const [showPopover, togglePopover] = useState(false); + const popoverElementRef = useRef(null); + + const handlePopoverToggle = (isOpen) => togglePopover(isOpen); + + const renderPopover = (props) => ( +
+ handlePopoverToggle(true)} + onMouseLeave={() => handlePopoverToggle(false)} + onFocus={() => handlePopoverToggle(true)} + onBlur={() => handlePopoverToggle(false)} + {...props} + > + + +
+ ); + + return ( + <> + + + + + + ); +}; + +PasteComponent.propTypes = { + handleCreateNewCourseXBlock: PropTypes.func.isRequired, + clipboardData: PropTypes.shape(clipboardPropsTypes), +}; + +PasteComponent.defaultProps = { + clipboardData: null, +}; + +export default PasteComponent; diff --git a/src/course-unit/clipboard/paste-component/messages.js b/src/course-unit/clipboard/paste-component/messages.js new file mode 100644 index 0000000000..1463a6746f --- /dev/null +++ b/src/course-unit/clipboard/paste-component/messages.js @@ -0,0 +1,18 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + pasteComponentButtonText: { + id: 'course-authoring.course-unit.paste-component.btn.text', + defaultMessage: 'Paste component', + }, + popoverContentText: { + id: 'course-authoring.course-unit.popover.content.text', + defaultMessage: 'From:', + }, + pasteComponentWhatsInClipboardText: { + id: 'course-authoring.course-unit.paste-component.whats-in-clipboard.text', + defaultMessage: "What's in my clipboard?", + }, +}); + +export default messages; diff --git a/src/course-unit/clipboard/paste-notification/components/ActionButton.jsx b/src/course-unit/clipboard/paste-notification/components/ActionButton.jsx new file mode 100644 index 0000000000..6e52d669fe --- /dev/null +++ b/src/course-unit/clipboard/paste-notification/components/ActionButton.jsx @@ -0,0 +1,21 @@ +import PropTypes from 'prop-types'; +import { Button } from '@edx/paragon'; +import { Link } from 'react-router-dom'; +import { getConfig } from '@edx/frontend-platform'; + +const ActionButton = ({ courseId, title }) => ( + +); + +ActionButton.propTypes = { + courseId: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, +}; + +export default ActionButton; diff --git a/src/course-unit/clipboard/paste-notification/components/AlertContent.jsx b/src/course-unit/clipboard/paste-notification/components/AlertContent.jsx new file mode 100644 index 0000000000..f5e8c55a98 --- /dev/null +++ b/src/course-unit/clipboard/paste-notification/components/AlertContent.jsx @@ -0,0 +1,22 @@ +import PropTypes from 'prop-types'; + +import { FILE_LIST_DEFAULT_VALUE } from '../constants'; +import FileList from './FileList'; + +const AlertContent = ({ fileList, text }) => ( + <> + {text} + + +); + +AlertContent.propTypes = { + fileList: PropTypes.arrayOf(PropTypes.string), + text: PropTypes.string.isRequired, +}; + +AlertContent.defaultProps = { + fileList: FILE_LIST_DEFAULT_VALUE, +}; + +export default AlertContent; diff --git a/src/course-unit/clipboard/paste-notification/components/FileList.jsx b/src/course-unit/clipboard/paste-notification/components/FileList.jsx new file mode 100644 index 0000000000..f3f9e3beaa --- /dev/null +++ b/src/course-unit/clipboard/paste-notification/components/FileList.jsx @@ -0,0 +1,21 @@ +import PropTypes from 'prop-types'; + +import { FILE_LIST_DEFAULT_VALUE } from '../constants'; + +const FileList = ({ fileList }) => ( +
    + {fileList.map((fileName) => ( +
  • {fileName}
  • + ))} +
+); + +FileList.propTypes = { + fileList: PropTypes.arrayOf(PropTypes.string), +}; + +FileList.defaultProps = { + fileList: FILE_LIST_DEFAULT_VALUE, +}; + +export default FileList; diff --git a/src/course-unit/clipboard/paste-notification/components/index.js b/src/course-unit/clipboard/paste-notification/components/index.js new file mode 100644 index 0000000000..ccee5ba494 --- /dev/null +++ b/src/course-unit/clipboard/paste-notification/components/index.js @@ -0,0 +1,3 @@ +export { default as AlertContent } from './AlertContent'; +export { default as FileList } from './FileList'; +export { default as ActionButton } from './ActionButton'; diff --git a/src/course-unit/clipboard/paste-notification/constants.js b/src/course-unit/clipboard/paste-notification/constants.js new file mode 100644 index 0000000000..a44ab2276c --- /dev/null +++ b/src/course-unit/clipboard/paste-notification/constants.js @@ -0,0 +1,7 @@ +export const FILE_LIST_DEFAULT_VALUE = []; + +export const initialNotificationAlertsState = { + conflictingFilesAlert: true, + errorFilesAlert: true, + newFilesAlert: true, +}; diff --git a/src/course-unit/clipboard/paste-notification/index.jsx b/src/course-unit/clipboard/paste-notification/index.jsx new file mode 100644 index 0000000000..1db8cff6ad --- /dev/null +++ b/src/course-unit/clipboard/paste-notification/index.jsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Error as ErrorIcon, + Info as InfoIcon, + Warning as WarningIcon, +} from '@edx/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 && ( + handleCloseNotificationAlert('conflictingFilesAlert')} + description={( + + )} + variant="warning" + icon={WarningIcon} + dismissible + actions={[ + , + ]} + /> + )} + {hasErrorFiles && ( + handleCloseNotificationAlert('errorFilesAlert')} + description={( + + )} + variant="danger" + icon={ErrorIcon} + dismissible + /> + )} + {hasNewFiles && ( + handleCloseNotificationAlert('newFilesAlert')} + description={( + + )} + variant="info" + icon={InfoIcon} + dismissible + actions={[ + , + ]} + /> + )} + + ); +}; + +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; diff --git a/src/course-unit/clipboard/paste-notification/messages.js b/src/course-unit/clipboard/paste-notification/messages.js new file mode 100644 index 0000000000..2786256a87 --- /dev/null +++ b/src/course-unit/clipboard/paste-notification/messages.js @@ -0,0 +1,38 @@ +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.', + }, + 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:', + }, + hasConflictingErrorsButtonText: { + id: 'course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text', + defaultMessage: 'Upload files', + }, + hasErrorsTitle: { + id: 'course-authoring.course-unit.paste-notification.has-errors.title', + defaultMessage: 'Some errors occurred', + }, + hasErrorsDescription: { + id: 'course-authoring.course-unit.paste-notification.has-errors.description', + defaultMessage: 'The following required files could not be added to the course:', + }, + hasNewFilesTitle: { + id: 'course-authoring.course-unit.paste-notification.has-new-files.title', + defaultMessage: 'New file(s) added to Files & Uploads.', + }, + hasNewFilesDescription: { + id: 'course-authoring.course-unit.paste-notification.has-new-files.description', + defaultMessage: 'The following required files were imported to this course:', + }, + hasNewFilesButtonText: { + id: 'course-authoring.course-unit.paste-notification.has-new-files.button.text', + defaultMessage: 'View files', + }, +}); + +export default messages; diff --git a/src/course-unit/clipboard/paste-notification/utils.js b/src/course-unit/clipboard/paste-notification/utils.js new file mode 100644 index 0000000000..d8d1122677 --- /dev/null +++ b/src/course-unit/clipboard/paste-notification/utils.js @@ -0,0 +1,12 @@ +/** + * Gets the status of an alert based on the length of a fileList. + * + * @param {Array} 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); diff --git a/src/course-unit/course-sequence/Sequence.jsx b/src/course-unit/course-sequence/Sequence.jsx index 5948b5675c..f405e07fa4 100644 --- a/src/course-unit/course-sequence/Sequence.jsx +++ b/src/course-unit/course-sequence/Sequence.jsx @@ -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} /> @@ -61,6 +63,7 @@ Sequence.propTypes = { courseId: PropTypes.string.isRequired, sequenceId: PropTypes.string, handleCreateNewCourseXBlock: PropTypes.func.isRequired, + showPasteUnit: PropTypes.bool.isRequired, }; Sequence.defaultProps = { diff --git a/src/course-unit/course-sequence/messages.js b/src/course-unit/course-sequence/messages.js index 7c33787077..0f7019ae20 100644 --- a/src/course-unit/course-sequence/messages.js +++ b/src/course-unit/course-sequence/messages.js @@ -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; diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx index cbf560194b..52d1a840e6 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx @@ -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} /> ); }; @@ -106,6 +108,7 @@ SequenceNavigation.propTypes = { className: PropTypes.string, sequenceId: PropTypes.string, handleCreateNewCourseXBlock: PropTypes.func.isRequired, + showPasteUnit: PropTypes.bool.isRequired, }; SequenceNavigation.defaultProps = { diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx index ca32aaf3b9..6ba3d2cc50 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx @@ -1,12 +1,18 @@ import PropTypes from 'prop-types'; import { Button, Dropdown } from '@edx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Plus as PlusIcon } from '@edx/paragon/icons/'; +import { Plus as PlusIcon, ContentPasteGo as ContentPasteGoIcon } from '@edx/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)} + {showPasteUnit && ( + + )} ); @@ -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; diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx index 4c93f9cb4d..36175f65bb 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx @@ -1,6 +1,6 @@ import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; -import { Button } from '@edx/paragon'; +import { Button, Dropdown } from '@edx/paragon'; import { Plus as PlusIcon } from '@edx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import { useNavigate } from 'react-router-dom'; @@ -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 (
@@ -56,13 +66,31 @@ const SequenceNavigationTabs = ({ unitIds, unitId, handleCreateNewCourseXBlock } > {intl.formatMessage(messages.newUnitBtnText)} + {showPasteUnit && ( + + + + + {intl.formatMessage(messages.pasteAsNewUnitLink)} + + + + )}
{shouldDisplayDropdown && ( )} @@ -73,6 +101,7 @@ SequenceNavigationTabs.propTypes = { unitId: PropTypes.string.isRequired, unitIds: PropTypes.arrayOf(PropTypes.string).isRequired, handleCreateNewCourseXBlock: PropTypes.func.isRequired, + showPasteUnit: PropTypes.bool.isRequired, }; export default SequenceNavigationTabs; diff --git a/src/course-unit/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx index 919d2a0611..120339934b 100644 --- a/src/course-unit/course-xblock/CourseXBlock.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.jsx @@ -5,14 +5,13 @@ import { } from '@edx/paragon'; import { EditOutline as EditIcon, MoreVert as MoveVertIcon } from '@edx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; 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 { copyToClipboard } from '../data/thunk'; -import { getCourseUnitEnableCopyPaste } from '../data/selectors'; import ContentIFrame from './ContentIFrame'; import { getIFrameUrl } from './urls'; import messages from './messages'; @@ -24,7 +23,6 @@ const CourseXBlock = ({ const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const dispatch = useDispatch(); - const enableCopyPasteUnits = useSelector(getCourseUnitEnableCopyPaste); const intl = useIntl(); const iframeUrl = getIFrameUrl({ blockId: id }); @@ -79,11 +77,9 @@ const CourseXBlock = ({ {intl.formatMessage(messages.blockLabelButtonMove)} - {enableCopyPasteUnits && ( - dispatch(copyToClipboard(id))}> - {intl.formatMessage(messages.blockLabelButtonCopyToClipboard)} - - )} + dispatch(copyToClipboard(id))}> + {intl.formatMessage(messages.blockLabelButtonCopyToClipboard)} + {intl.formatMessage(messages.blockLabelButtonManageAccess)} diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index b0c89a3a01..0ac4bb6e7b 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -61,13 +61,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} 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, @@ -75,6 +75,7 @@ export async function createCourseXblock({ category: category || type, parent_locator: parentLocator, display_name: displayName, + staged_content: stagedContent, }; const { data } = await getAuthenticatedHttpClient() diff --git a/src/course-unit/data/selectors.js b/src/course-unit/data/selectors.js index 1ccf567408..7595417bfc 100644 --- a/src/course-unit/data/selectors.js +++ b/src/course-unit/data/selectors.js @@ -1,4 +1,5 @@ export const getCourseUnitData = (state) => state.courseUnit.unit; +export const getStaticFileNotices = (state) => state.courseUnit.staticFileNotices; export const getSavingStatus = (state) => state.courseUnit.savingStatus; export const getLoadingStatus = (state) => state.courseUnit.loadingStatus; export const getSequenceStatus = (state) => state.courseUnit.sequenceStatus; diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index 66b82aafd9..d0513cddcd 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -18,6 +18,7 @@ const slice = createSlice({ courseSectionVertical: {}, courseVerticalChildren: [], clipboardData: null, + staticFileNotices: {}, }, reducers: { fetchCourseItemSuccess: (state, { payload }) => { @@ -99,6 +100,9 @@ const slice = createSlice({ updateClipboardData: (state, { payload }) => { state.clipboardData = payload; }, + fetchStaticFileNoticesSuccess: (state, { payload }) => { + state.staticFileNotices = payload; + }, }, }); @@ -120,6 +124,7 @@ export const { deleteXBlock, duplicateXBlock, updateClipboardData, + fetchStaticFileNoticesSuccess, } = slice.actions; export const { diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index 5e22a9c814..e67425cd6b 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -1,4 +1,6 @@ import { logError } from '@edx/frontend-platform/logging'; +import { camelCaseObject } from '@edx/frontend-platform'; + import { hideProcessingNotification, showProcessingNotification, @@ -6,6 +8,7 @@ import { import { RequestStatus } from '../../data/constants'; import { NOTIFICATION_MESSAGES } from '../../constants'; import { updateModel, updateModels } from '../../generic/model-store'; +import { CLIPBOARD_STATUS } from '../constants'; import { getCourseUnitData, editUnitDisplayName, @@ -34,8 +37,8 @@ import { updateQueryPendingStatus, deleteXBlock, duplicateXBlock, + fetchStaticFileNoticesSuccess, } from './slice'; -import { CLIPBOARD_STATUS } from '../constants'; import { getNotificationMessage } from './utils'; export function fetchCourseUnitQuery(courseId) { @@ -143,16 +146,25 @@ 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) { + const formattedResult = camelCaseObject(result); if (body.category === 'vertical') { - const courseSectionVerticalData = await getCourseSectionVerticalData(result.locator); + const courseSectionVerticalData = await getCourseSectionVerticalData(formattedResult.locator); dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData)); } + if (body.stagedContent) { + dispatch(fetchStaticFileNoticesSuccess(formattedResult.staticFileNotices)); + } const courseVerticalChildrenData = await getCourseVerticalChildren(blockId); dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); dispatch(hideProcessingNotification()); @@ -162,8 +174,6 @@ export function createNewCourseXBlock(body, callback, blockId) { callback(result); } } - const courseUnit = await getCourseUnitData(blockId); - dispatch(fetchCourseItemSuccess(courseUnit)); }); } catch (error) { dispatch(hideProcessingNotification()); @@ -195,6 +205,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()); @@ -233,7 +245,6 @@ export function copyToClipboard(usageKey) { const POLL_INTERVAL_MS = 1000; // Timeout duration for polling in milliseconds return async (dispatch) => { - dispatch(updateClipboardData(null)); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying)); dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(updateQueryPendingStatus(true)); diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 45d693e56d..acb42af584 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -20,8 +20,12 @@ import { getLoadingStatus, getSavingStatus, getSequenceStatus, + getCourseUnitEnableCopyPaste, + getStaticFileNotices, } from './data/selectors'; import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice'; + +import { useCopyToClipboard } from './clipboard'; import { PUBLISH_TYPES } from './constants'; // eslint-disable-next-line import/prefer-default-export @@ -36,10 +40,13 @@ export const useCourseUnit = ({ courseId, blockId }) => { const sequenceStatus = useSelector(getSequenceStatus); const { draftPreviewLink, publishedPreviewLink } = useSelector(getCourseSectionVertical); const courseVerticalChildren = useSelector(getCourseVerticalChildren); + const enableCopyPasteUnits = useSelector(getCourseUnitEnableCopyPaste); + const staticFileNotices = useSelector(getStaticFileNotices); const navigate = useNavigate(); const isEditTitleFormOpen = useSelector(state => state.courseUnit.isEditTitleFormOpen); const isQueryPending = useSelector(state => state.courseUnit.isQueryPending); const { currentlyVisibleToStudents } = courseUnit; + const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(); const unitTitle = courseUnit.metadata?.displayName || ''; const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id; @@ -117,11 +124,16 @@ export const useCourseUnit = ({ courseId, blockId }) => { savingStatus, isQueryPending, isErrorAlert, + staticFileNotices, currentlyVisibleToStudents, isLoading: loadingStatus.fetchUnitLoadingStatus === RequestStatus.IN_PROGRESS || loadingStatus.courseSectionVerticalLoadingStatus === RequestStatus.IN_PROGRESS, isEditTitleFormOpen, isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED, + enableCopyPasteUnits, + sharedClipboardData, + showPasteXBlock, + showPasteUnit, handleInternetConnectionFailed, unitXBlockActions, headerNavigationsActions, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index cf7caaf4e7..94e82b03c6 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1138,5 +1138,16 @@ "course-authoring.textbooks.sidebar.section-2.title": "What if my book isn't divided into chapters?", "course-authoring.textbooks.sidebar.section-2.descriptions": "If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.", "course-authoring.textbooks.sidebar.section-link": "Learn more", - "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response." + "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", + "course-authoring.course-unit.paste-component.btn.text": "Paste component", + "course-authoring.course-unit.popover.content.text": "From:", + "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", + "course-authoring.course-unit.paste-notification.has-errors.title": "Some errors occurred", + "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", + "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", + "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index e31eaaae57..e620b25e62 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1138,5 +1138,16 @@ "course-authoring.textbooks.sidebar.section-2.title": "What if my book isn't divided into chapters?", "course-authoring.textbooks.sidebar.section-2.descriptions": "If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.", "course-authoring.textbooks.sidebar.section-link": "Learn more", - "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response." + "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", + "course-authoring.course-unit.paste-component.btn.text": "Paste component", + "course-authoring.course-unit.popover.content.text": "From:", + "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", + "course-authoring.course-unit.paste-notification.has-errors.title": "Some errors occurred", + "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", + "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", + "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" } diff --git a/src/i18n/messages/de_DE.json b/src/i18n/messages/de_DE.json index 75e50ffb59..4a26a3f3bd 100644 --- a/src/i18n/messages/de_DE.json +++ b/src/i18n/messages/de_DE.json @@ -1138,5 +1138,16 @@ "course-authoring.textbooks.sidebar.section-2.title": "What if my book isn't divided into chapters?", "course-authoring.textbooks.sidebar.section-2.descriptions": "If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.", "course-authoring.textbooks.sidebar.section-link": "Learn more", - "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response." + "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", + "course-authoring.course-unit.paste-component.btn.text": "Paste component", + "course-authoring.course-unit.popover.content.text": "From:", + "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", + "course-authoring.course-unit.paste-notification.has-errors.title": "Some errors occurred", + "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", + "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", + "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" } diff --git a/src/i18n/messages/es_419.json b/src/i18n/messages/es_419.json index 10e4aaebb9..499e0d6edd 100644 --- a/src/i18n/messages/es_419.json +++ b/src/i18n/messages/es_419.json @@ -1138,5 +1138,16 @@ "course-authoring.textbooks.sidebar.section-2.title": "What if my book isn't divided into chapters?", "course-authoring.textbooks.sidebar.section-2.descriptions": "If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.", "course-authoring.textbooks.sidebar.section-link": "Learn more", - "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response." + "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", + "course-authoring.course-unit.paste-component.btn.text": "Paste component", + "course-authoring.course-unit.popover.content.text": "From:", + "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", + "course-authoring.course-unit.paste-notification.has-errors.title": "Some errors occurred", + "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", + "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", + "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" } diff --git a/src/i18n/messages/fa_IR.json b/src/i18n/messages/fa_IR.json index 435d10dbde..83c981b3f2 100644 --- a/src/i18n/messages/fa_IR.json +++ b/src/i18n/messages/fa_IR.json @@ -160,5 +160,16 @@ "course-authoring.textbooks.sidebar.section-2.title": "What if my book isn't divided into chapters?", "course-authoring.textbooks.sidebar.section-2.descriptions": "If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.", "course-authoring.textbooks.sidebar.section-link": "Learn more", - "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response." + "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", + "course-authoring.course-unit.paste-component.btn.text": "Paste component", + "course-authoring.course-unit.popover.content.text": "From:", + "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", + "course-authoring.course-unit.paste-notification.has-errors.title": "Some errors occurred", + "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", + "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", + "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 1b50b6e1f6..d95f257e07 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1138,5 +1138,16 @@ "course-authoring.textbooks.sidebar.section-2.title": "What if my book isn't divided into chapters?", "course-authoring.textbooks.sidebar.section-2.descriptions": "If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.", "course-authoring.textbooks.sidebar.section-link": "Learn more", - "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response." + "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", + "course-authoring.course-unit.paste-component.btn.text": "Paste component", + "course-authoring.course-unit.popover.content.text": "From:", + "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", + "course-authoring.course-unit.paste-notification.has-errors.title": "Some errors occurred", + "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", + "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", + "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" } diff --git a/src/i18n/messages/fr_CA.json b/src/i18n/messages/fr_CA.json index f4280e343d..4b0fc44086 100644 --- a/src/i18n/messages/fr_CA.json +++ b/src/i18n/messages/fr_CA.json @@ -1138,5 +1138,16 @@ "course-authoring.textbooks.sidebar.section-2.title": "What if my book isn't divided into chapters?", "course-authoring.textbooks.sidebar.section-2.descriptions": "If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.", "course-authoring.textbooks.sidebar.section-link": "Learn more", - "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response." + "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", + "course-authoring.course-unit.paste-component.btn.text": "Paste component", + "course-authoring.course-unit.popover.content.text": "From:", + "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", + "course-authoring.course-unit.paste-notification.has-errors.title": "Some errors occurred", + "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", + "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", + "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" } diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index e31eaaae57..e620b25e62 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -1138,5 +1138,16 @@ "course-authoring.textbooks.sidebar.section-2.title": "What if my book isn't divided into chapters?", "course-authoring.textbooks.sidebar.section-2.descriptions": "If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.", "course-authoring.textbooks.sidebar.section-link": "Learn more", - "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response." + "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", + "course-authoring.course-unit.paste-component.btn.text": "Paste component", + "course-authoring.course-unit.popover.content.text": "From:", + "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", + "course-authoring.course-unit.paste-notification.has-errors.title": "Some errors occurred", + "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", + "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", + "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index e31eaaae57..e620b25e62 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -1138,5 +1138,16 @@ "course-authoring.textbooks.sidebar.section-2.title": "What if my book isn't divided into chapters?", "course-authoring.textbooks.sidebar.section-2.descriptions": "If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.", "course-authoring.textbooks.sidebar.section-link": "Learn more", - "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response." + "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", + "course-authoring.course-unit.paste-component.btn.text": "Paste component", + "course-authoring.course-unit.popover.content.text": "From:", + "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", + "course-authoring.course-unit.paste-notification.has-errors.title": "Some errors occurred", + "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", + "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", + "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" } diff --git a/src/i18n/messages/it_IT.json b/src/i18n/messages/it_IT.json index f723355eb2..12743ecc73 100644 --- a/src/i18n/messages/it_IT.json +++ b/src/i18n/messages/it_IT.json @@ -1138,5 +1138,16 @@ "course-authoring.textbooks.sidebar.section-2.title": "What if my book isn't divided into chapters?", "course-authoring.textbooks.sidebar.section-2.descriptions": "If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.", "course-authoring.textbooks.sidebar.section-link": "Learn more", - "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response." + "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", + "course-authoring.course-unit.paste-component.btn.text": "Paste component", + "course-authoring.course-unit.popover.content.text": "From:", + "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", + "course-authoring.course-unit.paste-notification.has-errors.title": "Some errors occurred", + "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", + "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", + "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index e31eaaae57..e620b25e62 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1138,5 +1138,16 @@ "course-authoring.textbooks.sidebar.section-2.title": "What if my book isn't divided into chapters?", "course-authoring.textbooks.sidebar.section-2.descriptions": "If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.", "course-authoring.textbooks.sidebar.section-link": "Learn more", - "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response." + "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", + "course-authoring.course-unit.paste-component.btn.text": "Paste component", + "course-authoring.course-unit.popover.content.text": "From:", + "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", + "course-authoring.course-unit.paste-notification.has-errors.title": "Some errors occurred", + "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", + "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", + "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" } diff --git a/src/i18n/messages/pt_PT.json b/src/i18n/messages/pt_PT.json index c66ecf6c90..91265f1444 100644 --- a/src/i18n/messages/pt_PT.json +++ b/src/i18n/messages/pt_PT.json @@ -1138,5 +1138,16 @@ "course-authoring.textbooks.sidebar.section-2.title": "What if my book isn't divided into chapters?", "course-authoring.textbooks.sidebar.section-2.descriptions": "If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.", "course-authoring.textbooks.sidebar.section-link": "Learn more", - "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response." + "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", + "course-authoring.course-unit.paste-component.btn.text": "Paste component", + "course-authoring.course-unit.popover.content.text": "From:", + "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", + "course-authoring.course-unit.paste-notification.has-errors.title": "Some errors occurred", + "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", + "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", + "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" } diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index e31eaaae57..e620b25e62 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -1138,5 +1138,16 @@ "course-authoring.textbooks.sidebar.section-2.title": "What if my book isn't divided into chapters?", "course-authoring.textbooks.sidebar.section-2.descriptions": "If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.", "course-authoring.textbooks.sidebar.section-link": "Learn more", - "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response." + "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", + "course-authoring.course-unit.paste-component.btn.text": "Paste component", + "course-authoring.course-unit.popover.content.text": "From:", + "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", + "course-authoring.course-unit.paste-notification.has-errors.title": "Some errors occurred", + "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", + "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", + "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" } diff --git a/src/i18n/messages/uk.json b/src/i18n/messages/uk.json index e31eaaae57..e620b25e62 100644 --- a/src/i18n/messages/uk.json +++ b/src/i18n/messages/uk.json @@ -1138,5 +1138,16 @@ "course-authoring.textbooks.sidebar.section-2.title": "What if my book isn't divided into chapters?", "course-authoring.textbooks.sidebar.section-2.descriptions": "If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.", "course-authoring.textbooks.sidebar.section-link": "Learn more", - "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response." + "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", + "course-authoring.course-unit.paste-component.btn.text": "Paste component", + "course-authoring.course-unit.popover.content.text": "From:", + "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", + "course-authoring.course-unit.paste-notification.has-errors.title": "Some errors occurred", + "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", + "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", + "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" } diff --git a/src/i18n/messages/zh_CN.json b/src/i18n/messages/zh_CN.json index e31eaaae57..e620b25e62 100644 --- a/src/i18n/messages/zh_CN.json +++ b/src/i18n/messages/zh_CN.json @@ -1138,5 +1138,16 @@ "course-authoring.textbooks.sidebar.section-2.title": "What if my book isn't divided into chapters?", "course-authoring.textbooks.sidebar.section-2.descriptions": "If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.", "course-authoring.textbooks.sidebar.section-link": "Learn more", - "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response." + "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", + "course-authoring.course-unit.paste-component.btn.text": "Paste component", + "course-authoring.course-unit.popover.content.text": "From:", + "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", + "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", + "course-authoring.course-unit.paste-notification.has-errors.title": "Some errors occurred", + "course-authoring.course-unit.paste-notification.has-errors.description": "The following required files could not be added to the course:", + "course-authoring.course-unit.paste-notification.has-new-files.title": "New file(s) added to Files & Uploads.", + "course-authoring.course-unit.paste-notification.has-new-files.description": "The following required files were imported to this course:", + "course-authoring.course-unit.paste-notification.has-new-files.button.text": "View files" }