diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index dc0b3b650..39808ab8a 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -7,6 +7,7 @@ import { PageWrap } from '@edx/frontend-platform/react'; import { Textbooks } from './textbooks'; import CourseAuthoringPage from './CourseAuthoringPage'; import { PagesAndResources } from './pages-and-resources'; +import EditorContainer from './editors/EditorContainer'; import VideoSelectorContainer from './selectors/VideoSelectorContainer'; import CustomPages from './custom-pages'; import { FilesPage, VideosPage } from './files-and-videos'; @@ -36,7 +37,7 @@ import { IframeProvider } from './generic/hooks/context/iFrameContext'; * * /course/:courseId/course-pages * /course/:courseId/proctored-exam-settings - * /course/:courseId/course-videos/:blockId + * /course/:courseId/editor/:blockType/:blockId * * This component and CourseAuthoringPage should maybe be combined once we no longer need to have * CourseAuthoringPage split out for use in LegacyProctoringRoute. Once that route is removed, we @@ -92,6 +93,10 @@ const CourseAuthoringRoutes = () => { path="editor/course-videos/:blockId" element={} /> + } + /> } diff --git a/src/CourseAuthoringRoutes.test.jsx b/src/CourseAuthoringRoutes.test.jsx index eece6be75..472862e80 100644 --- a/src/CourseAuthoringRoutes.test.jsx +++ b/src/CourseAuthoringRoutes.test.jsx @@ -6,6 +6,7 @@ import { const courseId = 'course-v1:edX+TestX+Test_Course'; const pagesAndResourcesMockText = 'Pages And Resources'; +const editorContainerMockText = 'Editor Container'; const videoSelectorContainerMockText = 'Video Selector Container'; const customPagesMockText = 'Custom Pages'; const mockComponentFn = jest.fn(); @@ -32,6 +33,10 @@ jest.mock('./pages-and-resources/PagesAndResources', () => (props) => { mockComponentFn(props); return pagesAndResourcesMockText; }); +jest.mock('./editors/EditorContainer', () => (props) => { + mockComponentFn(props); + return editorContainerMockText; +}); jest.mock('./selectors/VideoSelectorContainer', () => (props) => { mockComponentFn(props); return videoSelectorContainerMockText; @@ -64,6 +69,22 @@ describe('', () => { }); }); + it('renders the EditorContainer component when the course editor route is active', async () => { + render( + , + { routerProps: { initialEntries: ['/editor/video/block-id'] } }, + ); + await waitFor(() => { + expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument(); + expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument(); + expect(mockComponentFn).toHaveBeenCalledWith( + expect.objectContaining({ + learningContextId: courseId, + }), + ); + }); + }); + it('renders the VideoSelectorContainer component when the course videos route is active', async () => { render( , diff --git a/src/editors/EditorContainer.test.tsx b/src/editors/EditorContainer.test.tsx new file mode 100644 index 000000000..d4d6d8371 --- /dev/null +++ b/src/editors/EditorContainer.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { getConfig } from '@edx/frontend-platform'; +import { + render, screen, initializeMocks, fireEvent, act, +} from '@src/testUtils'; +import EditorContainer from './EditorContainer'; +import { mockWaffleFlags } from '../data/apiHooks.mock'; +import editorCmsApi from './data/services/cms/api'; + +mockWaffleFlags(); + +const mockPathname = '/editor/'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + blockId: 'block-v1:Org+TS100+24+type@fake+block@123456fake', + blockType: 'fake', + }), + useLocation: () => ({ + pathname: mockPathname, + }), + useSearchParams: () => [{ + get: () => 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', + }], +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: () => ({ + useReactMarkdownEditor: true, // or false depending on the test + }), +})); + +// Mock this plugins component: +jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' })); +// Always mock out the "fetch course images" endpoint: +jest.spyOn(editorCmsApi, 'fetchCourseImages').mockImplementation(async () => ( // eslint-disable-next-line + { data: { assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0 } } +)); +// Mock out the 'get ancestors' API: +jest.spyOn(editorCmsApi, 'fetchByUnitId').mockImplementation(async () => ({ + status: 200, + data: { + ancestors: [{ + id: 'block-v1:Org+TS100+24+type@vertical+block@parent', + display_name: 'You-Knit? The Test Unit', + category: 'vertical', + has_children: true, + }], + }, +})); +jest.mock('../library-authoring/LibraryBlock', () => ({ + LibraryBlock: jest.fn(() => (
Advanced Editor Iframe
)), +})); + +const props = { learningContextId: 'cOuRsEId' }; + +describe('EditorContainer', () => { + beforeEach(() => { + initializeMocks(); + jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => ( + { + status: 200, + data: { + display_name: 'Fake Un-editable Block', category: 'fake', metadata: {}, data: '', + }, + } + )); + }); + + test('render component', () => { + render(); + expect(screen.getByText('View in Library')).toBeInTheDocument(); + expect(screen.getByText('Advanced Editor Iframe')).toBeInTheDocument(); + }); + + test('should call onClose param when receiving "cancel-clicked" message', () => { + const onCloseMock = jest.fn(); + render(); + const messageEvent = new MessageEvent('message', { + data: { + type: 'xblock-event', + eventName: 'cancel', + }, + origin: getConfig().STUDIO_BASE_URL, + }); + + act(() => { + window.dispatchEvent(messageEvent); + }); + fireEvent.click(screen.getByRole('button', { name: 'Discard Changes and Exit' })); + expect(onCloseMock).toHaveBeenCalled(); + }); +}); diff --git a/src/editors/EditorContainer.tsx b/src/editors/EditorContainer.tsx new file mode 100644 index 000000000..0df4c816f --- /dev/null +++ b/src/editors/EditorContainer.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { useLocation, useParams, useSearchParams } from 'react-router-dom'; +import { getConfig } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button, Hyperlink } from '@openedx/paragon'; +import { Warning as WarningIcon } from '@openedx/paragon/icons'; + +import EditorPage from './EditorPage'; +import AlertMessage from '../generic/alert-message'; +import messages from './messages'; +import { getLibraryId } from '../generic/key-utils'; +import { createCorrectInternalRoute } from '../utils'; + +interface Props { + /** Course ID or Library ID */ + learningContextId: string; + /** Event handler sometimes called when user cancels out of the editor page */ + onClose?: (prevPath?: string) => void; + /** + * Event handler called after when user saves their changes using an editor + * and sometimes called when user cancels the editor, instead of onClose. + * If changes are saved, newData will be present, and if it was cancellation, + * newData will be undefined. + * TODO: clean this up so there are separate onCancel and onSave callbacks, + * and they are used consistently instead of this mess. + */ + returnFunction?: (prevPath?: string) => (newData: Record | undefined) => void; +} + +const EditorContainer: React.FC = ({ + learningContextId, + onClose, + returnFunction, +}) => { + const intl = useIntl(); + const { blockType, blockId } = useParams(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + const upstreamLibRef = searchParams.get('upstreamLibRef'); + + if (blockType === undefined || blockId === undefined) { + // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. + return
Error: missing URL parameters
; + } + + const getLibraryBlockUrl = () => { + if (!upstreamLibRef) { + // istanbul ignore next + return ''; + } + const libId = getLibraryId(upstreamLibRef); + return createCorrectInternalRoute(`/library/${libId}/components?usageKey=${upstreamLibRef}`); + }; + + return ( +
+ + {intl.formatMessage(messages.libraryBlockEditWarningLink)} + , + ]} + /> + onClose(location.state?.from) : null} + returnFunction={returnFunction ? () => returnFunction(location.state?.from) : null} + /> +
+ ); +}; + +export default EditorContainer; diff --git a/src/editors/example.jsx b/src/editors/example.jsx new file mode 100644 index 000000000..2e6985340 --- /dev/null +++ b/src/editors/example.jsx @@ -0,0 +1,101 @@ +/* istanbul ignore file */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable import/extensions */ +/* eslint-disable import/no-unresolved */ +/** + * This is an example component for an xblock Editor + * It uses pre-existing components to handle the saving of a the result of a function into the xblock's data. + * To use run npm run-script addXblock + */ + +/* eslint-disable no-unused-vars */ + +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { Spinner } from '@openedx/paragon'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; + +import EditorContainer from '../EditorContainer'; +// This 'module' self-import hack enables mocking during tests. +// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested +// should be re-thought and cleaned up to avoid this pattern. +// eslint-disable-next-line import/no-self-import +import * as module from '..'; +import { actions, selectors } from '../../data/redux'; +import { RequestKeys } from '../../data/constants/requests'; + +export const hooks = { + getContent: () => ({ + some: 'content', + }), +}; + +export const thumbEditor = ({ + onClose, + // redux + blockValue, + lmsEndpointUrl, + blockFailed, + blockFinished, + initializeEditor, + // inject + intl, +}) => ( + +
+ {!blockFinished + ? ( +
+ +
+ ) + : ( +

+ Your Editor Goes here. + You can get at the xblock data with the blockValue field. + here is what is in your xblock: {JSON.stringify(blockValue)} +

+ )} +
+
+); +thumbEditor.defaultProps = { + blockValue: null, + lmsEndpointUrl: null, +}; +thumbEditor.propTypes = { + onClose: PropTypes.func.isRequired, + // redux + blockValue: PropTypes.shape({ + data: PropTypes.shape({ data: PropTypes.string }), + }), + lmsEndpointUrl: PropTypes.string, + blockFailed: PropTypes.bool.isRequired, + blockFinished: PropTypes.bool.isRequired, + initializeEditor: PropTypes.func.isRequired, + // inject + intl: intlShape.isRequired, +}; + +export const mapStateToProps = (state) => ({ + blockValue: selectors.app.blockValue(state), + lmsEndpointUrl: selectors.app.lmsEndpointUrl(state), + blockFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchBlock }), + blockFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchBlock }), +}); + +export const mapDispatchToProps = { + initializeEditor: actions.app.initializeEditor, +}; + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(thumbEditor));