From 14eba029b2a6c23c79f358aa1df624dca10e5529 Mon Sep 17 00:00:00 2001 From: monteri <36768631+monteri@users.noreply.github.com> Date: Tue, 30 Jan 2024 13:01:26 +0200 Subject: [PATCH] feat: Copy/paste functionality * feat: [AXIMST-344] Copy/paste functionality base * feat: [AXIMST-344] Copy/paste functionality visible part * feat: tests * fix: PR comment review * refactor: refactoring after review * refactor: refactoring after rebase --------- Co-authored-by: monteri Co-authored-by: PKulkoRaccoonGang feat: [AXIMST-375] Course unit - Added functionality for copying and pasting xblocks and units (#147) * feat: [AXIMST-350] added functionality for copying and pasting xblocks and units * refactor: refactoring after review * refactor: refactoring after second review fix: [AXIMST-480] fixed paste notification behavior after switching a unit (#160) fix: [AXIMST-478] fixed copy-paste tooltip (#161) feat: [AXIMST-338] Course unit - Added canEdit and canPasteComponent variables (#170) * feat: [AXIMST-338] added canEdit and canPasteComponent variables * refactor: added condition for Can copy Unit btn feat: [AXIMST-525] separated the copy unit button (#190) refactor: [AXIMST-507] Course unit - Changed Paste unit UI (#186) * refactor: [AXIMST-507] changed Paste unit UI * refactor: code refactoring fix: fixed react-intl error (#197) fix: [AXIMST-516] fixed paste alerts view (#189) refactor: code refactoring refactor: code refactoring --- package-lock.json | 2 +- package.json | 3 + src/course-unit/CourseUnit.jsx | 19 + src/course-unit/CourseUnit.scss | 1 + src/course-unit/CourseUnit.test.jsx | 432 +++++++++++++++++- .../__mocks__/clipboardResponse.js | 9 + src/course-unit/__mocks__/clipboardUnit.js | 16 + src/course-unit/__mocks__/clipboardXBlock.js | 16 + .../__mocks__/courseVerticalChildren.js | 1 + src/course-unit/__mocks__/index.js | 3 + .../clipboard/hooks/useClipboard.jsx | 52 +++ .../clipboard/hooks/useClipboard.test.jsx | 121 +++++ src/course-unit/clipboard/index.js | 3 + .../paste-component/PasteComponent.scss | 46 ++ .../components/PasteComponentButton.jsx | 33 ++ .../components/PopoverContent.jsx | 47 ++ .../components/WhatsInClipboard.jsx | 58 +++ .../paste-component/components/index.js | 3 + .../clipboard/paste-component/constants.js | 12 + .../clipboard/paste-component/index.jsx | 61 +++ .../clipboard/paste-component/messages.js | 18 + .../components/ActionButton.jsx | 21 + .../components/AlertContent.jsx | 22 + .../components/FileList.jsx | 21 + .../paste-notification/components/index.js | 3 + .../clipboard/paste-notification/constants.js | 7 + .../clipboard/paste-notification/index.jsx | 107 +++++ .../clipboard/paste-notification/messages.js | 38 ++ .../clipboard/paste-notification/utils.js | 12 + src/course-unit/constants.js | 16 + .../course-sequence/CourseSequence.scss | 4 +- src/course-unit/course-sequence/Sequence.jsx | 3 + src/course-unit/course-sequence/messages.js | 4 + .../SequenceNavigation.jsx | 3 + .../SequenceNavigationDropdown.jsx | 26 +- .../SequenceNavigationTabs.jsx | 31 +- .../course-xblock/CourseXBlock.jsx | 14 +- .../course-xblock/CourseXBlock.test.jsx | 21 +- src/course-unit/course-xblock/messages.js | 8 +- src/course-unit/data/api.js | 35 +- src/course-unit/data/selectors.js | 4 + src/course-unit/data/slice.js | 11 + src/course-unit/data/thunk.js | 55 ++- src/course-unit/hooks.jsx | 13 + src/course-unit/sidebar/Sidebar.scss | 8 + .../sidebar-footer/ActionButtons.jsx | 31 +- .../sidebar-footer/ActionButtons.test.jsx | 77 ++++ src/generic/divider/Divider.jsx | 16 + src/generic/divider/Divider.scss | 5 + src/generic/divider/index.jsx | 2 + src/generic/styles.scss | 1 + src/i18n/messages/ar.json | 15 +- src/i18n/messages/de.json | 32 +- src/i18n/messages/de_DE.json | 32 +- src/i18n/messages/es_419.json | 32 +- src/i18n/messages/fa_IR.json | 33 +- src/i18n/messages/fr.json | 32 +- src/i18n/messages/fr_CA.json | 32 +- src/i18n/messages/hi.json | 32 +- src/i18n/messages/it.json | 32 +- src/i18n/messages/it_IT.json | 32 +- src/i18n/messages/pt.json | 32 +- src/i18n/messages/pt_PT.json | 32 +- src/i18n/messages/ru.json | 32 +- src/i18n/messages/uk.json | 32 +- src/i18n/messages/zh_CN.json | 32 +- 66 files changed, 1964 insertions(+), 75 deletions(-) create mode 100644 src/course-unit/__mocks__/clipboardResponse.js create mode 100644 src/course-unit/__mocks__/clipboardUnit.js create mode 100644 src/course-unit/__mocks__/clipboardXBlock.js create mode 100644 src/course-unit/clipboard/hooks/useClipboard.jsx create mode 100644 src/course-unit/clipboard/hooks/useClipboard.test.jsx create mode 100644 src/course-unit/clipboard/index.js create mode 100644 src/course-unit/clipboard/paste-component/PasteComponent.scss create mode 100644 src/course-unit/clipboard/paste-component/components/PasteComponentButton.jsx create mode 100644 src/course-unit/clipboard/paste-component/components/PopoverContent.jsx create mode 100644 src/course-unit/clipboard/paste-component/components/WhatsInClipboard.jsx create mode 100644 src/course-unit/clipboard/paste-component/components/index.js create mode 100644 src/course-unit/clipboard/paste-component/constants.js create mode 100644 src/course-unit/clipboard/paste-component/index.jsx create mode 100644 src/course-unit/clipboard/paste-component/messages.js create mode 100644 src/course-unit/clipboard/paste-notification/components/ActionButton.jsx create mode 100644 src/course-unit/clipboard/paste-notification/components/AlertContent.jsx create mode 100644 src/course-unit/clipboard/paste-notification/components/FileList.jsx create mode 100644 src/course-unit/clipboard/paste-notification/components/index.js create mode 100644 src/course-unit/clipboard/paste-notification/constants.js create mode 100644 src/course-unit/clipboard/paste-notification/index.jsx create mode 100644 src/course-unit/clipboard/paste-notification/messages.js create mode 100644 src/course-unit/clipboard/paste-notification/utils.js create mode 100644 src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx create mode 100644 src/generic/divider/Divider.jsx create mode 100644 src/generic/divider/Divider.scss create mode 100644 src/generic/divider/index.jsx diff --git a/package-lock.json b/package-lock.json index a4d615c222..5186ae7592 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,7 @@ "react-responsive": "9.0.2", "react-router": "6.16.0", "react-router-dom": "6.16.0", - "react-select": "^5.8.0", + "react-select": "5.8.0", "react-textarea-autosize": "^8.4.1", "react-transition-group": "4.4.5", "redux": "4.0.5", diff --git a/package.json b/package.json index c740331c61..1b778cdc89 100644 --- a/package.json +++ b/package.json @@ -114,5 +114,8 @@ }, "peerDependencies": { "decode-uri-component": ">=0.2.2" + }, + "overrides": { + "react-intl": "^6.4.0" } } diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index b2cda53184..cbb6a533d3 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,15 +38,20 @@ const CourseUnit = ({ courseId }) => { savingStatus, isTitleEditFormOpen, isErrorAlert, + staticFileNotices, currentlyVisibleToStudents, isInternetConnectionAlertFailed, unitXBlockActions, + sharedClipboardData, + showPasteXBlock, + showPasteUnit, handleTitleEditSubmit, headerNavigationsActions, handleTitleEdit, handleInternetConnectionFailed, handleCreateNewCourseXBlock, courseVerticalChildren, + canPasteComponent, } = useCourseUnit({ courseId, blockId }); document.title = getPageHeadTitle('', unitTitle); @@ -98,6 +104,7 @@ const CourseUnit = ({ courseId }) => { sequenceId={sequenceId} unitId={blockId} handleCreateNewCourseXBlock={handleCreateNewCourseXBlock} + showPasteUnit={showPasteUnit} /> { icon={WarningIcon} /> )} + {staticFileNotices && ( + + )} {courseVerticalChildren.children.map(({ name, blockId: id, shouldScroll }) => ( { blockId={blockId} handleCreateNewCourseXBlock={handleCreateNewCourseXBlock} /> + {showPasteXBlock && canPasteComponent && ( + + )} diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss index 270691ecae..cb4cfe99ab 100644 --- a/src/course-unit/CourseUnit.scss +++ b/src/course-unit/CourseUnit.scss @@ -3,3 +3,4 @@ @import "./add-component/AddComponent"; @import "./course-xblock/CourseXBlock"; @import "./sidebar/Sidebar"; +@import "./clipboard/paste-component/PasteComponent"; diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index a4d4337bb5..ebf3d0cff4 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,23 +27,27 @@ 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 sidebarMessages from './sidebar/messages'; import { extractCourseUnitId } from './sidebar/utils'; -import CourseUnit from './CourseUnit'; -import messages from './messages'; - -import deleteModalMessages from '../generic/delete-modal/messages'; import courseXBlockMessages from './course-xblock/messages'; +import messages from './messages'; +import CourseUnit from './CourseUnit'; import addComponentMessages from './add-component/messages'; import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants'; @@ -53,12 +59,24 @@ 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 }), useNavigate: () => mockedUsedNavigate, })); +const clipboardBroadcastChannelMock = { + postMessage: jest.fn(), + close: jest.fn(), +}; + +global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); + const RootWrapper = () => ( @@ -77,7 +95,7 @@ describe('', () => { roles: [], }, }); - + global.localStorage.clear(); store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock @@ -278,7 +296,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 @@ -910,4 +928,404 @@ describe('', () => { .replace('{sectionName}', courseUnitIndexMock.release_date_from), )).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, getByRole, + } = 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(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(); + + 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(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(); + + 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(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(); + + 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(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(); + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, { + ...courseVerticalChildrenMock, + canPasteComponent: false, + }); + + await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); + + expect(queryByRole('button', { + name: pasteComponentMessages.pasteComponentButtonText.defaultMessage, + })).not.toBeInTheDocument(); + expect(queryByText( + pasteComponentMessages.pasteComponentWhatsInClipboardText.defaultMessage, + )).not.toBeInTheDocument(); + }); + }); }); 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__/clipboardUnit.js b/src/course-unit/__mocks__/clipboardUnit.js new file mode 100644 index 0000000000..d181c94ac6 --- /dev/null +++ b/src/course-unit/__mocks__/clipboardUnit.js @@ -0,0 +1,16 @@ +module.exports = { + content: { + id: 67, + userId: 3, + created: '2024-01-16T13:09:11.540615Z', + purpose: 'clipboard', + status: 'ready', + blockType: 'vertical', + blockTypeDisplay: 'Unit', + olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx', + displayName: 'Introduction: Video and Sequences', + }, + sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc', + sourceContextTitle: 'Demonstration Course', + sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc', +}; diff --git a/src/course-unit/__mocks__/clipboardXBlock.js b/src/course-unit/__mocks__/clipboardXBlock.js new file mode 100644 index 0000000000..621044e494 --- /dev/null +++ b/src/course-unit/__mocks__/clipboardXBlock.js @@ -0,0 +1,16 @@ +module.exports = { + content: { + id: 69, + userId: 3, + created: '2024-01-16T13:33:21.314439Z', + purpose: 'clipboard', + status: 'ready', + blockType: 'html', + blockTypeDisplay: 'Text', + olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/69/olx', + displayName: 'Blank HTML Page', + }, + sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@html+block@html1', + sourceContextTitle: 'Demonstration Course', + sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1', +}; diff --git a/src/course-unit/__mocks__/courseVerticalChildren.js b/src/course-unit/__mocks__/courseVerticalChildren.js index d7cc9bf611..e0ca29bf7f 100644 --- a/src/course-unit/__mocks__/courseVerticalChildren.js +++ b/src/course-unit/__mocks__/courseVerticalChildren.js @@ -12,4 +12,5 @@ module.exports = { }, ], is_published: false, + canPasteComponent: true, }; diff --git a/src/course-unit/__mocks__/index.js b/src/course-unit/__mocks__/index.js index d8c220b7a4..88072ae83e 100644 --- a/src/course-unit/__mocks__/index.js +++ b/src/course-unit/__mocks__/index.js @@ -3,3 +3,6 @@ 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 clipboardUnit } from './clipboardUnit'; +export { default as clipboardXBlock } from './clipboardXBlock'; +export { default as clipboardMockResponse } from './clipboardResponse'; diff --git a/src/course-unit/clipboard/hooks/useClipboard.jsx b/src/course-unit/clipboard/hooks/useClipboard.jsx new file mode 100644 index 0000000000..0d0c6a82de --- /dev/null +++ b/src/course-unit/clipboard/hooks/useClipboard.jsx @@ -0,0 +1,52 @@ +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'; + +const useCopyToClipboard = (canEdit) => { + 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) => { + const isPasteable = canEdit && data?.content && data.content.status !== CLIPBOARD_STATUS.expired; + const isPasteableXBlock = isPasteable && !NOT_XBLOCK_TYPES.includes(data.content.blockType); + const isPasteableUnit = isPasteable && data.content.blockType === 'vertical'; + + setShowPasteXBlock(!!isPasteableXBlock); + setShowPasteUnit(!!isPasteableUnit); + }; + + useEffect(() => { + // Handle updates to clipboard data + if (canEdit) { + refreshPasteButton(clipboardData); + setSharedClipboardData(clipboardData); + clipboardBroadcastChannel.postMessage(clipboardData); + } else { + setShowPasteXBlock(false); + setShowPasteUnit(false); + } + }, [clipboardData, canEdit, clipboardBroadcastChannel]); + + useEffect(() => { + // Handle messages from the broadcast channel + clipboardBroadcastChannel.onmessage = (event) => { + setSharedClipboardData(event.data); + refreshPasteButton(event.data); + }; + + // Cleanup function for the BroadcastChannel when the hook is unmounted + return () => { + clipboardBroadcastChannel.close(); + }; + }, [clipboardBroadcastChannel]); + + return { showPasteUnit, showPasteXBlock, sharedClipboardData }; +}; + +export default useCopyToClipboard; diff --git a/src/course-unit/clipboard/hooks/useClipboard.test.jsx b/src/course-unit/clipboard/hooks/useClipboard.test.jsx new file mode 100644 index 0000000000..049cd52477 --- /dev/null +++ b/src/course-unit/clipboard/hooks/useClipboard.test.jsx @@ -0,0 +1,121 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { Provider } from 'react-redux'; +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import initializeStore from '../../../store'; +import { executeThunk } from '../../../utils'; +import { copyToClipboard } from '../../data/thunk'; +import { getClipboardUrl } from '../../data/api'; +import { clipboardUnit, clipboardXBlock } from '../../__mocks__'; +import useClipboard from './useClipboard'; + +let axiosMock; +let store; +const unitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc'; +const xblockId = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4'; +const clipboardBroadcastChannelMock = { + postMessage: jest.fn(), + close: jest.fn(), +}; +global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); + +const wrapper = ({ children }) => ( + + + {children} + + +); + +describe('useCopyToClipboard', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + it('initializes correctly', () => { + const { result } = renderHook(() => useClipboard(true), { wrapper }); + + expect(result.current.showPasteUnit).toBe(false); + expect(result.current.showPasteXBlock).toBe(false); + }); + + describe('clipboard data update effect', () => { + it('returns falsy flags if canEdit = false', async () => { + const { result } = renderHook(() => useClipboard(false), { wrapper }); + + axiosMock + .onPost(getClipboardUrl()) + .reply(200, clipboardUnit); + axiosMock + .onGet(getClipboardUrl()) + .reply(200, clipboardUnit); + + await act(async () => { + await executeThunk(copyToClipboard(unitId), store.dispatch); + }); + expect(result.current.showPasteUnit).toBe(false); + expect(result.current.showPasteXBlock).toBe(false); + }); + + it('returns flag to display the Paste Unit button', async () => { + const { result } = renderHook(() => useClipboard(true), { wrapper }); + + axiosMock + .onPost(getClipboardUrl()) + .reply(200, clipboardUnit); + axiosMock + .onGet(getClipboardUrl()) + .reply(200, clipboardUnit); + + await act(async () => { + await executeThunk(copyToClipboard(unitId), store.dispatch); + }); + expect(result.current.showPasteUnit).toBe(true); + expect(result.current.showPasteXBlock).toBe(false); + }); + + it('returns flag to display the Paste XBlock button', async () => { + const { result } = renderHook(() => useClipboard(true), { wrapper }); + + axiosMock + .onPost(getClipboardUrl()) + .reply(200, clipboardXBlock); + axiosMock + .onGet(getClipboardUrl()) + .reply(200, clipboardXBlock); + + await act(async () => { + await executeThunk(copyToClipboard(xblockId), store.dispatch); + }); + expect(result.current.showPasteUnit).toBe(false); + expect(result.current.showPasteXBlock).toBe(true); + }); + }); + + describe('broadcast channel message handling', () => { + it('updates states correctly on receiving a broadcast message', async () => { + const { result } = renderHook(() => useClipboard(true), { wrapper }); + clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit }); + + expect(result.current.showPasteUnit).toBe(true); + expect(result.current.showPasteXBlock).toBe(false); + + clipboardBroadcastChannelMock.onmessage({ data: clipboardXBlock }); + expect(result.current.showPasteUnit).toBe(false); + expect(result.current.showPasteXBlock).toBe(true); + }); + }); +}); 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..197c09904d --- /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 '@openedx/paragon'; +import { ContentCopy as ContentCopyIcon } from '@openedx/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..70193c3eae --- /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 '@openedx/paragon'; +import { OpenInNew as OpenInNewIcon } from '@openedx/paragon/icons'; + +import messages from '../messages'; +import { clipboardPropsTypes } from '../constants'; + +const PopoverContent = ({ clipboardData }) => { + const intl = useIntl(); + const { sourceEditUrl, content, sourceContextTitle } = clipboardData; + + return ( + + + + {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..939dcfa2d5 --- /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 '@openedx/paragon'; +import { Question as QuestionIcon } from '@openedx/paragon/icons'; + +import messages from '../messages'; + +const WhatsInClipboard = ({ + handlePopoverToggle, togglePopover, popoverElementRef, +}) => { + const intl = useIntl(); + const triggerElementRef = useRef(null); + + const handleKeyDown = ({ key }) => { + if (key === 'Tab') { + popoverElementRef.current.focus(); + handlePopoverToggle(true); + } + }; + + return ( +
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..ab140bf383 --- /dev/null +++ b/src/course-unit/clipboard/paste-component/index.jsx @@ -0,0 +1,61 @@ +import { useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { OverlayTrigger, Popover } from '@openedx/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} + > + {clipboardData && ( + + )} + +
+ ); + + 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..bc9dcc6daf --- /dev/null +++ b/src/course-unit/clipboard/paste-notification/components/ActionButton.jsx @@ -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 }) => ( + +); + +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..260acdd20a --- /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 '@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 && ( + 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/constants.js b/src/course-unit/constants.js index 1351689a36..1a5d42c993 100644 --- a/src/course-unit/constants.js +++ b/src/course-unit/constants.js @@ -16,6 +16,22 @@ import messages from './sidebar/messages'; export const UNIT_ICON_TYPES = ['video', 'other', 'vertical', 'problem', 'lock']; +export const NOT_XBLOCK_TYPES = ['vertical', 'sequential', 'chapter', 'course']; + +export const STUDIO_CLIPBOARD_CHANNEL = 'studio_clipboard_channel'; + +/** + * Enum for clipboard status. + * @readonly + * @enum {string} + */ +export const CLIPBOARD_STATUS = { + loading: 'loading', + ready: 'ready', + expired: 'expired', + error: 'error', +}; + export const COMPONENT_ICON_TYPES = { advanced: 'advanced', discussion: 'discussion', diff --git a/src/course-unit/course-sequence/CourseSequence.scss b/src/course-unit/course-sequence/CourseSequence.scss index 21bf490d15..9a0a34004c 100644 --- a/src/course-unit/course-sequence/CourseSequence.scss +++ b/src/course-unit/course-sequence/CourseSequence.scss @@ -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; } diff --git a/src/course-unit/course-sequence/Sequence.jsx b/src/course-unit/course-sequence/Sequence.jsx index 7729bba5a6..f8a7ea007f 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 a2fbe55d4e..0fa15fa29e 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} /> ); }; @@ -110,6 +112,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 e601ce2f3b..2e2923d272 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 '@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)} + {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 370488ce06..f83686fe2d 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx @@ -1,7 +1,7 @@ import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; 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 { 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 (
@@ -49,20 +59,32 @@ const SequenceNavigationTabs = ({ unitIds, unitId, handleCreateNewCourseXBlock } /> ))} + {showPasteUnit && ( + + )}
{shouldDisplayDropdown && ( )} @@ -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; diff --git a/src/course-unit/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx index bd8a11ebfc..d09075fd52 100644 --- a/src/course-unit/course-xblock/CourseXBlock.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.jsx @@ -5,9 +5,13 @@ import { } from '@openedx/paragon'; import { EditOutline as EditIcon, MoreVert as MoveVertIcon } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { useDispatch, useSelector } from 'react-redux'; +import { getCanEdit } from 'CourseAuthoring/course-unit/data/selectors'; import DeleteModal from '../../generic/delete-modal/DeleteModal'; import { scrollToElement } from '../../course-outline/utils'; +import { copyToClipboard } from '../data/thunk'; + import messages from './messages'; const CourseXBlock = ({ @@ -15,6 +19,8 @@ const CourseXBlock = ({ }) => { const courseXBlockElementRef = useRef(null); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + const dispatch = useDispatch(); + const canEdit = useSelector(getCanEdit); const intl = useIntl(); const onXBlockDelete = () => { @@ -52,15 +58,17 @@ const CourseXBlock = ({ iconAs={Icon} /> - - {intl.formatMessage(messages.blockLabelButtonCopy)} - unitXBlockActions.handleDuplicate(id)}> {intl.formatMessage(messages.blockLabelButtonDuplicate)} {intl.formatMessage(messages.blockLabelButtonMove)} + {canEdit && ( + dispatch(copyToClipboard(id))}> + {intl.formatMessage(messages.blockLabelButtonCopyToClipboard)} + + )} {intl.formatMessage(messages.blockLabelButtonManageAccess)} diff --git a/src/course-unit/course-xblock/CourseXBlock.test.jsx b/src/course-unit/course-xblock/CourseXBlock.test.jsx index 2c6defc766..ade96b4fe6 100644 --- a/src/course-unit/course-xblock/CourseXBlock.test.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.test.jsx @@ -3,14 +3,21 @@ import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { initializeMockApp } from '@edx/frontend-platform'; import { AppProvider } from '@edx/frontend-platform/react'; - -import { courseVerticalChildrenMock } from '../__mocks__'; -import CourseXBlock from './CourseXBlock'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import deleteModalMessages from '../../generic/delete-modal/messages'; +import initializeStore from '../../store'; +import { getCourseSectionVerticalApiUrl } from '../data/api'; +import { fetchCourseSectionVerticalData } from '../data/thunk'; +import { executeThunk } from '../../utils'; +import { courseSectionVerticalMock, courseVerticalChildrenMock } from '../__mocks__'; +import CourseXBlock from './CourseXBlock'; import messages from './messages'; +let axiosMock; let store; +const blockId = '567890'; const handleDeleteMock = jest.fn(); const handleDuplicateMock = jest.fn(); const xblockData = courseVerticalChildrenMock.children[0]; @@ -42,6 +49,13 @@ describe('', () => { roles: [], }, }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, courseSectionVerticalMock); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); }); it('render CourseXBlock component correctly', async () => { @@ -59,7 +73,6 @@ describe('', () => { await waitFor(() => { userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); - expect(getByRole('button', { name: messages.blockLabelButtonCopy.defaultMessage })).toBeInTheDocument(); expect(getByRole('button', { name: messages.blockLabelButtonDuplicate.defaultMessage })).toBeInTheDocument(); expect(getByRole('button', { name: messages.blockLabelButtonMove.defaultMessage })).toBeInTheDocument(); expect(getByRole('button', { name: messages.blockLabelButtonManageAccess.defaultMessage })).toBeInTheDocument(); diff --git a/src/course-unit/course-xblock/messages.js b/src/course-unit/course-xblock/messages.js index e4b6365424..fc75b645b2 100644 --- a/src/course-unit/course-xblock/messages.js +++ b/src/course-unit/course-xblock/messages.js @@ -9,10 +9,6 @@ const messages = defineMessages({ id: 'course-authoring.course-unit.xblock.button.actions.alt', defaultMessage: 'Actions', }, - blockLabelButtonCopy: { - id: 'course-authoring.course-unit.xblock.button.copy.label', - defaultMessage: 'Copy', - }, blockLabelButtonDuplicate: { id: 'course-authoring.course-unit.xblock.button.duplicate.label', defaultMessage: 'Duplicate', @@ -21,6 +17,10 @@ const messages = defineMessages({ id: 'course-authoring.course-unit.xblock.button.move.label', defaultMessage: 'Move', }, + 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', diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index 6520d1e1de..b270788854 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -11,6 +11,7 @@ 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 getClipboardUrl = () => `${getStudioBaseUrl()}/api/content-staging/v1/clipboard/`; export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`; @@ -60,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, @@ -74,6 +75,7 @@ export async function createCourseXblock({ category: category || type, parent_locator: parentLocator, display_name: displayName, + staged_content: stagedContent, }; const { data } = await getAuthenticatedHttpClient() @@ -82,6 +84,29 @@ export async function createCourseXblock({ return data; } +/** + * Retrieves user's clipboard. + * @returns {Promise} - A Promise that resolves clipboard data. + */ +export async function getClipboard() { + const { data } = await getAuthenticatedHttpClient() + .get(getClipboardUrl()); + + return camelCaseObject(data); +} + +/** + * Updates user's clipboard. + * @param {string} usageKey - The ID of the block. + * @returns {Promise} - A Promise that resolves clipboard data. + */ +export async function updateClipboard(usageKey) { + const { data } = await getAuthenticatedHttpClient() + .post(getClipboardUrl(), { usage_key: usageKey }); + + return camelCaseObject(data); +} + /** * Handles the visibility and data of a course unit, such as publishing, resetting to default values, * and toggling visibility to students. diff --git a/src/course-unit/data/selectors.js b/src/course-unit/data/selectors.js index 16619548ff..19d1a2c1b2 100644 --- a/src/course-unit/data/selectors.js +++ b/src/course-unit/data/selectors.js @@ -1,4 +1,7 @@ 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 +10,4 @@ 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; +export const getClipboardData = state => state.courseUnit.clipboardData; diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index 0134fcb054..f0f5fd34cc 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -9,6 +9,7 @@ const slice = createSlice({ savingStatus: '', isQueryPending: false, isTitleEditFormOpen: false, + canEdit: true, loadingStatus: { fetchUnitLoadingStatus: RequestStatus.IN_PROGRESS, courseSectionVerticalLoadingStatus: RequestStatus.IN_PROGRESS, @@ -17,6 +18,8 @@ const slice = createSlice({ unit: {}, courseSectionVertical: {}, courseVerticalChildren: [], + clipboardData: null, + staticFileNotices: {}, }, reducers: { fetchCourseItemSuccess: (state, { payload }) => { @@ -95,6 +98,12 @@ const slice = createSlice({ }), }; }, + updateClipboardData: (state, { payload }) => { + state.clipboardData = payload; + }, + fetchStaticFileNoticesSuccess: (state, { payload }) => { + state.staticFileNotices = payload; + }, }, }); @@ -115,6 +124,8 @@ export const { updateCourseVerticalChildrenLoadingStatus, 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 e18a0cc6d6..f3f39b0c80 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -1,4 +1,5 @@ import { camelCaseObject } from '@edx/frontend-platform'; +import { logError } from '@edx/frontend-platform/logging'; import { hideProcessingNotification, @@ -7,12 +8,15 @@ 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, getCourseSectionVerticalData, createCourseXblock, getCourseVerticalChildren, + updateClipboard, + getClipboard, handleCourseUnitVisibilityAndData, deleteUnitItem, duplicateUnitItem, @@ -29,9 +33,11 @@ import { updateLoadingCourseXblockStatus, updateCourseVerticalChildren, updateCourseVerticalChildrenLoadingStatus, + updateClipboardData, updateQueryPendingStatus, deleteXBlock, duplicateXBlock, + fetchStaticFileNoticesSuccess, } from './slice'; import { getNotificationMessage } from './utils'; @@ -68,6 +74,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 +148,14 @@ export function editCourseUnitVisibilityAndData(itemId, type, isVisible) { 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 +164,9 @@ 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)); + } const courseVerticalChildrenData = await getCourseVerticalChildren(blockId); dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); dispatch(hideProcessingNotification()); @@ -162,8 +179,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 +210,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()); @@ -228,3 +245,35 @@ export function duplicateUnitItemQuery(itemId, xblockId) { } }; } + +export function copyToClipboard(usageKey) { + const POLL_INTERVAL_MS = 1000; // Timeout duration for polling in milliseconds + + return async (dispatch) => { + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying)); + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(updateQueryPendingStatus(true)); + + try { + let clipboardData = await updateClipboard(usageKey); + + while (clipboardData.content?.status === CLIPBOARD_STATUS.loading) { + // eslint-disable-next-line no-await-in-loop,no-promise-executor-return + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + clipboardData = await getClipboard(); // eslint-disable-line no-await-in-loop + } + + if (clipboardData.content?.status === CLIPBOARD_STATUS.ready) { + dispatch(updateClipboardData(clipboardData)); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + } else { + throw new Error(`Unexpected clipboard status "${clipboardData.content?.status}" in successful API response.`); + } + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + logError('Error copying to clipboard:', error); + } finally { + dispatch(hideProcessingNotification()); + } + }; +} diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index d1d1edc7bc..13f54b70f3 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -19,9 +19,13 @@ import { getLoadingStatus, getSavingStatus, getSequenceStatus, + getStaticFileNotices, + getCanEdit, } from './data/selectors'; import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice'; +import { useCopyToClipboard } from './clipboard'; + // eslint-disable-next-line import/prefer-default-export export const useCourseUnit = ({ courseId, blockId }) => { const dispatch = useDispatch(); @@ -34,10 +38,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; @@ -110,11 +118,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, @@ -122,5 +134,6 @@ export const useCourseUnit = ({ courseId, blockId }) => { handleTitleEditSubmit, handleCreateNewCourseXBlock, courseVerticalChildren, + canPasteComponent, }; }; diff --git a/src/course-unit/sidebar/Sidebar.scss b/src/course-unit/sidebar/Sidebar.scss index 954e20d4b2..6f7ec0a24b 100644 --- a/src/course-unit/sidebar/Sidebar.scss +++ b/src/course-unit/sidebar/Sidebar.scss @@ -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 { diff --git a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx index ac0a63287d..a3e7e03afa 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx @@ -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 '../../../data/thunk'; 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 }) => { )} {(published && hasChanges) && ( - )} - {enableCopyPasteUnits && ( - + {enableCopyPasteUnits && canEdit && ( + <> + + + )} ); diff --git a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx new file mode 100644 index 0000000000..a91aba196b --- /dev/null +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx @@ -0,0 +1,77 @@ +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 { getClipboardUrl, getCourseUnitApiUrl } from '../../../data/api'; +import { copyToClipboard, fetchCourseUnitQuery } from '../../../data/thunk'; +import { clipboardUnit, courseUnitIndexMock } from '../../../__mocks__'; +import messages from '../../messages'; +import ActionButtons from './ActionButtons'; + +jest.mock('../../../data/thunk', () => ({ + ...jest.requireActual('../../../data/thunk'), + copyToClipboard: jest.fn().mockImplementation(() => () => {}), +})); + +let store; +let axiosMock; +const courseId = '123'; + +const renderComponent = (props = {}) => render( + + + + + , +); + +describe('', () => { + 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(); + }); +}); diff --git a/src/generic/divider/Divider.jsx b/src/generic/divider/Divider.jsx new file mode 100644 index 0000000000..6b75eff3df --- /dev/null +++ b/src/generic/divider/Divider.jsx @@ -0,0 +1,16 @@ +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +const Divider = ({ className, ...props }) => ( +
+); + +Divider.propTypes = { + className: PropTypes.string, +}; + +Divider.defaultProps = { + className: undefined, +}; + +export default Divider; diff --git a/src/generic/divider/Divider.scss b/src/generic/divider/Divider.scss new file mode 100644 index 0000000000..b78206689d --- /dev/null +++ b/src/generic/divider/Divider.scss @@ -0,0 +1,5 @@ +.divider { + border-top: $border-width solid $light-400; + height: 0; + margin: $spacer map-get($spacers, 0); +} diff --git a/src/generic/divider/index.jsx b/src/generic/divider/index.jsx new file mode 100644 index 0000000000..ca4fc16364 --- /dev/null +++ b/src/generic/divider/index.jsx @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as Divider } from './Divider'; diff --git a/src/generic/styles.scss b/src/generic/styles.scss index becfb9a77a..25908ed155 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -6,3 +6,4 @@ @import "./create-or-rerun-course/CreateOrRerunCourseForm"; @import "./WysiwygEditor"; @import "./course-stepper/CouseStepper"; +@import "./divider/Divider"; diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 5ddf05c815..752a339d46 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1040,9 +1040,9 @@ "course-authoring.course-unit.status.released.title": "RELEASED", "course-authoring.course-unit.status.scheduled.title": "SCHEDULED", "course-authoring.course-unit.xblock.button.edit.alt": "Edit Item", - "course-authoring.course-unit.xblock.button.copy.label": "Copy", "course-authoring.course-unit.xblock.button.duplicate.label": "Duplicate", "course-authoring.course-unit.xblock.button.move.label": "Move", + "course-authoring.course-unit.xblock.button.copyToClipboard.label": "Copy to clipboard", "course-authoring.course-unit.xblock.button.manageAccess.label": "Manage access", "course-authoring.course-unit.xblock.button.delete.label": "Delete", "course-authoring.group-configurations.heading-title": "Group configurations", @@ -1076,5 +1076,16 @@ "course-authoring.course-unit.modal.make-visibility.btn.action.text": "Make visible to students", "course-authoring.course-unit.modal.make-visibility.btn.cancel.text": "Cancel", "course-authoring.course-unit.modal.make-visibility.description": "If the unit was previously published and released to students, any changes you made to the unit when it was hidden will now be visible to students. Do you want to proceed?", - "course-authoring.course-unit.xblock.button.actions.alt": "Actions" + "course-authoring.course-unit.xblock.button.actions.alt": "Actions", + "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 ad312d6647..6bb3679e37 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1041,9 +1041,9 @@ "course-authoring.course-unit.status.released.title": "RELEASED", "course-authoring.course-unit.status.scheduled.title": "SCHEDULED", "course-authoring.course-unit.xblock.button.edit.alt": "Edit Item", - "course-authoring.course-unit.xblock.button.copy.label": "Copy", "course-authoring.course-unit.xblock.button.duplicate.label": "Duplicate", "course-authoring.course-unit.xblock.button.move.label": "Move", + "course-authoring.course-unit.xblock.button.copyToClipboard.label": "Copy to clipboard", "course-authoring.course-unit.xblock.button.manageAccess.label": "Manage access", "course-authoring.course-unit.xblock.button.delete.label": "Delete", "course-authoring.course-unit.xblock.button.actions.alt": "Actions", @@ -1077,5 +1077,33 @@ "course-authoring.group-configurations.empty-placeholder.button": "Add your first content group", "course-authoring.group-configurations.experimental-empty-placeholder.button": "Add your first group configuration", "course-authoring.group-configurations.content-groups.add-new-group": "New content group", - "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience." + "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience.", + "course-authoring.textbooks.header.title": "Textbooks", + "course-authoring.textbooks.header.breadcrumb.content": "Content", + "course-authoring.textbooks.header.breadcrumb.pages-and-resources": "Pages & resources", + "course-authoring.textbooks.header.breadcrumb.aria-label": "Textbook breadcrumb", + "course-authoring.textbooks.header.new-textbook": "New textbook", + "course-authoring.textbooks.empty-placeholder.title": "You haven't added any textbooks to this course yet.", + "course-authoring.textbooks.empty-placeholder.button.new-textbook": "New textbook", + "course-authoring.textbooks.chapters.title": "{count} PDF chapters", + "course-authoring.textbooks.button.view": "View the PDF live", + "course-authoring.textbooks.button.edit": "Edit", + "course-authoring.textbooks.button.delete": "Delete", + "course-authoring.textbooks.sidebar.section-1.title": "Why should I break my textbook into chapters?", + "course-authoring.textbooks.sidebar.section-1.descriptions": "Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.", + "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.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 ad043c3f7c..31d5d35ce6 100644 --- a/src/i18n/messages/de_DE.json +++ b/src/i18n/messages/de_DE.json @@ -1041,9 +1041,9 @@ "course-authoring.course-unit.status.released.title": "RELEASED", "course-authoring.course-unit.status.scheduled.title": "SCHEDULED", "course-authoring.course-unit.xblock.button.edit.alt": "Edit Item", - "course-authoring.course-unit.xblock.button.copy.label": "Copy", "course-authoring.course-unit.xblock.button.duplicate.label": "Duplicate", "course-authoring.course-unit.xblock.button.move.label": "Move", + "course-authoring.course-unit.xblock.button.copyToClipboard.label": "Copy to clipboard", "course-authoring.course-unit.xblock.button.manageAccess.label": "Manage access", "course-authoring.course-unit.xblock.button.delete.label": "Delete", "course-authoring.course-unit.xblock.button.actions.alt": "Actions", @@ -1077,5 +1077,33 @@ "course-authoring.course-unit.modal.make-visibility.title": "Make visible to students", "course-authoring.course-unit.modal.make-visibility.btn.action.text": "Make visible to students", "course-authoring.course-unit.modal.make-visibility.btn.cancel.text": "Cancel", - "course-authoring.course-unit.modal.make-visibility.description": "If the unit was previously published and released to students, any changes you made to the unit when it was hidden will now be visible to students. Do you want to proceed?" + "course-authoring.course-unit.modal.make-visibility.description": "If the unit was previously published and released to students, any changes you made to the unit when it was hidden will now be visible to students. Do you want to proceed?", + "course-authoring.textbooks.header.title": "Textbooks", + "course-authoring.textbooks.header.breadcrumb.content": "Content", + "course-authoring.textbooks.header.breadcrumb.pages-and-resources": "Pages & resources", + "course-authoring.textbooks.header.breadcrumb.aria-label": "Textbook breadcrumb", + "course-authoring.textbooks.header.new-textbook": "New textbook", + "course-authoring.textbooks.empty-placeholder.title": "You haven't added any textbooks to this course yet.", + "course-authoring.textbooks.empty-placeholder.button.new-textbook": "New textbook", + "course-authoring.textbooks.chapters.title": "{count} PDF chapters", + "course-authoring.textbooks.button.view": "View the PDF live", + "course-authoring.textbooks.button.edit": "Edit", + "course-authoring.textbooks.button.delete": "Delete", + "course-authoring.textbooks.sidebar.section-1.title": "Why should I break my textbook into chapters?", + "course-authoring.textbooks.sidebar.section-1.descriptions": "Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.", + "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.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 8ecd721182..eddf2b676b 100644 --- a/src/i18n/messages/es_419.json +++ b/src/i18n/messages/es_419.json @@ -1041,9 +1041,9 @@ "course-authoring.course-unit.status.released.title": "RELEASED", "course-authoring.course-unit.status.scheduled.title": "SCHEDULED", "course-authoring.course-unit.xblock.button.edit.alt": "Edit Item", - "course-authoring.course-unit.xblock.button.copy.label": "Copy", "course-authoring.course-unit.xblock.button.duplicate.label": "Duplicate", "course-authoring.course-unit.xblock.button.move.label": "Move", + "course-authoring.course-unit.xblock.button.copyToClipboard.label": "Copy to clipboard", "course-authoring.course-unit.xblock.button.manageAccess.label": "Manage access", "course-authoring.course-unit.xblock.button.delete.label": "Delete", "course-authoring.course-unit.xblock.button.actions.alt": "Actions", @@ -1077,5 +1077,33 @@ "course-authoring.group-configurations.empty-placeholder.button": "Add your first content group", "course-authoring.group-configurations.experimental-empty-placeholder.button": "Add your first group configuration", "course-authoring.group-configurations.content-groups.add-new-group": "New content group", - "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience." + "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience.", + "course-authoring.textbooks.header.title": "Textbooks", + "course-authoring.textbooks.header.breadcrumb.content": "Content", + "course-authoring.textbooks.header.breadcrumb.pages-and-resources": "Pages & resources", + "course-authoring.textbooks.header.breadcrumb.aria-label": "Textbook breadcrumb", + "course-authoring.textbooks.header.new-textbook": "New textbook", + "course-authoring.textbooks.empty-placeholder.title": "You haven't added any textbooks to this course yet.", + "course-authoring.textbooks.empty-placeholder.button.new-textbook": "New textbook", + "course-authoring.textbooks.chapters.title": "{count} PDF chapters", + "course-authoring.textbooks.button.view": "View the PDF live", + "course-authoring.textbooks.button.edit": "Edit", + "course-authoring.textbooks.button.delete": "Delete", + "course-authoring.textbooks.sidebar.section-1.title": "Why should I break my textbook into chapters?", + "course-authoring.textbooks.sidebar.section-1.descriptions": "Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.", + "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.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 a9d33d6636..2cca343e31 100644 --- a/src/i18n/messages/fa_IR.json +++ b/src/i18n/messages/fa_IR.json @@ -64,9 +64,8 @@ "course-authoring.course-unit.status.released.title": "RELEASED", "course-authoring.course-unit.status.scheduled.title": "SCHEDULED", "course-authoring.course-unit.xblock.button.edit.alt": "Edit Item", - "course-authoring.course-unit.xblock.button.copy.label": "Copy", "course-authoring.course-unit.xblock.button.duplicate.label": "Duplicate", - "course-authoring.course-unit.xblock.button.move.label": "Move", + "course-authoring.course-unit.xblock.button.copyToClipboard.label": "Copy to clipboard", "course-authoring.course-unit.xblock.button.manageAccess.label": "Manage access", "course-authoring.course-unit.xblock.button.delete.label": "Delete", "course-authoring.course-unit.xblock.button.actions.alt": "Actions", @@ -100,5 +99,33 @@ "course-authoring.group-configurations.empty-placeholder.button": "Add your first content group", "course-authoring.group-configurations.experimental-empty-placeholder.button": "Add your first group configuration", "course-authoring.group-configurations.content-groups.add-new-group": "New content group", - "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience." + "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience.", + "course-authoring.textbooks.header.title": "Textbooks", + "course-authoring.textbooks.header.breadcrumb.content": "Content", + "course-authoring.textbooks.header.breadcrumb.pages-and-resources": "Pages & resources", + "course-authoring.textbooks.header.breadcrumb.aria-label": "Textbook breadcrumb", + "course-authoring.textbooks.header.new-textbook": "New textbook", + "course-authoring.textbooks.empty-placeholder.title": "You haven't added any textbooks to this course yet.", + "course-authoring.textbooks.empty-placeholder.button.new-textbook": "New textbook", + "course-authoring.textbooks.chapters.title": "{count} PDF chapters", + "course-authoring.textbooks.button.view": "View the PDF live", + "course-authoring.textbooks.button.edit": "Edit", + "course-authoring.textbooks.button.delete": "Delete", + "course-authoring.textbooks.sidebar.section-1.title": "Why should I break my textbook into chapters?", + "course-authoring.textbooks.sidebar.section-1.descriptions": "Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.", + "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.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 6287473708..1474b7078e 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1041,9 +1041,9 @@ "course-authoring.course-unit.status.released.title": "RELEASED", "course-authoring.course-unit.status.scheduled.title": "SCHEDULED", "course-authoring.course-unit.xblock.button.edit.alt": "Edit Item", - "course-authoring.course-unit.xblock.button.copy.label": "Copy", "course-authoring.course-unit.xblock.button.duplicate.label": "Duplicate", "course-authoring.course-unit.xblock.button.move.label": "Move", + "course-authoring.course-unit.xblock.button.copyToClipboard.label": "Copy to clipboard", "course-authoring.course-unit.xblock.button.manageAccess.label": "Manage access", "course-authoring.course-unit.xblock.button.delete.label": "Delete", "course-authoring.course-unit.xblock.button.actions.alt": "Actions", @@ -1077,5 +1077,33 @@ "course-authoring.group-configurations.empty-placeholder.button": "Add your first content group", "course-authoring.group-configurations.experimental-empty-placeholder.button": "Add your first group configuration", "course-authoring.group-configurations.content-groups.add-new-group": "New content group", - "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience." + "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience.", + "course-authoring.textbooks.header.title": "Textbooks", + "course-authoring.textbooks.header.breadcrumb.content": "Content", + "course-authoring.textbooks.header.breadcrumb.pages-and-resources": "Pages & resources", + "course-authoring.textbooks.header.breadcrumb.aria-label": "Textbook breadcrumb", + "course-authoring.textbooks.header.new-textbook": "New textbook", + "course-authoring.textbooks.empty-placeholder.title": "You haven't added any textbooks to this course yet.", + "course-authoring.textbooks.empty-placeholder.button.new-textbook": "New textbook", + "course-authoring.textbooks.chapters.title": "{count} PDF chapters", + "course-authoring.textbooks.button.view": "View the PDF live", + "course-authoring.textbooks.button.edit": "Edit", + "course-authoring.textbooks.button.delete": "Delete", + "course-authoring.textbooks.sidebar.section-1.title": "Why should I break my textbook into chapters?", + "course-authoring.textbooks.sidebar.section-1.descriptions": "Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.", + "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.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 071a2b2934..3640ce00a0 100644 --- a/src/i18n/messages/fr_CA.json +++ b/src/i18n/messages/fr_CA.json @@ -1041,9 +1041,9 @@ "course-authoring.course-unit.status.released.title": "RELEASED", "course-authoring.course-unit.status.scheduled.title": "SCHEDULED", "course-authoring.course-unit.xblock.button.edit.alt": "Edit Item", - "course-authoring.course-unit.xblock.button.copy.label": "Copy", "course-authoring.course-unit.xblock.button.duplicate.label": "Duplicate", "course-authoring.course-unit.xblock.button.move.label": "Move", + "course-authoring.course-unit.xblock.button.copyToClipboard.label": "Copy to clipboard", "course-authoring.course-unit.xblock.button.manageAccess.label": "Manage access", "course-authoring.course-unit.xblock.button.delete.label": "Delete", "course-authoring.course-unit.xblock.button.actions.alt": "Actions", @@ -1077,5 +1077,33 @@ "course-authoring.group-configurations.empty-placeholder.button": "Add your first content group", "course-authoring.group-configurations.experimental-empty-placeholder.button": "Add your first group configuration", "course-authoring.group-configurations.content-groups.add-new-group": "New content group", - "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience." + "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience.", + "course-authoring.textbooks.header.title": "Textbooks", + "course-authoring.textbooks.header.breadcrumb.content": "Content", + "course-authoring.textbooks.header.breadcrumb.pages-and-resources": "Pages & resources", + "course-authoring.textbooks.header.breadcrumb.aria-label": "Textbook breadcrumb", + "course-authoring.textbooks.header.new-textbook": "New textbook", + "course-authoring.textbooks.empty-placeholder.title": "You haven't added any textbooks to this course yet.", + "course-authoring.textbooks.empty-placeholder.button.new-textbook": "New textbook", + "course-authoring.textbooks.chapters.title": "{count} PDF chapters", + "course-authoring.textbooks.button.view": "View the PDF live", + "course-authoring.textbooks.button.edit": "Edit", + "course-authoring.textbooks.button.delete": "Delete", + "course-authoring.textbooks.sidebar.section-1.title": "Why should I break my textbook into chapters?", + "course-authoring.textbooks.sidebar.section-1.descriptions": "Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.", + "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.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 ad312d6647..6bb3679e37 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -1041,9 +1041,9 @@ "course-authoring.course-unit.status.released.title": "RELEASED", "course-authoring.course-unit.status.scheduled.title": "SCHEDULED", "course-authoring.course-unit.xblock.button.edit.alt": "Edit Item", - "course-authoring.course-unit.xblock.button.copy.label": "Copy", "course-authoring.course-unit.xblock.button.duplicate.label": "Duplicate", "course-authoring.course-unit.xblock.button.move.label": "Move", + "course-authoring.course-unit.xblock.button.copyToClipboard.label": "Copy to clipboard", "course-authoring.course-unit.xblock.button.manageAccess.label": "Manage access", "course-authoring.course-unit.xblock.button.delete.label": "Delete", "course-authoring.course-unit.xblock.button.actions.alt": "Actions", @@ -1077,5 +1077,33 @@ "course-authoring.group-configurations.empty-placeholder.button": "Add your first content group", "course-authoring.group-configurations.experimental-empty-placeholder.button": "Add your first group configuration", "course-authoring.group-configurations.content-groups.add-new-group": "New content group", - "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience." + "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience.", + "course-authoring.textbooks.header.title": "Textbooks", + "course-authoring.textbooks.header.breadcrumb.content": "Content", + "course-authoring.textbooks.header.breadcrumb.pages-and-resources": "Pages & resources", + "course-authoring.textbooks.header.breadcrumb.aria-label": "Textbook breadcrumb", + "course-authoring.textbooks.header.new-textbook": "New textbook", + "course-authoring.textbooks.empty-placeholder.title": "You haven't added any textbooks to this course yet.", + "course-authoring.textbooks.empty-placeholder.button.new-textbook": "New textbook", + "course-authoring.textbooks.chapters.title": "{count} PDF chapters", + "course-authoring.textbooks.button.view": "View the PDF live", + "course-authoring.textbooks.button.edit": "Edit", + "course-authoring.textbooks.button.delete": "Delete", + "course-authoring.textbooks.sidebar.section-1.title": "Why should I break my textbook into chapters?", + "course-authoring.textbooks.sidebar.section-1.descriptions": "Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.", + "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.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 ad312d6647..6bb3679e37 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -1041,9 +1041,9 @@ "course-authoring.course-unit.status.released.title": "RELEASED", "course-authoring.course-unit.status.scheduled.title": "SCHEDULED", "course-authoring.course-unit.xblock.button.edit.alt": "Edit Item", - "course-authoring.course-unit.xblock.button.copy.label": "Copy", "course-authoring.course-unit.xblock.button.duplicate.label": "Duplicate", "course-authoring.course-unit.xblock.button.move.label": "Move", + "course-authoring.course-unit.xblock.button.copyToClipboard.label": "Copy to clipboard", "course-authoring.course-unit.xblock.button.manageAccess.label": "Manage access", "course-authoring.course-unit.xblock.button.delete.label": "Delete", "course-authoring.course-unit.xblock.button.actions.alt": "Actions", @@ -1077,5 +1077,33 @@ "course-authoring.group-configurations.empty-placeholder.button": "Add your first content group", "course-authoring.group-configurations.experimental-empty-placeholder.button": "Add your first group configuration", "course-authoring.group-configurations.content-groups.add-new-group": "New content group", - "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience." + "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience.", + "course-authoring.textbooks.header.title": "Textbooks", + "course-authoring.textbooks.header.breadcrumb.content": "Content", + "course-authoring.textbooks.header.breadcrumb.pages-and-resources": "Pages & resources", + "course-authoring.textbooks.header.breadcrumb.aria-label": "Textbook breadcrumb", + "course-authoring.textbooks.header.new-textbook": "New textbook", + "course-authoring.textbooks.empty-placeholder.title": "You haven't added any textbooks to this course yet.", + "course-authoring.textbooks.empty-placeholder.button.new-textbook": "New textbook", + "course-authoring.textbooks.chapters.title": "{count} PDF chapters", + "course-authoring.textbooks.button.view": "View the PDF live", + "course-authoring.textbooks.button.edit": "Edit", + "course-authoring.textbooks.button.delete": "Delete", + "course-authoring.textbooks.sidebar.section-1.title": "Why should I break my textbook into chapters?", + "course-authoring.textbooks.sidebar.section-1.descriptions": "Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.", + "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.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 b743fe0365..232b512a9b 100644 --- a/src/i18n/messages/it_IT.json +++ b/src/i18n/messages/it_IT.json @@ -1041,9 +1041,9 @@ "course-authoring.course-unit.status.released.title": "RELEASED", "course-authoring.course-unit.status.scheduled.title": "SCHEDULED", "course-authoring.course-unit.xblock.button.edit.alt": "Edit Item", - "course-authoring.course-unit.xblock.button.copy.label": "Copy", "course-authoring.course-unit.xblock.button.duplicate.label": "Duplicate", "course-authoring.course-unit.xblock.button.move.label": "Move", + "course-authoring.course-unit.xblock.button.copyToClipboard.label": "Copy to clipboard", "course-authoring.course-unit.xblock.button.manageAccess.label": "Manage access", "course-authoring.course-unit.xblock.button.delete.label": "Delete", "course-authoring.course-unit.xblock.button.actions.alt": "Actions", @@ -1077,5 +1077,33 @@ "course-authoring.group-configurations.empty-placeholder.button": "Add your first content group", "course-authoring.group-configurations.experimental-empty-placeholder.button": "Add your first group configuration", "course-authoring.group-configurations.content-groups.add-new-group": "New content group", - "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience." + "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience.", + "course-authoring.textbooks.header.title": "Textbooks", + "course-authoring.textbooks.header.breadcrumb.content": "Content", + "course-authoring.textbooks.header.breadcrumb.pages-and-resources": "Pages & resources", + "course-authoring.textbooks.header.breadcrumb.aria-label": "Textbook breadcrumb", + "course-authoring.textbooks.header.new-textbook": "New textbook", + "course-authoring.textbooks.empty-placeholder.title": "You haven't added any textbooks to this course yet.", + "course-authoring.textbooks.empty-placeholder.button.new-textbook": "New textbook", + "course-authoring.textbooks.chapters.title": "{count} PDF chapters", + "course-authoring.textbooks.button.view": "View the PDF live", + "course-authoring.textbooks.button.edit": "Edit", + "course-authoring.textbooks.button.delete": "Delete", + "course-authoring.textbooks.sidebar.section-1.title": "Why should I break my textbook into chapters?", + "course-authoring.textbooks.sidebar.section-1.descriptions": "Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.", + "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.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 ad312d6647..6bb3679e37 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1041,9 +1041,9 @@ "course-authoring.course-unit.status.released.title": "RELEASED", "course-authoring.course-unit.status.scheduled.title": "SCHEDULED", "course-authoring.course-unit.xblock.button.edit.alt": "Edit Item", - "course-authoring.course-unit.xblock.button.copy.label": "Copy", "course-authoring.course-unit.xblock.button.duplicate.label": "Duplicate", "course-authoring.course-unit.xblock.button.move.label": "Move", + "course-authoring.course-unit.xblock.button.copyToClipboard.label": "Copy to clipboard", "course-authoring.course-unit.xblock.button.manageAccess.label": "Manage access", "course-authoring.course-unit.xblock.button.delete.label": "Delete", "course-authoring.course-unit.xblock.button.actions.alt": "Actions", @@ -1077,5 +1077,33 @@ "course-authoring.group-configurations.empty-placeholder.button": "Add your first content group", "course-authoring.group-configurations.experimental-empty-placeholder.button": "Add your first group configuration", "course-authoring.group-configurations.content-groups.add-new-group": "New content group", - "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience." + "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience.", + "course-authoring.textbooks.header.title": "Textbooks", + "course-authoring.textbooks.header.breadcrumb.content": "Content", + "course-authoring.textbooks.header.breadcrumb.pages-and-resources": "Pages & resources", + "course-authoring.textbooks.header.breadcrumb.aria-label": "Textbook breadcrumb", + "course-authoring.textbooks.header.new-textbook": "New textbook", + "course-authoring.textbooks.empty-placeholder.title": "You haven't added any textbooks to this course yet.", + "course-authoring.textbooks.empty-placeholder.button.new-textbook": "New textbook", + "course-authoring.textbooks.chapters.title": "{count} PDF chapters", + "course-authoring.textbooks.button.view": "View the PDF live", + "course-authoring.textbooks.button.edit": "Edit", + "course-authoring.textbooks.button.delete": "Delete", + "course-authoring.textbooks.sidebar.section-1.title": "Why should I break my textbook into chapters?", + "course-authoring.textbooks.sidebar.section-1.descriptions": "Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.", + "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.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 b0567f5942..50b17667f2 100644 --- a/src/i18n/messages/pt_PT.json +++ b/src/i18n/messages/pt_PT.json @@ -1041,9 +1041,9 @@ "course-authoring.course-unit.status.released.title": "RELEASED", "course-authoring.course-unit.status.scheduled.title": "SCHEDULED", "course-authoring.course-unit.xblock.button.edit.alt": "Edit Item", - "course-authoring.course-unit.xblock.button.copy.label": "Copy", "course-authoring.course-unit.xblock.button.duplicate.label": "Duplicate", "course-authoring.course-unit.xblock.button.move.label": "Move", + "course-authoring.course-unit.xblock.button.copyToClipboard.label": "Copy to clipboard", "course-authoring.course-unit.xblock.button.manageAccess.label": "Manage access", "course-authoring.course-unit.xblock.button.delete.label": "Delete", "course-authoring.course-unit.xblock.button.actions.alt": "Actions", @@ -1077,5 +1077,33 @@ "course-authoring.group-configurations.empty-placeholder.button": "Add your first content group", "course-authoring.group-configurations.experimental-empty-placeholder.button": "Add your first group configuration", "course-authoring.group-configurations.content-groups.add-new-group": "New content group", - "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience." + "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience.", + "course-authoring.textbooks.header.title": "Textbooks", + "course-authoring.textbooks.header.breadcrumb.content": "Content", + "course-authoring.textbooks.header.breadcrumb.pages-and-resources": "Pages & resources", + "course-authoring.textbooks.header.breadcrumb.aria-label": "Textbook breadcrumb", + "course-authoring.textbooks.header.new-textbook": "New textbook", + "course-authoring.textbooks.empty-placeholder.title": "You haven't added any textbooks to this course yet.", + "course-authoring.textbooks.empty-placeholder.button.new-textbook": "New textbook", + "course-authoring.textbooks.chapters.title": "{count} PDF chapters", + "course-authoring.textbooks.button.view": "View the PDF live", + "course-authoring.textbooks.button.edit": "Edit", + "course-authoring.textbooks.button.delete": "Delete", + "course-authoring.textbooks.sidebar.section-1.title": "Why should I break my textbook into chapters?", + "course-authoring.textbooks.sidebar.section-1.descriptions": "Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.", + "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.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 ad312d6647..6bb3679e37 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -1041,9 +1041,9 @@ "course-authoring.course-unit.status.released.title": "RELEASED", "course-authoring.course-unit.status.scheduled.title": "SCHEDULED", "course-authoring.course-unit.xblock.button.edit.alt": "Edit Item", - "course-authoring.course-unit.xblock.button.copy.label": "Copy", "course-authoring.course-unit.xblock.button.duplicate.label": "Duplicate", "course-authoring.course-unit.xblock.button.move.label": "Move", + "course-authoring.course-unit.xblock.button.copyToClipboard.label": "Copy to clipboard", "course-authoring.course-unit.xblock.button.manageAccess.label": "Manage access", "course-authoring.course-unit.xblock.button.delete.label": "Delete", "course-authoring.course-unit.xblock.button.actions.alt": "Actions", @@ -1077,5 +1077,33 @@ "course-authoring.group-configurations.empty-placeholder.button": "Add your first content group", "course-authoring.group-configurations.experimental-empty-placeholder.button": "Add your first group configuration", "course-authoring.group-configurations.content-groups.add-new-group": "New content group", - "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience." + "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience.", + "course-authoring.textbooks.header.title": "Textbooks", + "course-authoring.textbooks.header.breadcrumb.content": "Content", + "course-authoring.textbooks.header.breadcrumb.pages-and-resources": "Pages & resources", + "course-authoring.textbooks.header.breadcrumb.aria-label": "Textbook breadcrumb", + "course-authoring.textbooks.header.new-textbook": "New textbook", + "course-authoring.textbooks.empty-placeholder.title": "You haven't added any textbooks to this course yet.", + "course-authoring.textbooks.empty-placeholder.button.new-textbook": "New textbook", + "course-authoring.textbooks.chapters.title": "{count} PDF chapters", + "course-authoring.textbooks.button.view": "View the PDF live", + "course-authoring.textbooks.button.edit": "Edit", + "course-authoring.textbooks.button.delete": "Delete", + "course-authoring.textbooks.sidebar.section-1.title": "Why should I break my textbook into chapters?", + "course-authoring.textbooks.sidebar.section-1.descriptions": "Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.", + "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.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 ad312d6647..6bb3679e37 100644 --- a/src/i18n/messages/uk.json +++ b/src/i18n/messages/uk.json @@ -1041,9 +1041,9 @@ "course-authoring.course-unit.status.released.title": "RELEASED", "course-authoring.course-unit.status.scheduled.title": "SCHEDULED", "course-authoring.course-unit.xblock.button.edit.alt": "Edit Item", - "course-authoring.course-unit.xblock.button.copy.label": "Copy", "course-authoring.course-unit.xblock.button.duplicate.label": "Duplicate", "course-authoring.course-unit.xblock.button.move.label": "Move", + "course-authoring.course-unit.xblock.button.copyToClipboard.label": "Copy to clipboard", "course-authoring.course-unit.xblock.button.manageAccess.label": "Manage access", "course-authoring.course-unit.xblock.button.delete.label": "Delete", "course-authoring.course-unit.xblock.button.actions.alt": "Actions", @@ -1077,5 +1077,33 @@ "course-authoring.group-configurations.empty-placeholder.button": "Add your first content group", "course-authoring.group-configurations.experimental-empty-placeholder.button": "Add your first group configuration", "course-authoring.group-configurations.content-groups.add-new-group": "New content group", - "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience." + "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience.", + "course-authoring.textbooks.header.title": "Textbooks", + "course-authoring.textbooks.header.breadcrumb.content": "Content", + "course-authoring.textbooks.header.breadcrumb.pages-and-resources": "Pages & resources", + "course-authoring.textbooks.header.breadcrumb.aria-label": "Textbook breadcrumb", + "course-authoring.textbooks.header.new-textbook": "New textbook", + "course-authoring.textbooks.empty-placeholder.title": "You haven't added any textbooks to this course yet.", + "course-authoring.textbooks.empty-placeholder.button.new-textbook": "New textbook", + "course-authoring.textbooks.chapters.title": "{count} PDF chapters", + "course-authoring.textbooks.button.view": "View the PDF live", + "course-authoring.textbooks.button.edit": "Edit", + "course-authoring.textbooks.button.delete": "Delete", + "course-authoring.textbooks.sidebar.section-1.title": "Why should I break my textbook into chapters?", + "course-authoring.textbooks.sidebar.section-1.descriptions": "Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.", + "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.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 ad312d6647..6bb3679e37 100644 --- a/src/i18n/messages/zh_CN.json +++ b/src/i18n/messages/zh_CN.json @@ -1041,9 +1041,9 @@ "course-authoring.course-unit.status.released.title": "RELEASED", "course-authoring.course-unit.status.scheduled.title": "SCHEDULED", "course-authoring.course-unit.xblock.button.edit.alt": "Edit Item", - "course-authoring.course-unit.xblock.button.copy.label": "Copy", "course-authoring.course-unit.xblock.button.duplicate.label": "Duplicate", "course-authoring.course-unit.xblock.button.move.label": "Move", + "course-authoring.course-unit.xblock.button.copyToClipboard.label": "Copy to clipboard", "course-authoring.course-unit.xblock.button.manageAccess.label": "Manage access", "course-authoring.course-unit.xblock.button.delete.label": "Delete", "course-authoring.course-unit.xblock.button.actions.alt": "Actions", @@ -1077,5 +1077,33 @@ "course-authoring.group-configurations.empty-placeholder.button": "Add your first content group", "course-authoring.group-configurations.experimental-empty-placeholder.button": "Add your first group configuration", "course-authoring.group-configurations.content-groups.add-new-group": "New content group", - "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience." + "course-authoring.course-unit.general.alert.unpublished-version.description": "Note: The last published version of this unit is live. By publishing changes you will change the student experience.", + "course-authoring.textbooks.header.title": "Textbooks", + "course-authoring.textbooks.header.breadcrumb.content": "Content", + "course-authoring.textbooks.header.breadcrumb.pages-and-resources": "Pages & resources", + "course-authoring.textbooks.header.breadcrumb.aria-label": "Textbook breadcrumb", + "course-authoring.textbooks.header.new-textbook": "New textbook", + "course-authoring.textbooks.empty-placeholder.title": "You haven't added any textbooks to this course yet.", + "course-authoring.textbooks.empty-placeholder.button.new-textbook": "New textbook", + "course-authoring.textbooks.chapters.title": "{count} PDF chapters", + "course-authoring.textbooks.button.view": "View the PDF live", + "course-authoring.textbooks.button.edit": "Edit", + "course-authoring.textbooks.button.delete": "Delete", + "course-authoring.textbooks.sidebar.section-1.title": "Why should I break my textbook into chapters?", + "course-authoring.textbooks.sidebar.section-1.descriptions": "Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.", + "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.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" }