feat: raw html editor (#86)

raw html editor
This commit is contained in:
Raymond Zhou
2022-06-28 11:25:09 -07:00
committed by GitHub
parent b2fec70702
commit b047f7a2a8
23 changed files with 353 additions and 23 deletions

View File

@@ -10,6 +10,7 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
"value": "something",
},
},
"isRaw": false,
},
}
}
@@ -64,6 +65,62 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
</EditorContainer>
`;
exports[`TextEditor snapshots loaded, raw editor 1`] = `
<EditorContainer
getContent={
Object {
"getContent": Object {
"editorRef": Object {
"current": Object {
"value": "something",
},
},
"isRaw": true,
},
}
}
onClose={[MockFunction props.onClose]}
>
<div
className="editor-body h-75 overflow-auto"
>
<ImageUploadModal
clearSelection={[MockFunction hooks.selectedImage.clearSelection]}
close={[MockFunction modal.closeModal]}
editorRef={
Object {
"current": Object {
"value": "something",
},
}
}
isOpen={false}
selection="hooks.selectedImage.selection"
setSelection={[MockFunction hooks.selectedImage.setSelection]}
/>
<Toast
onClose={[MockFunction hooks.nullMethod]}
show={false}
>
<FormattedMessage
defaultMessage="Error: Could Not Load Text Content"
description="Error Message Dispayed When HTML content fails to Load"
id="authoring.texteditor.load.error"
/>
</Toast>
<RawEditor
editorRef={
Object {
"current": Object {
"value": "something",
},
}
}
/>
</div>
</EditorContainer>
`;
exports[`TextEditor snapshots not yet loaded, Spinner appears 1`] = `
<EditorContainer
getContent={
@@ -74,6 +131,7 @@ exports[`TextEditor snapshots not yet loaded, Spinner appears 1`] = `
"value": "something",
},
},
"isRaw": false,
},
}
}
@@ -129,6 +187,7 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
"value": "something",
},
},
"isRaw": false,
},
}
}

View File

@@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ImageSettingsModal renders as expected with default behavior 1`] = `
<div
className="form-group"
style={
Object {
"padding": "10px 30px",
}
}
>
<Alert
variant="danger"
>
You are using the raw HTML editor.
</Alert>
<textarea
className="form-control"
rows="12"
>
sOmErAwHtml
</textarea>
</div>
`;

View File

@@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from '@edx/paragon';
export const RawEditor = ({
editorRef,
text,
}) => (
<div className="form-group" style={{ padding: '10px 30px' }}>
<Alert variant="danger">
You are using the raw HTML editor.
</Alert>
<textarea
className="form-control"
ref={editorRef}
rows="12"
>
{ text }
</textarea>
</div>
);
RawEditor.defaultProps = {
editorRef: null,
};
RawEditor.propTypes = {
editorRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.any }),
]),
text: PropTypes.string.isRequired,
};
export default RawEditor;

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { shallow } from 'enzyme';
import { RawEditor } from '.';
describe('ImageSettingsModal', () => {
const props = {
editorRef: {
current: {
value: 'Ref Value',
},
},
text: 'sOmErAwHtml',
};
test('renders as expected with default behavior', () => {
expect(shallow(<RawEditor {...props} />)).toMatchSnapshot();
});
});

View File

