Skip to content

Commit a3e03dc

Browse files
authored
Revert "Remove unused <EditorContainer> and URL route" (#2274)
1 parent 77fe2d1 commit a3e03dc

File tree

5 files changed

+311
-1
lines changed

5 files changed

+311
-1
lines changed

src/CourseAuthoringRoutes.jsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { PageWrap } from '@edx/frontend-platform/react';
77
import { Textbooks } from './textbooks';
88
import CourseAuthoringPage from './CourseAuthoringPage';
99
import { PagesAndResources } from './pages-and-resources';
10+
import EditorContainer from './editors/EditorContainer';
1011
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
1112
import CustomPages from './custom-pages';
1213
import { FilesPage, VideosPage } from './files-and-videos';
@@ -36,7 +37,7 @@ import { IframeProvider } from './generic/hooks/context/iFrameContext';
3637
*
3738
* /course/:courseId/course-pages
3839
* /course/:courseId/proctored-exam-settings
39-
* /course/:courseId/course-videos/:blockId
40+
* /course/:courseId/editor/:blockType/:blockId
4041
*
4142
* This component and CourseAuthoringPage should maybe be combined once we no longer need to have
4243
* CourseAuthoringPage split out for use in LegacyProctoringRoute. Once that route is removed, we
@@ -92,6 +93,10 @@ const CourseAuthoringRoutes = () => {
9293
path="editor/course-videos/:blockId"
9394
element={<PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap>}
9495
/>
96+
<Route
97+
path="editor/:blockType/:blockId?"
98+
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
99+
/>
95100
<Route
96101
path="settings/details"
97102
element={<PageWrap><ScheduleAndDetails courseId={courseId} /></PageWrap>}

src/CourseAuthoringRoutes.test.jsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66

77
const courseId = 'course-v1:edX+TestX+Test_Course';
88
const pagesAndResourcesMockText = 'Pages And Resources';
9+
const editorContainerMockText = 'Editor Container';
910
const videoSelectorContainerMockText = 'Video Selector Container';
1011
const customPagesMockText = 'Custom Pages';
1112
const mockComponentFn = jest.fn();
@@ -32,6 +33,10 @@ jest.mock('./pages-and-resources/PagesAndResources', () => (props) => {
3233
mockComponentFn(props);
3334
return pagesAndResourcesMockText;
3435
});
36+
jest.mock('./editors/EditorContainer', () => (props) => {
37+
mockComponentFn(props);
38+
return editorContainerMockText;
39+
});
3540
jest.mock('./selectors/VideoSelectorContainer', () => (props) => {
3641
mockComponentFn(props);
3742
return videoSelectorContainerMockText;
@@ -64,6 +69,22 @@ describe('<CourseAuthoringRoutes>', () => {
6469
});
6570
});
6671

72+
it('renders the EditorContainer component when the course editor route is active', async () => {
73+
render(
74+
<CourseAuthoringRoutes />,
75+
{ routerProps: { initialEntries: ['/editor/video/block-id'] } },
76+
);
77+
await waitFor(() => {
78+
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
79+
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
80+
expect(mockComponentFn).toHaveBeenCalledWith(
81+
expect.objectContaining({
82+
learningContextId: courseId,
83+
}),
84+
);
85+
});
86+
});
87+
6788
it('renders the VideoSelectorContainer component when the course videos route is active', async () => {
6889
render(
6990
<CourseAuthoringRoutes />,

src/editors/EditorContainer.test.tsx

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import React from 'react';
2+
import { getConfig } from '@edx/frontend-platform';
3+
import {
4+
render, screen, initializeMocks, fireEvent, act,
5+
} from '@src/testUtils';
6+
import EditorContainer from './EditorContainer';
7+
import { mockWaffleFlags } from '../data/apiHooks.mock';
8+
import editorCmsApi from './data/services/cms/api';
9+
10+
mockWaffleFlags();
11+
12+
const mockPathname = '/editor/';
13+
jest.mock('react-router-dom', () => ({
14+
...jest.requireActual('react-router-dom'),
15+
useParams: () => ({
16+
blockId: 'block-v1:Org+TS100+24+type@fake+block@123456fake',
17+
blockType: 'fake',
18+
}),
19+
useLocation: () => ({
20+
pathname: mockPathname,
21+
}),
22+
useSearchParams: () => [{
23+
get: () => 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
24+
}],
25+
}));
26+
27+
jest.mock('react-redux', () => ({
28+
...jest.requireActual('react-redux'),
29+
useSelector: () => ({
30+
useReactMarkdownEditor: true, // or false depending on the test
31+
}),
32+
}));
33+
34+
// Mock this plugins component:
35+
jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' }));
36+
// Always mock out the "fetch course images" endpoint:
37+
jest.spyOn(editorCmsApi, 'fetchCourseImages').mockImplementation(async () => ( // eslint-disable-next-line
38+
{ data: { assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0 } }
39+
));
40+
// Mock out the 'get ancestors' API:
41+
jest.spyOn(editorCmsApi, 'fetchByUnitId').mockImplementation(async () => ({
42+
status: 200,
43+
data: {
44+
ancestors: [{
45+
id: 'block-v1:Org+TS100+24+type@vertical+block@parent',
46+
display_name: 'You-Knit? The Test Unit',
47+
category: 'vertical',
48+
has_children: true,
49+
}],
50+
},
51+
}));
52+
jest.mock('../library-authoring/LibraryBlock', () => ({
53+
LibraryBlock: jest.fn(() => (<div>Advanced Editor Iframe</div>)),
54+
}));
55+
56+
const props = { learningContextId: 'cOuRsEId' };
57+
58+
describe('EditorContainer', () => {
59+
beforeEach(() => {
60+
initializeMocks();
61+
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
62+
{
63+
status: 200,
64+
data: {
65+
display_name: 'Fake Un-editable Block', category: 'fake', metadata: {}, data: '',
66+
},
67+
}
68+
));
69+
});
70+
71+
test('render component', () => {
72+
render(<EditorContainer {...props} />);
73+
expect(screen.getByText('View in Library')).toBeInTheDocument();
74+
expect(screen.getByText('Advanced Editor Iframe')).toBeInTheDocument();
75+
});
76+
77+
test('should call onClose param when receiving "cancel-clicked" message', () => {
78+
const onCloseMock = jest.fn();
79+
render(<EditorContainer {...props} onClose={onCloseMock} />);
80+
const messageEvent = new MessageEvent('message', {
81+
data: {
82+
type: 'xblock-event',
83+
eventName: 'cancel',
84+
},
85+
origin: getConfig().STUDIO_BASE_URL,
86+
});
87+
88+
act(() => {
89+
window.dispatchEvent(messageEvent);
90+
});
91+
fireEvent.click(screen.getByRole('button', { name: 'Discard Changes and Exit' }));
92+
expect(onCloseMock).toHaveBeenCalled();
93+
});
94+
});

src/editors/EditorContainer.tsx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import React from 'react';
2+
import { useLocation, useParams, useSearchParams } from 'react-router-dom';
3+
import { getConfig } from '@edx/frontend-platform';
4+
import { useIntl } from '@edx/frontend-platform/i18n';
5+
import { Button, Hyperlink } from '@openedx/paragon';
6+
import { Warning as WarningIcon } from '@openedx/paragon/icons';
7+
8+
import EditorPage from './EditorPage';
9+
import AlertMessage from '../generic/alert-message';
10+
import messages from './messages';
11+
import { getLibraryId } from '../generic/key-utils';
12+
import { createCorrectInternalRoute } from '../utils';
13+
14+
interface Props {
15+
/** Course ID or Library ID */
16+
learningContextId: string;
17+
/** Event handler sometimes called when user cancels out of the editor page */
18+
onClose?: (prevPath?: string) => void;
19+
/**
20+
* Event handler called after when user saves their changes using an editor
21+
* and sometimes called when user cancels the editor, instead of onClose.
22+
* If changes are saved, newData will be present, and if it was cancellation,
23+
* newData will be undefined.
24+
* TODO: clean this up so there are separate onCancel and onSave callbacks,
25+
* and they are used consistently instead of this mess.
26+
*/
27+
returnFunction?: (prevPath?: string) => (newData: Record<string, any> | undefined) => void;
28+
}
29+
30+
const EditorContainer: React.FC<Props> = ({
31+
learningContextId,
32+
onClose,
33+
returnFunction,
34+
}) => {
35+
const intl = useIntl();
36+
const { blockType, blockId } = useParams();
37+
const location = useLocation();
38+
const [searchParams] = useSearchParams();
39+
const upstreamLibRef = searchParams.get('upstreamLibRef');
40+
41+
if (blockType === undefined || blockId === undefined) {
42+
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
43+
return <div>Error: missing URL parameters</div>;
44+
}
45+
46+
const getLibraryBlockUrl = () => {
47+
if (!upstreamLibRef) {
48+
// istanbul ignore next
49+
return '';
50+
}
51+
const libId = getLibraryId(upstreamLibRef);
52+
return createCorrectInternalRoute(`/library/${libId}/components?usageKey=${upstreamLibRef}`);
53+
};
54+
55+
return (
56+
<div className="editor-page">
57+
<AlertMessage
58+
className="m-3"
59+
show={!!upstreamLibRef}
60+
variant="warning"
61+
icon={WarningIcon}
62+
title={intl.formatMessage(messages.libraryBlockEditWarningTitle)}
63+
description={intl.formatMessage(messages.libraryBlockEditWarningDescription)}
64+
actions={[
65+
<Button
66+
destination={getLibraryBlockUrl()}
67+
target="_blank"
68+
rel="noopener noreferrer"
69+
showLaunchIcon
70+
as={Hyperlink}
71+
>
72+
{intl.formatMessage(messages.libraryBlockEditWarningLink)}
73+
</Button>,
74+
]}
75+
/>
76+
<EditorPage
77+
courseId={learningContextId}
78+
blockType={blockType}
79+
blockId={blockId}
80+
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
81+
lmsEndpointUrl={getConfig().LMS_BASE_URL}
82+
onClose={onClose ? () => onClose(location.state?.from) : null}
83+
returnFunction={returnFunction ? () => returnFunction(location.state?.from) : null}
84+
/>
85+
</div>
86+
);
87+
};
88+
89+
export default EditorContainer;

src/editors/example.jsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/* istanbul ignore file */
2+
/* eslint-disable @typescript-eslint/no-unused-vars */
3+
/* eslint-disable import/extensions */
4+
/* eslint-disable import/no-unresolved */
5+
/**
6+
* This is an example component for an xblock Editor
7+
* It uses pre-existing components to handle the saving of a the result of a function into the xblock's data.
8+
* To use run npm run-script addXblock <your>
9+
*/
10+
11+
/* eslint-disable no-unused-vars */
12+
13+
import React from 'react';
14+
import { connect } from 'react-redux';
15+
import PropTypes from 'prop-types';
16+
17+
import { Spinner } from '@openedx/paragon';
18+
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
19+
20+
import EditorContainer from '../EditorContainer';
21+
// This 'module' self-import hack enables mocking during tests.
22+
// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
23+
// should be re-thought and cleaned up to avoid this pattern.
24+
// eslint-disable-next-line import/no-self-import
25+
import * as module from '..';
26+
import { actions, selectors } from '../../data/redux';
27+
import { RequestKeys } from '../../data/constants/requests';
28+
29+
export const hooks = {
30+
getContent: () => ({
31+
some: 'content',
32+
}),
33+
};
34+
35+
export const thumbEditor = ({
36+
onClose,
37+
// redux
38+
blockValue,
39+
lmsEndpointUrl,
40+
blockFailed,
41+
blockFinished,
42+
initializeEditor,
43+
// inject
44+
intl,
45+
}) => (
46+
<EditorContainer
47+
getContent={module.hooks.getContent}
48+
onClose={onClose}
49+
>
50+
<div className="editor-body h-75 overflow-auto">
51+
{!blockFinished
52+
? (
53+
<div className="text-center p-6">
54+
<Spinner
55+
animation="border"
56+
className="m-3"
57+
// Use a messages.js file for intl messages.
58+
screenreadertext={intl.formatMessage('Loading Spinner')}
59+
/>
60+
</div>
61+
)
62+
: (
63+
<p>
64+
Your Editor Goes here.
65+
You can get at the xblock data with the blockValue field.
66+
here is what is in your xblock: {JSON.stringify(blockValue)}
67+
</p>
68+
)}
69+
</div>
70+
</EditorContainer>
71+
);
72+
thumbEditor.defaultProps = {
73+
blockValue: null,
74+
lmsEndpointUrl: null,
75+
};
76+
thumbEditor.propTypes = {
77+
onClose: PropTypes.func.isRequired,
78+
// redux
79+
blockValue: PropTypes.shape({
80+
data: PropTypes.shape({ data: PropTypes.string }),
81+
}),
82+
lmsEndpointUrl: PropTypes.string,
83+
blockFailed: PropTypes.bool.isRequired,
84+
blockFinished: PropTypes.bool.isRequired,
85+
initializeEditor: PropTypes.func.isRequired,
86+
// inject
87+
intl: intlShape.isRequired,
88+
};
89+
90+
export const mapStateToProps = (state) => ({
91+
blockValue: selectors.app.blockValue(state),
92+
lmsEndpointUrl: selectors.app.lmsEndpointUrl(state),
93+
blockFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchBlock }),
94+
blockFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchBlock }),
95+
});
96+
97+
export const mapDispatchToProps = {
98+
initializeEditor: actions.app.initializeEditor,
99+
};
100+
101+
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(thumbEditor));

0 commit comments

Comments
 (0)