Revert "Remove unused <EditorContainer> and URL route" (#2274)
This commit is contained in:
committed by
GitHub
parent
77fe2d1086
commit
a3e03dc12f
@@ -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={<PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="editor/:blockType/:blockId?"
|
||||
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="settings/details"
|
||||
element={<PageWrap><ScheduleAndDetails courseId={courseId} /></PageWrap>}
|
||||
|
||||
@@ -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('<CourseAuthoringRoutes>', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the EditorContainer component when the course editor route is active', async () => {
|
||||
render(
|
||||
<CourseAuthoringRoutes />,
|
||||
{ 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(
|
||||
<CourseAuthoringRoutes />,
|
||||
|
||||
94
src/editors/EditorContainer.test.tsx
Normal file
94
src/editors/EditorContainer.test.tsx
Normal file
@@ -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(() => (<div>Advanced Editor Iframe</div>)),
|
||||
}));
|
||||
|
||||
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(<EditorContainer {...props} />);
|
||||
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(<EditorContainer {...props} onClose={onCloseMock} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
89
src/editors/EditorContainer.tsx
Normal file
89
src/editors/EditorContainer.tsx
Normal file
@@ -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<string, any> | undefined) => void;
|
||||
}
|
||||
|
||||
const EditorContainer: React.FC<Props> = ({
|
||||
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 <div>Error: missing URL parameters</div>;
|
||||
}
|
||||
|
||||
const getLibraryBlockUrl = () => {
|
||||
if (!upstreamLibRef) {
|
||||
// istanbul ignore next
|
||||
return '';
|
||||
}
|
||||
const libId = getLibraryId(upstreamLibRef);
|
||||
return createCorrectInternalRoute(`/library/${libId}/components?usageKey=${upstreamLibRef}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="editor-page">
|
||||
<AlertMessage
|
||||
className="m-3"
|
||||
show={!!upstreamLibRef}
|
||||
variant="warning"
|
||||
icon={WarningIcon}
|
||||
title={intl.formatMessage(messages.libraryBlockEditWarningTitle)}
|
||||
description={intl.formatMessage(messages.libraryBlockEditWarningDescription)}
|
||||
actions={[
|
||||
<Button
|
||||
destination={getLibraryBlockUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
showLaunchIcon
|
||||
as={Hyperlink}
|
||||
>
|
||||
{intl.formatMessage(messages.libraryBlockEditWarningLink)}
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
<EditorPage
|
||||
courseId={learningContextId}
|
||||
blockType={blockType}
|
||||
blockId={blockId}
|
||||
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
|
||||
lmsEndpointUrl={getConfig().LMS_BASE_URL}
|
||||
onClose={onClose ? () => onClose(location.state?.from) : null}
|
||||
returnFunction={returnFunction ? () => returnFunction(location.state?.from) : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorContainer;
|
||||
101
src/editors/example.jsx
Normal file
101
src/editors/example.jsx
Normal file
@@ -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 <your>
|
||||
*/
|
||||
|
||||
/* 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,
|
||||
}) => (
|
||||
<EditorContainer
|
||||
getContent={module.hooks.getContent}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="editor-body h-75 overflow-auto">
|
||||
{!blockFinished
|
||||
? (
|
||||
<div className="text-center p-6">
|
||||
<Spinner
|
||||
animation="border"
|
||||
className="m-3"
|
||||
// Use a messages.js file for intl messages.
|
||||
screenreadertext={intl.formatMessage('Loading Spinner')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<p>
|
||||
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)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
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));
|
||||
Reference in New Issue
Block a user