@@ -112,7 +112,12 @@ export const prepareEditorRef = () => {
return { editorRef, refReady, setEditorRef };
};
export const getContent = ({ editorRef }) => () => editorRef.current?.getContent();
export const getContent = ({ editorRef, isRaw }) => () => {
if (isRaw && editorRef && editorRef.current) {
return editorRef.current.value;
}
return editorRef.current?.getContent();
};
export const selectedImage = (val) => {
const [selection, setSelection] = module.state.imageSelection(val);

View File

@@ -188,6 +188,21 @@ describe('TextEditor hooks', () => {
});
});
describe('getContent', () => {
const visualContent = 'sOmEViSualContent';
const rawContent = 'soMeRawContent';
const editorRef = {
current: {
getContent: () => visualContent,
value: rawContent,
},
};
test('returns correct ontent based on isRaw', () => {
expect(module.getContent({ editorRef, isRaw: false })()).toEqual(visualContent);
expect(module.getContent({ editorRef, isRaw: true })()).toEqual(rawContent);
});
});
describe('selectedImage hooks', () => {
const val = { a: 'VaLUe' };
beforeEach(() => {

View File

@@ -31,12 +31,14 @@ import { RequestKeys } from '../../data/constants/requests';
import EditorContainer from '../EditorContainer';
import ImageUploadModal from './components/ImageUploadModal';
import RawEditor from './components/RawEditor';
import * as hooks from './hooks';
import messages from './messages';
export const TextEditor = ({
onClose,
// redux
isRaw,
blockValue,
lmsEndpointUrl,
studioEndpointUrl,
@@ -52,9 +54,34 @@ export const TextEditor = ({
if (!refReady) { return null; }
const selectEditor = () => {
if (isRaw) {
return (
<RawEditor
editorRef={editorRef}
text={blockValue.data.data}
/>
);
}
return (
<Editor
{...hooks.editorConfig({
setEditorRef,
blockValue,
openModal,
initializeEditor,
lmsEndpointUrl,
studioEndpointUrl,
setSelection: imageSelection.setSelection,
clearSelection: imageSelection.clearSelection,
})}
/>
);
};
return (
<EditorContainer
getContent={hooks.getContent({ editorRef })}
getContent={hooks.getContent({ editorRef, isRaw })}
onClose={onClose}
>
<div className="editor-body h-75 overflow-auto">
@@ -78,21 +105,7 @@ export const TextEditor = ({
screenreadertext={intl.formatMessage(messages.spinnerScreenReaderText)}
/>
</div>
)
: (
<Editor
{...hooks.editorConfig({
setEditorRef,
blockValue,
openModal,
initializeEditor,
lmsEndpointUrl,
studioEndpointUrl,
setSelection: imageSelection.setSelection,
clearSelection: imageSelection.clearSelection,
})}
/>
)}
) : (selectEditor())}
</div>
</EditorContainer>
@@ -102,6 +115,7 @@ TextEditor.defaultProps = {
blockValue: null,
lmsEndpointUrl: null,
studioEndpointUrl: null,
isRaw: null,
};
TextEditor.propTypes = {
onClose: PropTypes.func.isRequired,
@@ -114,6 +128,7 @@ TextEditor.propTypes = {
blockFailed: PropTypes.bool.isRequired,
blockFinished: PropTypes.bool.isRequired,
initializeEditor: PropTypes.func.isRequired,
isRaw: PropTypes.bool,
// inject
intl: intlShape.isRequired,
};
@@ -124,6 +139,7 @@ export const mapStateToProps = (state) => ({
studioEndpointUrl: selectors.app.studioEndpointUrl(state),
blockFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchBlock }),
blockFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchBlock }),
isRaw: selectors.app.isRaw(state),
});
export const mapDispatchToProps = {

View File

@@ -61,7 +61,8 @@ jest.mock('../../data/redux', () => ({
app: {
blockValue: jest.fn(state => ({ blockValue: state })),
lmsEndpointUrl: jest.fn(state => ({ lmsEndpointUrl: state })),
studioEndpointUrl: jest.fn(state => ({ lmsEndpointUrl: state })),
studioEndpointUrl: jest.fn(state => ({ studioEndpointUrl: state })),
isRaw: jest.fn(state => ({ isRaw: state })),
},
requests: {
isFailed: jest.fn((state, params) => ({ isFailed: { state, params } })),
@@ -80,6 +81,7 @@ describe('TextEditor', () => {
blockFailed: false,
blockFinished: true,
initializeEditor: jest.fn().mockName('args.intializeEditor'),
isRaw: false,
// inject
intl: { formatMessage },
};
@@ -95,6 +97,9 @@ describe('TextEditor', () => {
test('not yet loaded, Spinner appears', () => {
expect(shallow(<TextEditor {...props} blockFinished={false} />)).toMatchSnapshot();
});
test('loaded, raw editor', () => {
expect(shallow(<TextEditor {...props} isRaw />)).toMatchSnapshot();
});
test('block failed to load, Toast is shown', () => {
expect(shallow(<TextEditor {...props} blockFailed />)).toMatchSnapshot();
});

View File

@@ -9,8 +9,9 @@ export const RequestStates = StrictDict({
export const RequestKeys = StrictDict({
fetchBlock: 'fetchBlock',
fetchImages: 'fetchImages',
fetchStudioView: 'fetchStudioView',
fetchUnit: 'fetchUnit',
saveBlock: 'saveBlock',
fetchImages: 'fetchImages',
uploadImage: 'uploadImage',
});

View File

@@ -6,6 +6,7 @@ const initialState = {
blockValue: null,
unitUrl: null,
blockContent: null,
studioView: null,
saveResponse: null,
blockId: null,
@@ -36,6 +37,10 @@ const app = createSlice({
blockValue: payload,
blockTitle: payload.data.display_name,
}),
setStudioView: (state, { payload }) => ({
...state,
studioView: payload,
}),
setBlockContent: (state, { payload }) => ({ ...state, blockContent: payload }),
setBlockTitle: (state, { payload }) => ({ ...state, blockTitle: payload }),
setSaveResponse: (state, { payload }) => ({ ...state, saveResponse: payload }),

View File

@@ -43,6 +43,7 @@ describe('app reducer', () => {
};
[
['setUnitUrl', 'unitUrl'],
['setStudioView', 'studioView'],
['setBlockContent', 'blockContent'],
['setBlockTitle', 'blockTitle'],
['setSaveResponse', 'saveResponse'],

View File

@@ -14,6 +14,7 @@ export const simpleSelectors = {
blockId: mkSimpleSelector(app => app.blockId),
blockType: mkSimpleSelector(app => app.blockType),
blockValue: mkSimpleSelector(app => app.blockValue),
studioView: mkSimpleSelector(app => app.studioView),
learningContextId: mkSimpleSelector(app => app.learningContextId),
editorInitialized: mkSimpleSelector(app => app.editorInitialized),
saveResponse: mkSimpleSelector(app => app.saveResponse),
@@ -55,6 +56,7 @@ export const displayTitle = createSelector(
: blockType[0].toUpperCase() + blockType.substring(1);
},
);
export const analytics = createSelector(
[
module.simpleSelectors.blockId,
@@ -66,10 +68,24 @@ export const analytics = createSelector(
),
);
export const isRaw = createSelector(
[module.simpleSelectors.studioView],
(studioView) => {
if (!studioView || !studioView.data || !studioView.data.html) {
return null;
}
if (studioView.data.html.includes('data-editor="raw"')) {
return true;
}
return false;
},
);
export default {
...simpleSelectors,
isInitialized,
returnUrl,
displayTitle,
analytics,
isRaw,
};

View File

@@ -45,6 +45,7 @@ describe('app selectors unit tests', () => {
simpleKeys.studioEndpointUrl,
simpleKeys.unitUrl,
simpleKeys.blockTitle,
simpleKeys.studioView,
].map(testSimpleSelector);
});
});
@@ -111,4 +112,31 @@ describe('app selectors unit tests', () => {
expect(selectors.displayTitle.cb('random', null)).toEqual('Random');
});
});
describe('isRaw', () => {
const studioViewRaw = {
data: {
html: 'data-editor="raw"',
},
};
const studioViewVisual = {
data: {
html: 'sOmEthIngElse',
},
};
it('is memoized based on studioView', () => {
expect(selectors.isRaw.preSelectors).toEqual([
simpleSelectors.studioView,
]);
});
it('returns null if studioView is null', () => {
expect(selectors.isRaw.cb(null)).toEqual(null);
});
it('returns true if studioView is raw', () => {
expect(selectors.isRaw.cb(studioViewRaw)).toEqual(true);
});
it('returns false if the studioView is not Raw', () => {
expect(selectors.isRaw.cb(studioViewVisual)).toEqual(false);
});
});
});

View File

@@ -7,9 +7,11 @@ import { RequestStates, RequestKeys } from '../../constants/requests';
const initialState = {
[RequestKeys.fetchUnit]: { status: RequestStates.inactive },
[RequestKeys.fetchBlock]: { status: RequestStates.inactive },
[RequestKeys.fetchStudioView]: { status: RequestStates.inactive },
[RequestKeys.saveBlock]: { status: RequestStates.inactive },
[RequestKeys.fetchImages]: { status: RequestStates.inactive },
[RequestKeys.uploadImage]: { status: RequestStates.inactive },
};
// eslint-disable-next-line no-unused-vars

View File

@@ -10,6 +10,13 @@ export const fetchBlock = () => (dispatch) => {
}));
};
export const fetchStudioView = () => (dispatch) => {
dispatch(requests.fetchStudioView({
onSuccess: (response) => dispatch(actions.app.setStudioView(response)),
onFailure: (e) => dispatch(actions.app.setStudioView(e)),
}));
};
export const fetchUnit = () => (dispatch) => {
dispatch(requests.fetchUnit({
onSuccess: (response) => dispatch(actions.app.setUnitUrl(response)),
@@ -27,6 +34,7 @@ export const initialize = (data) => (dispatch) => {
dispatch(actions.app.initialize(data));
dispatch(module.fetchBlock());
dispatch(module.fetchUnit());
dispatch(module.fetchStudioView());
};
/**
@@ -67,4 +75,5 @@ export default StrictDict({
fetchImages,
uploadImage,
fetchVideos,
fetchStudioView,
});

View File

@@ -8,6 +8,7 @@ jest.mock('./requests', () => ({
saveBlock: (args) => ({ saveBlock: args }),
fetchImages: (args) => ({ fetchImages: args }),
uploadImage: (args) => ({ uploadImage: args }),
fetchStudioView: (args) => ({ fetchStudioView: args }),
}));
jest.mock('../../../utils', () => ({
@@ -37,6 +38,27 @@ describe('app thunkActions', () => {
expect(dispatch).toHaveBeenCalledWith(actions.app.setBlockValue(testValue));
});
});
describe('fetchStudioView', () => {
beforeEach(() => {
thunkActions.fetchStudioView()(dispatch);
[[dispatchedAction]] = dispatch.mock.calls;
});
it('dispatches fetchStudioView action', () => {
expect(dispatchedAction.fetchStudioView).not.toEqual(undefined);
});
it('dispatches actions.app.setStudioViewe on success', () => {
dispatch.mockClear();
dispatchedAction.fetchStudioView.onSuccess(testValue);
expect(dispatch).toHaveBeenCalledWith(actions.app.setStudioView(testValue));
});
it('dispatches setStudioView on failure', () => {
dispatch.mockClear();
dispatchedAction.fetchStudioView.onFailure(testValue);
expect(dispatch).toHaveBeenCalledWith(actions.app.setStudioView(testValue));
});
});
describe('fetchUnit', () => {
beforeEach(() => {
thunkActions.fetchUnit()(dispatch);
@@ -58,17 +80,20 @@ describe('app thunkActions', () => {
});
describe('initialize', () => {
it('dispatches actions.app.initialize, and then fetches both block and unit', () => {
const { fetchBlock, fetchUnit } = thunkActions;
const { fetchBlock, fetchUnit, fetchStudioView } = thunkActions;
thunkActions.fetchBlock = () => 'fetchBlock';
thunkActions.fetchUnit = () => 'fetchUnit';
thunkActions.fetchStudioView = () => 'fetchStudioView';
thunkActions.initialize(testValue)(dispatch);
expect(dispatch.mock.calls).toEqual([
[actions.app.initialize(testValue)],
[thunkActions.fetchBlock()],
[thunkActions.fetchUnit()],
[thunkActions.fetchStudioView()],
]);
thunkActions.fetchBlock = fetchBlock;
thunkActions.fetchUnit = fetchUnit;
thunkActions.fetchStudioView = fetchStudioView;
});
});
describe('saveBlock', () => {

View File

@@ -50,6 +50,23 @@ export const fetchBlock = ({ ...rest }) => (dispatch, getState) => {
}));
};
/**
* Tracked fetchStudioView api method.
* Tracked to the `fetchBlock` request key.
* @param {[func]} onSuccess - onSuccess method ((response) => { ... })
* @param {[func]} onFailure - onFailure method ((error) => { ... })
*/
export const fetchStudioView = ({ ...rest }) => (dispatch, getState) => {
dispatch(module.networkRequest({
requestKey: RequestKeys.fetchStudioView,
promise: api.fetchStudioView({
studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
blockId: selectors.app.blockId(getState()),
}),
...rest,
}));
};
/**
* Tracked fetchByUnitId api method.
* Tracked to the `fetchUnit` request key.
@@ -111,9 +128,10 @@ export const fetchImages = ({ ...rest }) => (dispatch, getState) => {
};
export default StrictDict({
uploadImage,
fetchImages,
fetchUnit,
fetchBlock,
fetchImages,
fetchStudioView,
fetchUnit,
saveBlock,
uploadImage,
});

View File

@@ -18,6 +18,7 @@ jest.mock('../app/selectors', () => ({
jest.mock('../../services/cms/api', () => ({
fetchBlockById: ({ id, url }) => ({ id, url }),
fetchStudioView: ({ id, url }) => ({ id, url }),
fetchByUnitId: ({ id, url }) => ({ id, url }),
saveBlock: (args) => args,
fetchImages: ({ id, url }) => ({ id, url }),
@@ -191,6 +192,21 @@ describe('requests thunkActions module', () => {
},
});
});
describe('fetchStudioView', () => {
testNetworkRequestAction({
action: requests.fetchStudioView,
args: fetchParams,
expectedString: 'with fetchStudioView promise',
expectedData: {
...fetchParams,
requestKey: RequestKeys.fetchStudioView,
promise: api.fetchStudioView({
studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
blockId: selectors.app.blockId(testState),
}),
},
});
});
describe('fetchImages', () => {
let fetchImages;

View File

@@ -11,6 +11,9 @@ export const apiMethods = {
fetchByUnitId: ({ blockId, studioEndpointUrl }) => get(
urls.blockAncestor({ studioEndpointUrl, blockId }),
),
fetchStudioView: ({ blockId, studioEndpointUrl }) => get(
urls.blockStudioView({ studioEndpointUrl, blockId }),
),
fetchImages: ({ learningContextId, studioEndpointUrl }) => get(
urls.courseImages({ studioEndpointUrl, learningContextId }),
),

View File

@@ -15,6 +15,7 @@ jest.mock('../../../utils', () => {
jest.mock('./urls', () => ({
block: jest.fn().mockName('urls.block'),
blockAncestor: jest.fn().mockName('urls.blockAncestor'),
blockStudioView: jest.fn().mockName('urls.StudioView'),
courseImages: jest.fn().mockName('urls.courseImages'),
courseAssets: jest.fn().mockName('urls.courseAssets'),
}));
@@ -50,6 +51,13 @@ describe('cms api', () => {
});
});
describe('fetchStudioView', () => {
it('should call get with url.blockStudioView', () => {
apiMethods.fetchStudioView({ blockId, studioEndpointUrl });
expect(get).toHaveBeenCalledWith(urls.blockStudioView({ studioEndpointUrl, blockId }));
});
});
describe('fetchImages', () => {
it('should call get with url.courseImages', () => {
apiMethods.fetchImages({ learningContextId, studioEndpointUrl });

View File

@@ -13,11 +13,22 @@ export const fetchBlockById = ({ blockId, studioEndpointUrl }) => mockPromise({
},
});
// TODO: update to return block data appropriate per block ID, which will equal block type
// eslint-disable-next-line
export const fetchStudioView = ({ blockId, studioEndpointUrl }) => mockPromise({
data: {
data_editor: 'raw',
data: '<p>Test prompt content</p>',
display_name: 'My Text Prompt',
},
});
// TODO: update to return block data appropriate per block ID, which will equal block type
// eslint-disable-next-line
export const fetchByUnitId = ({ blockId, studioEndpointUrl }) => mockPromise({
data: { ancestors: [{ id: 'unitUrl' }] },
});
// eslint-disable-next-line
export const fetchImages = ({ learningContextId, studioEndpointUrl }) => mockPromise({
data: {

View File

@@ -23,6 +23,10 @@ export const blockAncestor = ({ studioEndpointUrl, blockId }) => (
`${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`
);
export const blockStudioView = ({ studioEndpointUrl, blockId }) => (
`${block({ studioEndpointUrl, blockId })}/studio_view`
);
export const courseAssets = ({ studioEndpointUrl, learningContextId }) => (
`${studioEndpointUrl}/assets/${learningContextId}/`
);

View File

@@ -4,6 +4,7 @@ import {
libraryV1,
block,
blockAncestor,
blockStudioView,
courseAssets,
courseImages,
} from './urls';
@@ -57,6 +58,13 @@ describe('cms url methods', () => {
.toEqual(`${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`);
});
});
describe('blockStudioView', () => {
it('returns url with studioEndpointUrl, blockId and studio_view query', () => {
expect(blockStudioView({ studioEndpointUrl, blockId }))
.toEqual(`${block({ studioEndpointUrl, blockId })}/studio_view`);
});
});
describe('courseAssets', () => {
it('returns url with studioEndpointUrl and learningContextId', () => {
expect(courseAssets({ studioEndpointUrl, learningContextId }))