!refactor: Breaking Change refactor use Redux. No release
This commit is contained in:
86
src/editors/Editor.jsx
Normal file
86
src/editors/Editor.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { blockTypes } from './data/constants/app';
|
||||
import { thunkActions } from './data/redux';
|
||||
|
||||
import TextEditor from './containers/TextEditor/TextEditor';
|
||||
import VideoEditor from './containers/VideoEditor/VideoEditor';
|
||||
import ProblemEditor from './containers/ProblemEditor/ProblemEditor';
|
||||
import EditorFooter from './components/EditorFooter';
|
||||
import EditorHeader from './components/EditorHeader';
|
||||
|
||||
import messages from './messages';
|
||||
import * as hooks from './hooks';
|
||||
|
||||
export const supportedEditors = {
|
||||
[blockTypes.html]: TextEditor,
|
||||
[blockTypes.video]: VideoEditor,
|
||||
[blockTypes.problem]: ProblemEditor,
|
||||
};
|
||||
|
||||
export const Editor = ({
|
||||
courseId,
|
||||
blockType,
|
||||
blockId,
|
||||
studioEndpointUrl,
|
||||
// redux
|
||||
initialize,
|
||||
}) => {
|
||||
hooks.initializeApp({
|
||||
initialize,
|
||||
data: {
|
||||
blockId,
|
||||
blockType,
|
||||
courseId,
|
||||
studioEndpointUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const { editorRef, refReady, setEditorRef } = hooks.prepareEditorRef();
|
||||
|
||||
const EditorComponent = supportedEditors[blockType];
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column vh-100">
|
||||
<div
|
||||
className="pgn__modal-fullscreen"
|
||||
role="dialog"
|
||||
aria-label={blockType}
|
||||
>
|
||||
{refReady && (
|
||||
<>
|
||||
<EditorHeader editorRef={editorRef} />
|
||||
{(EditorComponent !== undefined)
|
||||
? <EditorComponent {...{ setEditorRef }} />
|
||||
: <FormattedMessage {...messages.couldNotFindEditor} />}
|
||||
<EditorFooter editorRef={editorRef} />
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Editor.defaultProps = {
|
||||
courseId: null,
|
||||
blockId: null,
|
||||
studioEndpointUrl: null,
|
||||
};
|
||||
|
||||
Editor.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
blockType: PropTypes.string.isRequired,
|
||||
blockId: PropTypes.string,
|
||||
studioEndpointUrl: PropTypes.string,
|
||||
// redux
|
||||
initialize: PropTypes.func.isRequired,
|
||||
};
|
||||
export const mapStateToProps = () => ({});
|
||||
export const mapDispatchToProps = {
|
||||
initialize: thunkActions.app.initialize,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Editor);
|
||||
@@ -1,77 +0,0 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import {
|
||||
Spinner, ActionRow, Button, ModalDialog, Toast,
|
||||
} from '@edx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import EditorPageContext from './EditorPageContext';
|
||||
import { ActionStates } from './data/constants';
|
||||
|
||||
const navigateAway = (destination) => {
|
||||
window.location.assign(destination);
|
||||
};
|
||||
|
||||
export default function EditorFooter() {
|
||||
const {
|
||||
blockLoading,
|
||||
setBlockContent,
|
||||
unitUrlLoading,
|
||||
unitUrl,
|
||||
setSaveUnderway,
|
||||
saveUnderway,
|
||||
saveResponse,
|
||||
studioEndpointUrl,
|
||||
editorRef,
|
||||
} = useContext(EditorPageContext);
|
||||
|
||||
const onSaveClicked = () => {
|
||||
if (blockLoading === ActionStates.FINISHED && unitUrlLoading === ActionStates.FINISHED && editorRef) {
|
||||
const content = editorRef.current.getContent();
|
||||
setBlockContent(content);
|
||||
setSaveUnderway(ActionStates.IN_PROGRESS);
|
||||
}
|
||||
};
|
||||
const onCancelClicked = () => {
|
||||
if (unitUrlLoading === ActionStates.FINISHED) {
|
||||
const destination = `${studioEndpointUrl}/container/${unitUrl.data.ancestors[0].id}`;
|
||||
navigateAway(destination);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (saveUnderway === ActionStates.FINISHED
|
||||
&& blockLoading === ActionStates.FINISHED
|
||||
&& unitUrlLoading === ActionStates.FINISHED) {
|
||||
const destination = `${studioEndpointUrl}/container/${unitUrl.data.ancestors[0].id}`;
|
||||
navigateAway(destination);
|
||||
}
|
||||
}, [saveUnderway]);
|
||||
return (
|
||||
<div className="editor-footer mt-auto">
|
||||
{ saveUnderway === 'complete' && saveResponse.error != null
|
||||
&& (
|
||||
<Toast><FormattedMessage
|
||||
id="authoring.editorfooter.save.error"
|
||||
defaultMessage="Error: Content save failed. Try again later."
|
||||
description="Error message displayed when content fails to save."
|
||||
/>
|
||||
</Toast>
|
||||
)}
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<ActionRow.Spacer />
|
||||
<Button aria-label="Discard Changes and Return to Learning Context" variant="tertiary" onClick={onCancelClicked}>Cancel</Button>
|
||||
<Button aria-label="Save Changes and Return to Learning Context" onClick={onSaveClicked}>
|
||||
{unitUrlLoading !== ActionStates.FINISHED
|
||||
? <Spinner animation="border" className="mr-3" />
|
||||
: (
|
||||
<FormattedMessage
|
||||
id="authoring.editorfooter.savebutton.label"
|
||||
defaultMessage="Add To Course"
|
||||
description="Label for Save button"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import {
|
||||
ActionRow, IconButton, Icon, ModalDialog,
|
||||
} from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useContext } from 'react';
|
||||
import { Close } from '@edx/paragon/icons';
|
||||
import EditorPageContext from './EditorPageContext';
|
||||
import { ActionStates, mapBlockTypeToName } from './data/constants';
|
||||
|
||||
const EditorHeader = ({ title }) => {
|
||||
const { unitUrl, unitUrlLoading, studioEndpointUrl } = useContext(EditorPageContext);
|
||||
|
||||
const onCancelClicked = () => {
|
||||
if (unitUrlLoading === ActionStates.FINISHED) {
|
||||
const destination = `${studioEndpointUrl}/container/${unitUrl.data.ancestors[0].id}`;
|
||||
window.location.assign(destination);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="editor-header">
|
||||
<ModalDialog.Header>
|
||||
<ActionRow>
|
||||
<ModalDialog.Title>
|
||||
{mapBlockTypeToName(title)}
|
||||
</ModalDialog.Title>
|
||||
<ActionRow.Spacer />
|
||||
<IconButton
|
||||
aria-label="Cancel Changes and Return to Learning Context"
|
||||
src={Close}
|
||||
iconAs={Icon}
|
||||
alt="Close"
|
||||
onClick={onCancelClicked}
|
||||
variant="light"
|
||||
className="mr-2"
|
||||
/>
|
||||
</ActionRow>
|
||||
</ModalDialog.Header>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
EditorHeader.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
export default EditorHeader;
|
||||
@@ -1,67 +1,38 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import TextEditor from './TextEditor/TextEditor';
|
||||
import VideoEditor from './VideoEditor/VideoEditor';
|
||||
import ProblemEditor from './ProblemEditor/ProblemEditor';
|
||||
import EditorFooter from './EditorFooter';
|
||||
import EditorHeader from './EditorHeader';
|
||||
import EditorPageProvider from './EditorPageProvider';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
export default function EditorPage({
|
||||
import store from './data/store';
|
||||
import Editor from './Editor';
|
||||
|
||||
export const EditorPage = ({
|
||||
courseId,
|
||||
blockType,
|
||||
blockId,
|
||||
studioEndpointUrl,
|
||||
}) {
|
||||
const selectEditor = (type) => {
|
||||
switch (type) {
|
||||
case 'html':
|
||||
return <TextEditor />;
|
||||
case 'video':
|
||||
return <VideoEditor />;
|
||||
case 'problem':
|
||||
return <ProblemEditor />;
|
||||
default:
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="authoring.editorpage.selecteditor.error"
|
||||
defaultMessage="Error: Could Not find Editor"
|
||||
description="Error Message Dispayed When An unsopported Editor is desired in V2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}) => (
|
||||
<Provider store={store}>
|
||||
<Editor
|
||||
{...{
|
||||
courseId,
|
||||
blockType,
|
||||
blockId,
|
||||
studioEndpointUrl,
|
||||
}}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
EditorPage.defaultProps = {
|
||||
courseId: null,
|
||||
blockId: null,
|
||||
studioEndpointUrl: null,
|
||||
};
|
||||
|
||||
return (
|
||||
<EditorPageProvider
|
||||
blockType={blockType}
|
||||
courseId={courseId}
|
||||
blockId={blockId}
|
||||
studioEndpointUrl={studioEndpointUrl}
|
||||
>
|
||||
<div className="d-flex flex-column vh-100">
|
||||
<div
|
||||
className="pgn__modal-fullscreen"
|
||||
role="dialog"
|
||||
aria-label={blockType}
|
||||
>
|
||||
<EditorHeader title={blockType} />
|
||||
{selectEditor(blockType)}
|
||||
<EditorFooter />
|
||||
</div>
|
||||
</div>
|
||||
</EditorPageProvider>
|
||||
);
|
||||
}
|
||||
EditorPage.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
blockType: PropTypes.string.isRequired,
|
||||
blockId: PropTypes.string,
|
||||
studioEndpointUrl: PropTypes.string,
|
||||
};
|
||||
EditorPage.defaultProps = {
|
||||
courseId: null,
|
||||
blockId: null,
|
||||
studioEndpointUrl: null,
|
||||
};
|
||||
|
||||
export default EditorPage;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const EditorPageContext = React.createContext();
|
||||
export default EditorPageContext;
|
||||
@@ -1,97 +0,0 @@
|
||||
import React, {
|
||||
useState, useEffect, useMemo,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { fetchBlockById, fetchUnitById, saveBlock } from './data/api';
|
||||
import EditorPageContext from './EditorPageContext';
|
||||
import { ActionStates } from './data/constants';
|
||||
|
||||
/* This Component serves as a container for state for V2 editors,
|
||||
to avoid prop drilling for: saving, loading, and navigating away from content. */
|
||||
|
||||
const EditorPageProvider = ({
|
||||
blockType, courseId, blockId, studioEndpointUrl, children,
|
||||
}) => {
|
||||
const editorRef = React.useRef(null);
|
||||
const [blockValue, setBlockValue] = useState(null); // this is the intial block, as called in from the api.
|
||||
const [blockError, setBlockError] = useState(null);
|
||||
const [blockLoading, setBlockLoading] = useState(ActionStates.NOT_BEGUN);
|
||||
const [unitUrl, setUnitUrlValue] = useState(null);
|
||||
const [unitUrlError, setUnitUrlError] = useState(null);
|
||||
const [unitUrlLoading, setUnitUrlLoading] = useState(ActionStates.NOT_BEGUN);
|
||||
const [blockContent, setBlockContent] = useState(null); // This is the updated content to be saved via api call
|
||||
const [saveResponse, setSaveResponse] = useState(null);
|
||||
const [saveUnderway, setSaveUnderway] = useState(ActionStates.NOT_BEGUN);
|
||||
|
||||
/* We memoize the context value, so it it is only updated
|
||||
(and therefore only causes a re-render of the consumers of this provider)
|
||||
when blockLoading, unitUrlLoading, or saveUnderway change */
|
||||
const value = useMemo(() => ({
|
||||
editorRef,
|
||||
blockValue,
|
||||
blockError,
|
||||
blockLoading,
|
||||
unitUrl,
|
||||
unitUrlError,
|
||||
unitUrlLoading,
|
||||
setBlockContent,
|
||||
saveResponse,
|
||||
setSaveUnderway,
|
||||
saveUnderway,
|
||||
studioEndpointUrl,
|
||||
blockId,
|
||||
courseId,
|
||||
blockType,
|
||||
}), [blockLoading, unitUrlLoading, saveUnderway]);
|
||||
|
||||
useEffect(() => {
|
||||
// On init, begin fetching data
|
||||
if (unitUrlLoading === ActionStates.NOT_BEGUN) {
|
||||
fetchUnitById({
|
||||
setValue: setUnitUrlValue,
|
||||
setError: setUnitUrlError,
|
||||
setLoading: setUnitUrlLoading,
|
||||
}, blockId, studioEndpointUrl);
|
||||
}
|
||||
if (blockLoading === ActionStates.NOT_BEGUN) {
|
||||
fetchBlockById(
|
||||
{
|
||||
setValue: setBlockValue,
|
||||
setError: setBlockError,
|
||||
setLoading: setBlockLoading,
|
||||
}, blockId, studioEndpointUrl,
|
||||
);
|
||||
}
|
||||
if (saveUnderway === ActionStates.IN_PROGRESS) {
|
||||
saveBlock(
|
||||
blockId,
|
||||
blockType,
|
||||
courseId,
|
||||
studioEndpointUrl,
|
||||
blockContent,
|
||||
{ setInProgress: setSaveUnderway, setResponse: setSaveResponse },
|
||||
);
|
||||
}
|
||||
}, [saveUnderway]);
|
||||
|
||||
return (
|
||||
<EditorPageContext.Provider
|
||||
value={value}
|
||||
>
|
||||
{children}
|
||||
</EditorPageContext.Provider>
|
||||
);
|
||||
};
|
||||
EditorPageProvider.propTypes = {
|
||||
blockType: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
blockId: PropTypes.string.isRequired,
|
||||
studioEndpointUrl: PropTypes.string,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
EditorPageProvider.defaultProps = {
|
||||
|
||||
studioEndpointUrl: null,
|
||||
};
|
||||
|
||||
export default EditorPageProvider;
|
||||
@@ -1,78 +0,0 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { Editor } from '@tinymce/tinymce-react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
useToggle, Spinner, Toast,
|
||||
} from '@edx/paragon';
|
||||
import EditorPageContext from '../EditorPageContext';
|
||||
import { ActionStates } from '../data/constants';
|
||||
import ImageUploadModal from './ImageUpload/Wizard/ImageUploadModal';
|
||||
|
||||
import 'tinymce';
|
||||
import 'tinymce/themes/silver';
|
||||
import 'tinymce/skins/ui/oxide/skin.css';
|
||||
import 'tinymce/icons/default';
|
||||
import 'tinymce/plugins/link';
|
||||
import 'tinymce/plugins/table';
|
||||
import 'tinymce/plugins/codesample';
|
||||
import 'tinymce/plugins/emoticons';
|
||||
import 'tinymce/plugins/emoticons/js/emojis';
|
||||
import 'tinymce/plugins/charmap';
|
||||
import 'tinymce/plugins/code';
|
||||
import 'tinymce/plugins/autoresize';
|
||||
|
||||
const TextEditor = () => {
|
||||
const {
|
||||
blockValue, blockError, blockLoading, editorRef,
|
||||
} = useContext(EditorPageContext);
|
||||
|
||||
const [isImageUploadModalOpen, openUploadModal, closeUploadModal] = useToggle(false);
|
||||
|
||||
return (
|
||||
<div className="editor-body h-75">
|
||||
<ImageUploadModal isOpen={isImageUploadModalOpen} close={closeUploadModal} />
|
||||
<Toast show={blockError != null} onClose={() => {}}>
|
||||
<FormattedMessage
|
||||
id="authoring.texteditor.load.error"
|
||||
defaultMessage="Error: Could Not Load Text Content"
|
||||
description="Error Message Dispayed When HTML content fails to Load"
|
||||
/>
|
||||
</Toast>
|
||||
{blockLoading !== ActionStates.FINISHED
|
||||
? (
|
||||
<div className="text-center p-6">
|
||||
<Spinner animation="border" className="m-3" screenreadertext="loading" />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<Editor
|
||||
onInit={(evt, editor) => {
|
||||
editorRef.current = editor;
|
||||
}}
|
||||
initialValue={blockValue ? blockValue.data.data : ''}
|
||||
init={{
|
||||
setup: (editor) => {
|
||||
editor.ui.registry.addButton('imageuploadbutton', {
|
||||
icon: 'image',
|
||||
onAction: () => openUploadModal(),
|
||||
});
|
||||
},
|
||||
plugins: 'link codesample emoticons table charmap code autoresize',
|
||||
menubar: false,
|
||||
toolbar: 'undo redo | formatselect | '
|
||||
+ 'bold italic backcolor | alignleft aligncenter '
|
||||
+ 'alignright alignjustify | bullist numlist outdent indent |'
|
||||
+ 'imageuploadbutton | link | emoticons | table | codesample | charmap |'
|
||||
+ 'removeformat | hr |code',
|
||||
height: '100%',
|
||||
content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }',
|
||||
min_height: 1000,
|
||||
branding: false,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextEditor;
|
||||
89
src/editors/components/EditorFooter/index.jsx
Normal file
89
src/editors/components/EditorFooter/index.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
Spinner,
|
||||
ActionRow,
|
||||
Button,
|
||||
ModalDialog,
|
||||
Toast,
|
||||
} from '@edx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { RequestKeys } from '../../data/constants/requests';
|
||||
import { selectors, thunkActions } from '../../data/redux';
|
||||
import { saveTextBlock, navigateCallback } from '../../hooks';
|
||||
import messages from '../messages';
|
||||
import * as module from '.';
|
||||
|
||||
export const handleSaveClicked = (props) => () => saveTextBlock(props);
|
||||
export const handleCancelClicked = ({ returnUrl }) => navigateCallback(returnUrl);
|
||||
|
||||
export const EditorFooter = ({
|
||||
editorRef,
|
||||
// redux
|
||||
isInitialized,
|
||||
returnUrl,
|
||||
saveFailed,
|
||||
saveBlock,
|
||||
}) => (
|
||||
<div className="editor-footer mt-auto">
|
||||
{saveFailed && (
|
||||
<Toast><FormattedMessage {...messages.contentSaveFailed} /></Toast>
|
||||
)}
|
||||
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<ActionRow.Spacer />
|
||||
<Button
|
||||
aria-label="Discard Changes and Return to Learning Context"
|
||||
variant="tertiary"
|
||||
onClick={module.handleCancelClicked({ returnUrl })}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Save Changes and Return to Learning Context"
|
||||
onClick={module.handleSaveClicked({
|
||||
editorRef,
|
||||
returnUrl,
|
||||
saveBlock,
|
||||
})}
|
||||
disabled={!isInitialized}
|
||||
>
|
||||
{isInitialized
|
||||
? <FormattedMessage {...messages.addToCourse} />
|
||||
: <Spinner animation="border" className="mr-3" />}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</div>
|
||||
);
|
||||
EditorFooter.defaultProps = {
|
||||
editorRef: null,
|
||||
returnUrl: null,
|
||||
};
|
||||
EditorFooter.propTypes = {
|
||||
editorRef: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.any }),
|
||||
]),
|
||||
// redux
|
||||
isInitialized: PropTypes.bool.isRequired,
|
||||
returnUrl: PropTypes.string,
|
||||
saveFailed: PropTypes.bool.isRequired,
|
||||
saveBlock: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
isInitialized: selectors.app.isInitialized(state),
|
||||
saveFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.saveBlock }),
|
||||
studioEndpointUrl: selectors.app.studioEndpointUrl(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
saveBlock: thunkActions.app.saveBlock,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EditorFooter);
|
||||
BIN
src/editors/components/EditorHeader/.HeaderTitle.jsx.swp
Normal file
BIN
src/editors/components/EditorHeader/.HeaderTitle.jsx.swp
Normal file
Binary file not shown.
41
src/editors/components/EditorHeader/EditableHeader.jsx
Normal file
41
src/editors/components/EditorHeader/EditableHeader.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Icon, Form } from '@edx/paragon';
|
||||
import { Edit } from '@edx/paragon/icons';
|
||||
|
||||
export const EditableHeader = ({
|
||||
handleChange,
|
||||
updateTitle,
|
||||
handleKeyDown,
|
||||
inputRef,
|
||||
localTitle,
|
||||
}) => (
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
autoFocus
|
||||
onBlur={updateTitle}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Title"
|
||||
ref={inputRef}
|
||||
trailingInputElement={<Icon src={Edit} />}
|
||||
value={localTitle}
|
||||
/>
|
||||
</Form.Group>
|
||||
);
|
||||
EditableHeader.defaultProps = {
|
||||
inputRef: null,
|
||||
};
|
||||
EditableHeader.propTypes = {
|
||||
inputRef: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.any }),
|
||||
]),
|
||||
handleChange: PropTypes.func.isRequired,
|
||||
updateTitle: PropTypes.func.isRequired,
|
||||
handleKeyDown: PropTypes.func.isRequired,
|
||||
localTitle: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default EditableHeader;
|
||||
90
src/editors/components/EditorHeader/HeaderTitle.jsx
Normal file
90
src/editors/components/EditorHeader/HeaderTitle.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Icon, IconButton } from '@edx/paragon';
|
||||
import { Edit } from '@edx/paragon/icons';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { actions, selectors } from '../../data/redux';
|
||||
import messages from '../messages';
|
||||
import EditableHeader from './EditableHeader';
|
||||
import { localTitleHooks } from './hooks';
|
||||
|
||||
export const HeaderTitle = ({
|
||||
editorRef,
|
||||
isInitialized,
|
||||
setBlockTitle,
|
||||
typeHeader,
|
||||
}) => {
|
||||
if (!isInitialized) { return <FormattedMessage {...messages.loading} />; }
|
||||
|
||||
console.log('HeaderTitle');
|
||||
const {
|
||||
inputRef,
|
||||
isEditing,
|
||||
handleChange,
|
||||
handleKeyDown,
|
||||
localTitle,
|
||||
startEditing,
|
||||
updateTitle,
|
||||
} = localTitleHooks({
|
||||
editorRef,
|
||||
setBlockTitle,
|
||||
typeHeader,
|
||||
});
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<EditableHeader
|
||||
{...{
|
||||
inputRef,
|
||||
handleChange,
|
||||
handleKeyDown,
|
||||
localTitle,
|
||||
updateTitle,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="d-flex">
|
||||
<div style={{ lineHeight: '1.5', paddingRight: '.25em' }}>
|
||||
{localTitle}
|
||||
</div>
|
||||
<IconButton
|
||||
iconAs={Icon}
|
||||
src={Edit}
|
||||
onClick={startEditing}
|
||||
alt="Edit"
|
||||
aria-label="Edit Title"
|
||||
className="mr-2"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
HeaderTitle.defaultProps = {
|
||||
editorRef: null,
|
||||
};
|
||||
HeaderTitle.propTypes = {
|
||||
editorRef: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.any }),
|
||||
]),
|
||||
// redux
|
||||
isInitialized: PropTypes.bool.isRequired,
|
||||
setBlockTitle: PropTypes.func.isRequired,
|
||||
typeHeader: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
typeHeader: selectors.app.typeHeader(state),
|
||||
isInitialized: selectors.app.isInitialized(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
setBlockTitle: actions.app.setBlockTitle,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(HeaderTitle);
|
||||
42
src/editors/components/EditorHeader/hooks.js
Normal file
42
src/editors/components/EditorHeader/hooks.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const localTitleHooks = ({
|
||||
editorRef,
|
||||
setBlockTitle,
|
||||
typeHeader,
|
||||
}) => {
|
||||
console.log('localTitleHooks');
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const startEditing = () => setIsEditing(true);
|
||||
const stopEditing = () => setIsEditing(false);
|
||||
const [localTitle, setLocalTitle] = React.useState(typeHeader);
|
||||
const inputRef = React.createRef();
|
||||
const updateTitle = () => {
|
||||
setBlockTitle(localTitle);
|
||||
stopEditing();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
stopEditing();
|
||||
}
|
||||
if (e.key === 'Tab' && editorRef) {
|
||||
e.preventDefault();
|
||||
editorRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => setLocalTitle(e.target.value);
|
||||
|
||||
return {
|
||||
isEditing,
|
||||
handleChange,
|
||||
startEditing,
|
||||
stopEditing,
|
||||
localTitle,
|
||||
inputRef,
|
||||
handleKeyDown,
|
||||
updateTitle,
|
||||
};
|
||||
};
|
||||
43
src/editors/components/EditorHeader/index.jsx
Normal file
43
src/editors/components/EditorHeader/index.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
ActionRow, IconButton, Icon, ModalDialog,
|
||||
} from '@edx/paragon';
|
||||
import { Close } from '@edx/paragon/icons';
|
||||
|
||||
import { selectors } from '../../data/redux';
|
||||
import * as appHooks from '../../hooks';
|
||||
|
||||
import HeaderTitle from './HeaderTitle';
|
||||
|
||||
const EditorHeader = ({
|
||||
returnUrl,
|
||||
}) => (
|
||||
<div className="editor-header">
|
||||
<ModalDialog.Header>
|
||||
<ActionRow>
|
||||
<ModalDialog.Title><HeaderTitle /></ModalDialog.Title>
|
||||
<ActionRow.Spacer />
|
||||
<IconButton
|
||||
aria-label="Cancel Changes and Return to Learning Context"
|
||||
src={Close}
|
||||
iconAs={Icon}
|
||||
alt="Close"
|
||||
onClick={appHooks.navigateCallback(returnUrl)}
|
||||
variant="light"
|
||||
className="mr-2"
|
||||
/>
|
||||
</ActionRow>
|
||||
</ModalDialog.Header>
|
||||
</div>
|
||||
);
|
||||
EditorHeader.propTypes = {
|
||||
returnUrl: PropTypes.string.isRequired,
|
||||
};
|
||||
export const mapStateToProps = (state) => ({
|
||||
returnUrl: selectors.app.returnUrl(state),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(EditorHeader);
|
||||
19
src/editors/components/messages.js
Normal file
19
src/editors/components/messages.js
Normal file
@@ -0,0 +1,19 @@
|
||||
export const messages = {
|
||||
contentSaveFailed: {
|
||||
id: 'authoring.editorfooter.save.error',
|
||||
defaultMessage: 'Error: Content save failed. Try again later.',
|
||||
description: 'Error message displayed when content fails to save.',
|
||||
},
|
||||
addToCourse: {
|
||||
id: 'authoring.editorfooter.savebutton.label',
|
||||
defaultMessage: 'Save',
|
||||
description: 'Label for Save button',
|
||||
},
|
||||
loading: {
|
||||
id: 'authoring.texteditor.title.loading',
|
||||
description: 'Message displayed while loading content',
|
||||
defaultMessage: 'Loading...',
|
||||
},
|
||||
};
|
||||
|
||||
export default messages;
|
||||
93
src/editors/containers/TextEditor/TextEditor.jsx
Normal file
93
src/editors/containers/TextEditor/TextEditor.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Editor } from '@tinymce/tinymce-react';
|
||||
|
||||
import 'tinymce';
|
||||
import 'tinymce/themes/silver';
|
||||
import 'tinymce/skins/ui/oxide/skin.css';
|
||||
import 'tinymce/icons/default';
|
||||
import 'tinymce/plugins/link';
|
||||
import 'tinymce/plugins/table';
|
||||
import 'tinymce/plugins/codesample';
|
||||
import 'tinymce/plugins/emoticons';
|
||||
import 'tinymce/plugins/emoticons/js/emojis';
|
||||
import 'tinymce/plugins/charmap';
|
||||
import 'tinymce/plugins/code';
|
||||
import 'tinymce/plugins/autoresize';
|
||||
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Spinner,
|
||||
Toast,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { actions, selectors } from '../../data/redux';
|
||||
import { RequestKeys } from '../../data/constants/requests';
|
||||
import {
|
||||
editorConfig,
|
||||
modalToggle,
|
||||
nullMethod,
|
||||
} from './hooks';
|
||||
import messages from './messages';
|
||||
import ImageUploadModal from './ImageUpload/ImageUploadModal';
|
||||
|
||||
export const TextEditor = ({
|
||||
setEditorRef,
|
||||
// redux
|
||||
blockValue,
|
||||
blockFailed,
|
||||
blockFinished,
|
||||
initializeEditor,
|
||||
}) => {
|
||||
console.log({ blockValue, blockFailed, blockFinished, test: 1 });
|
||||
const { isOpen, openModal, closeModal } = modalToggle();
|
||||
|
||||
return (
|
||||
<div className="editor-body h-75">
|
||||
<ImageUploadModal
|
||||
isOpen={isOpen}
|
||||
close={closeModal}
|
||||
/>
|
||||
|
||||
<Toast show={blockFailed} onClose={nullMethod}>
|
||||
<FormattedMessage {...messages.couldNotLoadTextContext} />
|
||||
</Toast>
|
||||
|
||||
{(!blockFinished)
|
||||
? (
|
||||
<div className="text-center p-6">
|
||||
<Spinner animation="border" className="m-3" screenreadertext="loading" />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<Editor {...editorConfig({ setEditorRef, blockValue, openModal, initializeEditor })} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
TextEditor.defaultProps = {
|
||||
blockValue: null,
|
||||
};
|
||||
TextEditor.propTypes = {
|
||||
setEditorRef: PropTypes.func.isRequired,
|
||||
// redux
|
||||
blockValue: PropTypes.shape({
|
||||
data: PropTypes.shape({ data: PropTypes.string }),
|
||||
}),
|
||||
blockFailed: PropTypes.bool.isRequired,
|
||||
blockFinished: PropTypes.bool.isRequired,
|
||||
initializeEditor: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
blockValue: selectors.app.blockValue(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 connect(mapStateToProps, mapDispatchToProps)(TextEditor);
|
||||
50
src/editors/containers/TextEditor/hooks.js
Normal file
50
src/editors/containers/TextEditor/hooks.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react';
|
||||
import * as module from './hooks';
|
||||
|
||||
export const addImageUploadButton = (openModal) => (editor) => {
|
||||
editor.ui.registry.addButton('imageuploadbutton', {
|
||||
icon: 'image',
|
||||
onAction: openModal,
|
||||
});
|
||||
};
|
||||
|
||||
export const initializeEditorRef = (setRef) => (evt, editor) => { setRef(editor); };
|
||||
|
||||
// for toast onClose to avoid console warnings
|
||||
export const nullMethod = () => {};
|
||||
|
||||
export const editorConfig = ({
|
||||
setEditorRef,
|
||||
blockValue,
|
||||
openModal,
|
||||
initializeEditor,
|
||||
}) => ({
|
||||
onInit: () => {
|
||||
module.initializeEditorRef(setEditorRef);
|
||||
initializeEditor();
|
||||
},
|
||||
initialValue: blockValue ? blockValue.data.data : '',
|
||||
init: {
|
||||
setup: module.addImageUploadButton(openModal),
|
||||
plugins: 'link codesample emoticons table charmap code autoresize',
|
||||
menubar: false,
|
||||
toolbar: 'undo redo | formatselect | '
|
||||
+ 'bold italic backcolor | alignleft aligncenter '
|
||||
+ 'alignright alignjustify | bullist numlist outdent indent |'
|
||||
+ 'imageuploadbutton | link | emoticons | table | codesample | charmap |'
|
||||
+ 'removeformat | hr |code',
|
||||
height: '100%',
|
||||
content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }',
|
||||
min_height: 1000,
|
||||
branding: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const modalToggle = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return {
|
||||
isOpen,
|
||||
openModal: () => setIsOpen(true),
|
||||
closeModal: () => setIsOpen(false),
|
||||
};
|
||||
};
|
||||
9
src/editors/containers/TextEditor/messages.js
Normal file
9
src/editors/containers/TextEditor/messages.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export const messages = {
|
||||
couldNotLoadTextContext: {
|
||||
id: 'authoring.texteditor.load.error',
|
||||
defaultMessage: 'Error: Could Not Load Text Content',
|
||||
description: 'Error Message Dispayed When HTML content fails to Load',
|
||||
},
|
||||
};
|
||||
|
||||
export default messages;
|
||||
@@ -1,42 +0,0 @@
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { ActionStates, normalizeContent } from './constants';
|
||||
|
||||
async function getAsync(updateContext, params) {
|
||||
try {
|
||||
updateContext.setLoading(ActionStates.IN_PROGRESS);
|
||||
const result = await getAuthenticatedHttpClient().get(...params);
|
||||
updateContext.setValue(result);
|
||||
} catch (e) {
|
||||
updateContext.setError(e);
|
||||
} finally {
|
||||
updateContext.setLoading(ActionStates.FINISHED);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAsync(updateContext, params) {
|
||||
try {
|
||||
const result = await getAuthenticatedHttpClient().post(...params);
|
||||
updateContext.setResponse(result);
|
||||
} catch (e) {
|
||||
updateContext.setResponse(e);
|
||||
} finally {
|
||||
updateContext.setInProgress(ActionStates.FINISHED);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchBlockById(updateContext, blockId, studioEndpointUrl) {
|
||||
const url = `${studioEndpointUrl}/xblock/${blockId}`;
|
||||
getAsync(updateContext, [url]);
|
||||
}
|
||||
|
||||
export async function fetchUnitById(updateContext, blockId, studioEndpointUrl) {
|
||||
const url = `${studioEndpointUrl}/xblock/${blockId}?fields=ancestorInfo`;
|
||||
getAsync(updateContext, [url]);
|
||||
}
|
||||
|
||||
export async function saveBlock(blockId, blockType, courseId, studioEndpointUrl, content, updateContext) {
|
||||
const normalizedContent = normalizeContent(blockType, content, blockId, courseId);
|
||||
const url = `${studioEndpointUrl}/xblock/${blockId}`;
|
||||
const params = [url, normalizedContent];
|
||||
saveAsync(updateContext, params);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
export function mapBlockTypeToName(blockType) {
|
||||
if (blockType === 'html') {
|
||||
return 'Text';
|
||||
}
|
||||
return blockType[0].toUpperCase() + blockType.substring(1);
|
||||
}
|
||||
// States for async processes
|
||||
export const ActionStates = {
|
||||
NOT_BEGUN: 'not_begun',
|
||||
IN_PROGRESS: 'in_progress',
|
||||
FINISHED: 'finished',
|
||||
};
|
||||
|
||||
export function normalizeContent(blockType, content, blockId, courseId) {
|
||||
/*
|
||||
For Each V2 Block type, return a javascript object which updates the requisite data fields,
|
||||
to be POST-messaged to the CMS.
|
||||
*/
|
||||
switch (blockType) {
|
||||
case 'html':
|
||||
return {
|
||||
id: blockId,
|
||||
category: blockType,
|
||||
has_changes: true,
|
||||
data: content,
|
||||
couseKey: courseId,
|
||||
};
|
||||
default:
|
||||
throw new TypeError(`No Block in V2 Editors named /"${blockType}/", Cannot Save Content.`);
|
||||
}
|
||||
}
|
||||
8
src/editors/data/constants/app.js
Normal file
8
src/editors/data/constants/app.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { StrictDict } from '../../utils';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const blockTypes = StrictDict({
|
||||
html: 'html',
|
||||
video: 'video',
|
||||
problem: 'problem',
|
||||
});
|
||||
20
src/editors/data/constants/requests.js
Normal file
20
src/editors/data/constants/requests.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { StrictDict } from '../../utils';
|
||||
|
||||
export const ReqeustKeys = StrictDict({
|
||||
fetchBlock: 'fetchBlock',
|
||||
fetchUnit: 'fetchUnit',
|
||||
saveBlock: 'saveBlock',
|
||||
});
|
||||
|
||||
export const RequestStates = StrictDict({
|
||||
inactive: 'inactive',
|
||||
pending: 'pending',
|
||||
completed: 'completed',
|
||||
failed: 'failed',
|
||||
});
|
||||
|
||||
export const RequestKeys = StrictDict({
|
||||
fetchBlock: 'fetchBlock',
|
||||
fetchUnit: 'fetchUnit',
|
||||
saveBlock: 'saveBlock',
|
||||
});
|
||||
BIN
src/editors/data/redux/app/.selectors.js.swp
Normal file
BIN
src/editors/data/redux/app/.selectors.js.swp
Normal file
Binary file not shown.
2
src/editors/data/redux/app/index.js
Normal file
2
src/editors/data/redux/app/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { actions, reducer } from './reducer';
|
||||
export { default as selectors } from './selectors';
|
||||
48
src/editors/data/redux/app/reducer.js
Normal file
48
src/editors/data/redux/app/reducer.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { StrictDict } from '../../../utils';
|
||||
|
||||
const initialState = {
|
||||
blockValue: null,
|
||||
unitUrl: null,
|
||||
blockContent: null,
|
||||
saveResponse: null,
|
||||
|
||||
blockId: null,
|
||||
blockTitle: null,
|
||||
blockType: null,
|
||||
courseId: null,
|
||||
editorInitialized: false,
|
||||
studioEndpointUrl: null,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const app = createSlice({
|
||||
name: 'app',
|
||||
initialState,
|
||||
reducers: {
|
||||
initialize: (state, { payload }) => ({
|
||||
...state,
|
||||
studioEndpointUrl: payload.studioEndpointUrl,
|
||||
blockId: payload.blockId,
|
||||
courseId: payload.courseId,
|
||||
blockType: payload.blockType,
|
||||
}),
|
||||
setUnitUrl: (state, { payload }) => ({ ...state, unitUrl: payload }),
|
||||
setBlockValue: (state, { payload }) => ({ ...state, blockValue: payload }),
|
||||
setBlockContent: (state, { payload }) => ({ ...state, blockContent: payload }),
|
||||
setBlockTitle: (state, { payload }) => ({ ...state, blockTitle: payload }),
|
||||
setSaveResponse: (state, { payload }) => ({ ...state, saveResponse: payload }),
|
||||
initializeEditor: (state) => ({ ...state, editorInitialized: true }),
|
||||
},
|
||||
});
|
||||
|
||||
const actions = StrictDict(app.actions);
|
||||
|
||||
const { reducer } = app;
|
||||
|
||||
export {
|
||||
actions,
|
||||
initialState,
|
||||
reducer,
|
||||
};
|
||||
51
src/editors/data/redux/app/selectors.js
Normal file
51
src/editors/data/redux/app/selectors.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { blockTypes } from '../../constants/app';
|
||||
import * as urls from '../../services/lms/urls';
|
||||
import * as module from './selectors';
|
||||
|
||||
export const appSelector = (state) => state.app;
|
||||
|
||||
const mkSimpleSelector = (cb) => createSelector([module.appSelector], cb);
|
||||
|
||||
// top-level app data selectors
|
||||
export const simpleSelectors = {
|
||||
blockContent: mkSimpleSelector(app => app.blockContent),
|
||||
blockId: mkSimpleSelector(app => app.blockId),
|
||||
blockType: mkSimpleSelector(app => app.blockType),
|
||||
blockValue: mkSimpleSelector(app => app.blockValue),
|
||||
courseId: mkSimpleSelector(app => app.courseId),
|
||||
editorInitialized: mkSimpleSelector(app => app.editorInitialized),
|
||||
saveResponse: mkSimpleSelector(app => app.saveResponse),
|
||||
studioEndpointUrl: mkSimpleSelector(app => app.studioEndpointUrl),
|
||||
unitUrl: mkSimpleSelector(app => app.unitUrl),
|
||||
};
|
||||
|
||||
export const returnUrl = createSelector(
|
||||
[module.simpleSelectors.unitUrl, module.simpleSelectors.studioEndpointUrl],
|
||||
(unitUrl, studioEndpointUrl) => (unitUrl ? urls.unit({ studioEndpointUrl, unitUrl }) : ''),
|
||||
);
|
||||
|
||||
export const isInitialized = createSelector(
|
||||
[
|
||||
module.simpleSelectors.unitUrl,
|
||||
module.simpleSelectors.editorInitialized,
|
||||
module.simpleSelectors.blockValue,
|
||||
],
|
||||
(unitUrl, editorInitialized, blockValue) => !!(unitUrl && blockValue && editorInitialized),
|
||||
);
|
||||
|
||||
export const typeHeader = createSelector(
|
||||
[module.simpleSelectors.blockType],
|
||||
(blockType) => ((blockType === blockTypes.html)
|
||||
? 'Text'
|
||||
: blockType[0].toUpperCase() + blockType.substring(1)
|
||||
),
|
||||
);
|
||||
|
||||
export default {
|
||||
...simpleSelectors,
|
||||
isInitialized,
|
||||
returnUrl,
|
||||
typeHeader,
|
||||
};
|
||||
28
src/editors/data/redux/index.js
Normal file
28
src/editors/data/redux/index.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { combineReducers } from 'redux';
|
||||
|
||||
import { StrictDict } from '../../utils';
|
||||
|
||||
import * as app from './app';
|
||||
import * as requests from './requests';
|
||||
|
||||
export { default as thunkActions } from './thunkActions';
|
||||
|
||||
const modules = {
|
||||
app,
|
||||
requests,
|
||||
};
|
||||
|
||||
const moduleProps = (propName) => Object.keys(modules).reduce(
|
||||
(obj, moduleKey) => ({ ...obj, [moduleKey]: modules[moduleKey][propName] }),
|
||||
{},
|
||||
);
|
||||
|
||||
const rootReducer = combineReducers(moduleProps('reducer'));
|
||||
|
||||
const actions = StrictDict(moduleProps('actions'));
|
||||
|
||||
const selectors = StrictDict(moduleProps('selectors'));
|
||||
|
||||
export { actions, selectors };
|
||||
|
||||
export default rootReducer;
|
||||
2
src/editors/data/redux/requests/index.js
Normal file
2
src/editors/data/redux/requests/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { actions, reducer } from './reducer';
|
||||
export { default as selectors } from './selectors';
|
||||
52
src/editors/data/redux/requests/reducer.js
Normal file
52
src/editors/data/redux/requests/reducer.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { StrictDict } from '../../../utils';
|
||||
|
||||
import { RequestStates, RequestKeys } from '../../constants/requests';
|
||||
|
||||
const initialState = {
|
||||
[RequestKeys.fetchUnit]: { status: RequestStates.inactive },
|
||||
[RequestKeys.fetchBlock]: { status: RequestStates.inactive },
|
||||
[RequestKeys.saveBlock]: { status: RequestStates.inactive },
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const requests = createSlice({
|
||||
name: 'requests',
|
||||
initialState,
|
||||
reducers: {
|
||||
startRequest: (state, { payload }) => ({
|
||||
...state,
|
||||
[payload]: {
|
||||
status: RequestStates.pending,
|
||||
},
|
||||
}),
|
||||
completeRequest: (state, { payload }) => ({
|
||||
...state,
|
||||
[payload.requestKey]: {
|
||||
status: RequestStates.completed,
|
||||
response: payload.response,
|
||||
},
|
||||
}),
|
||||
failRequest: (state, { payload }) => ({
|
||||
...state,
|
||||
[payload.requestKey]: {
|
||||
status: RequestStates.failed,
|
||||
error: payload.error,
|
||||
},
|
||||
}),
|
||||
clearRequest: (state, { payload }) => ({
|
||||
...state,
|
||||
[payload.requestKey]: {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const actions = StrictDict(requests.actions);
|
||||
const { reducer } = requests;
|
||||
|
||||
export {
|
||||
actions,
|
||||
reducer,
|
||||
initialState,
|
||||
};
|
||||
38
src/editors/data/redux/requests/selectors.js
Normal file
38
src/editors/data/redux/requests/selectors.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { StrictDict } from '../../../utils';
|
||||
import { RequestStates } from '../../constants/requests';
|
||||
import * as module from './selectors';
|
||||
|
||||
export const requestStatus = (state, { requestKey }) => state.requests[requestKey];
|
||||
|
||||
const statusSelector = (fn) => (state, { requestKey }) => fn(state.requests[requestKey]);
|
||||
|
||||
export const isInactive = ({ status }) => status === RequestStates.inactive;
|
||||
export const isPending = ({ status }) => status === RequestStates.pending;
|
||||
export const isCompleted = ({ status }) => status === RequestStates.completed;
|
||||
export const isFailed = ({ status }) => status === RequestStates.failed;
|
||||
export const isFinished = ({ status }) => (
|
||||
[RequestStates.failed, RequestStates.completed].includes(status)
|
||||
);
|
||||
export const error = (request) => request.error;
|
||||
export const errorStatus = (request) => request.error?.response?.status;
|
||||
export const errorCode = (request) => request.error?.response?.data;
|
||||
|
||||
export const data = (request) => request.data;
|
||||
|
||||
export const allowNavigation = ({ requests }) => (
|
||||
!Object.keys(requests).some(requestKey => module.isPending(requests[requestKey]))
|
||||
);
|
||||
|
||||
export default StrictDict({
|
||||
requestStatus,
|
||||
allowNavigation,
|
||||
isInactive: statusSelector(isInactive),
|
||||
isPending: statusSelector(isPending),
|
||||
isCompleted: statusSelector(isCompleted),
|
||||
isFailed: statusSelector(isFailed),
|
||||
isFinished: statusSelector(isFinished),
|
||||
error: statusSelector(error),
|
||||
errorCode: statusSelector(errorCode),
|
||||
errorStatus: statusSelector(errorStatus),
|
||||
data: statusSelector(data),
|
||||
});
|
||||
BIN
src/editors/data/redux/thunkActions/.requests.js.swp
Normal file
BIN
src/editors/data/redux/thunkActions/.requests.js.swp
Normal file
Binary file not shown.
50
src/editors/data/redux/thunkActions/app.js
Normal file
50
src/editors/data/redux/thunkActions/app.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { StrictDict } from '../../../utils';
|
||||
import { actions, selectors } from '..';
|
||||
import * as requests from './requests';
|
||||
|
||||
export const fetchBlock = () => (dispatch) => {
|
||||
dispatch(requests.fetchBlock({
|
||||
onSuccess: (response) => dispatch(actions.app.setBlockValue(response)),
|
||||
onFailure: (e) => dispatch(actions.app.setBlockValue(e)),
|
||||
}));
|
||||
};
|
||||
|
||||
export const fetchUnit = () => (dispatch) => {
|
||||
dispatch(requests.fetchUnit({
|
||||
onSuccess: (response) => dispatch(actions.app.setUnitUrl(response)),
|
||||
onFailure: (e) => dispatch(actions.app.setUnitUrl(e)),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} studioEndpointUrl
|
||||
* @param {string} blockId
|
||||
* @param {string} courseId
|
||||
* @param {string} blockType
|
||||
*/
|
||||
export const initialize = (data) => (dispatch) => {
|
||||
dispatch(actions.app.initialize(data));
|
||||
dispatch(fetchBlock());
|
||||
dispatch(fetchUnit());
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {func} onSuccess
|
||||
*/
|
||||
export const saveBlock = ({ content, returnToUnit }) => (dispatch, getState) => {
|
||||
dispatch(actions.app.setBlockContent(content));
|
||||
dispatch(requests.saveBlock({
|
||||
content,
|
||||
onSuccess: (response) => {
|
||||
dispatch(actions.app.setSaveResponse(response));
|
||||
returnToUnit();
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
export default StrictDict({
|
||||
fetchBlock,
|
||||
fetchUnit,
|
||||
initialize,
|
||||
saveBlock,
|
||||
});
|
||||
42
src/editors/data/redux/thunkActions/app.test.js
Normal file
42
src/editors/data/redux/thunkActions/app.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { locationId } from './data/constants/app';
|
||||
|
||||
import { actions } from './data/redux';
|
||||
import thunkActions from './app';
|
||||
|
||||
jest.mock('./requests', () => ({
|
||||
initializeApp: (args) => ({ initializeApp: args }),
|
||||
}));
|
||||
|
||||
describe('app thunkActions', () => {
|
||||
let dispatch;
|
||||
let dispatchedAction;
|
||||
beforeEach(() => {
|
||||
dispatch = jest.fn((action) => ({ dispatch: action }));
|
||||
});
|
||||
describe('initialize', () => {
|
||||
beforeEach(() => {
|
||||
thunkActions.initialize()(dispatch);
|
||||
[[dispatchedAction]] = dispatch.mock.calls;
|
||||
});
|
||||
it('dispatches initializeApp with locationId and onSuccess', () => {
|
||||
expect(dispatchedAction.initializeApp.locationId).toEqual(locationId);
|
||||
expect(typeof dispatchedAction.initializeApp.onSuccess).toEqual('function');
|
||||
});
|
||||
describe('on success', () => {
|
||||
test('loads oraMetadata, courseMetadata and list data', () => {
|
||||
dispatch.mockClear();
|
||||
const response = {
|
||||
oraMetadata: { some: 'ora-metadata' },
|
||||
courseMetadata: { some: 'course-metadata' },
|
||||
submissions: { some: 'submissions' },
|
||||
};
|
||||
dispatchedAction.initializeApp.onSuccess(response);
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
[actions.app.loadOraMetadata(response.oraMetadata)],
|
||||
[actions.app.loadCourseMetadata(response.courseMetadata)],
|
||||
[actions.submissions.loadList(response.submissions)],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
7
src/editors/data/redux/thunkActions/index.js
Normal file
7
src/editors/data/redux/thunkActions/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { StrictDict } from '../../../utils';
|
||||
|
||||
import app from './app';
|
||||
|
||||
export default StrictDict({
|
||||
app,
|
||||
});
|
||||
95
src/editors/data/redux/thunkActions/requests.js
Normal file
95
src/editors/data/redux/thunkActions/requests.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { StrictDict } from '../../../utils';
|
||||
|
||||
import { RequestKeys } from '../../constants/requests';
|
||||
import { actions, selectors } from '..';
|
||||
import * as api from '../../services/lms/api';
|
||||
|
||||
import * as module from './requests';
|
||||
|
||||
/**
|
||||
* Wrapper around a network request promise, that sends actions to the redux store to
|
||||
* track the state of that promise.
|
||||
* Tracks the promise by requestKey, and sends an action when it is started, succeeds, or
|
||||
* fails. It also accepts onSuccess and onFailure methods to be called with the output
|
||||
* of failure or success of the promise.
|
||||
* @param {string} requestKey - request tracking identifier
|
||||
* @param {Promise} promise - api event promise
|
||||
* @param {[func]} onSuccess - onSuccess method ((response) => { ... })
|
||||
* @param {[func]} onFailure - onFailure method ((error) => { ... })
|
||||
*/
|
||||
export const networkRequest = ({
|
||||
requestKey,
|
||||
promise,
|
||||
onSuccess,
|
||||
onFailure,
|
||||
}) => (dispatch) => {
|
||||
dispatch(actions.requests.startRequest(requestKey));
|
||||
return promise.then((response) => {
|
||||
if (onSuccess) { onSuccess(response); }
|
||||
dispatch(actions.requests.completeRequest({ requestKey, response }));
|
||||
}).catch((error) => {
|
||||
if (onFailure) { onFailure(error); }
|
||||
dispatch(actions.requests.failRequest({ requestKey, error }));
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracked fetchByBlockId api method.
|
||||
* Tracked to the `fetchBlock` request key.
|
||||
* @param {[func]} onSuccess - onSuccess method ((response) => { ... })
|
||||
* @param {[func]} onFailure - onFailure method ((error) => { ... })
|
||||
*/
|
||||
export const fetchBlock = ({ ...rest }) => (dispatch, getState) => {
|
||||
dispatch(module.networkRequest({
|
||||
requestKey: RequestKeys.fetchBlock,
|
||||
promise: api.fetchBlockById({
|
||||
studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
|
||||
blockId: selectors.app.blockId(getState()),
|
||||
}),
|
||||
...rest,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracked fetchByUnitId api method.
|
||||
* Tracked to the `fetchUnit` request key.
|
||||
* @param {[func]} onSuccess - onSuccess method ((response) => { ... })
|
||||
* @param {[func]} onFailure - onFailure method ((error) => { ... })
|
||||
*/
|
||||
export const fetchUnit = ({ ...rest }) => (dispatch, getState) => {
|
||||
dispatch(module.networkRequest({
|
||||
requestKey: RequestKeys.fetchUnit,
|
||||
promise: api.fetchByUnitId({
|
||||
studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
|
||||
blockId: selectors.app.blockId(getState()),
|
||||
}),
|
||||
...rest,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracked saveBlock api method. Tracked to the `saveBlock` request key.
|
||||
* @param {string} content
|
||||
* @param {[func]} onSuccess - onSuccess method ((response) => { ... })
|
||||
* @param {[func]} onFailure - onFailure method ((error) => { ... })
|
||||
*/
|
||||
export const saveBlock = ({ content, ...rest }) => (dispatch, getState) => {
|
||||
dispatch(module.networkRequest({
|
||||
requestKey: RequestKeys.saveBlock,
|
||||
promise: api.saveBlock({
|
||||
blockId: selectors.app.blockId(getState()),
|
||||
blockType: selectors.app.blockType(getState()),
|
||||
courseId: selectors.app.courseId(getState()),
|
||||
content,
|
||||
studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
|
||||
title: selectors.app.title(getState()),
|
||||
}),
|
||||
...rest,
|
||||
}));
|
||||
};
|
||||
|
||||
export default StrictDict({
|
||||
fetchUnit,
|
||||
fetchBlock,
|
||||
saveBlock,
|
||||
});
|
||||
179
src/editors/data/redux/thunkActions/requests.test.js
Normal file
179
src/editors/data/redux/thunkActions/requests.test.js
Normal file
@@ -0,0 +1,179 @@
|
||||
import { actions } from 'data/redux';
|
||||
import { RequestKeys } from 'data/constants/requests';
|
||||
import api from 'data/services/lms/api';
|
||||
import * as requests from './requests';
|
||||
|
||||
jest.mock('data/services/lms/api', () => ({
|
||||
initializeApp: (locationId) => ({ initializeApp: locationId }),
|
||||
fetchSubmissionStatus: (submissionUUID) => ({ fetchSubmissionStatus: submissionUUID }),
|
||||
fetchSubmission: (submissionUUID) => ({ fetchSubmission: submissionUUID }),
|
||||
lockSubmission: ({ submissionUUID }) => ({ lockSubmission: { submissionUUID } }),
|
||||
unlockSubmission: ({ submissionUUID }) => ({ unlockSubmission: { submissionUUID } }),
|
||||
updateGrade: (submissionUUID, gradeData) => ({ updateGrade: { submissionUUID, gradeData } }),
|
||||
}));
|
||||
|
||||
let dispatch;
|
||||
let onSuccess;
|
||||
let onFailure;
|
||||
describe('requests thunkActions module', () => {
|
||||
beforeEach(() => {
|
||||
dispatch = jest.fn();
|
||||
onSuccess = jest.fn();
|
||||
onFailure = jest.fn();
|
||||
});
|
||||
|
||||
describe('networkRequest', () => {
|
||||
const requestKey = 'test-request';
|
||||
const testData = { some: 'test data' };
|
||||
let resolveFn;
|
||||
let rejectFn;
|
||||
beforeEach(() => {
|
||||
onSuccess = jest.fn();
|
||||
onFailure = jest.fn();
|
||||
requests.networkRequest({
|
||||
requestKey,
|
||||
promise: new Promise((resolve, reject) => {
|
||||
resolveFn = resolve;
|
||||
rejectFn = reject;
|
||||
}),
|
||||
onSuccess,
|
||||
onFailure,
|
||||
})(dispatch);
|
||||
});
|
||||
test('calls startRequest action with requestKey', async () => {
|
||||
expect(dispatch.mock.calls).toEqual([[actions.requests.startRequest(requestKey)]]);
|
||||
});
|
||||
describe('on success', () => {
|
||||
beforeEach(async () => {
|
||||
await resolveFn(testData);
|
||||
});
|
||||
it('dispatches completeRequest', async () => {
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
[actions.requests.startRequest(requestKey)],
|
||||
[actions.requests.completeRequest({ requestKey, response: testData })],
|
||||
]);
|
||||
});
|
||||
it('calls onSuccess with response', async () => {
|
||||
expect(onSuccess).toHaveBeenCalledWith(testData);
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('on failure', () => {
|
||||
beforeEach(async () => {
|
||||
await rejectFn(testData);
|
||||
});
|
||||
test('dispatches completeRequest', async () => {
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
[actions.requests.startRequest(requestKey)],
|
||||
[actions.requests.failRequest({ requestKey, error: testData })],
|
||||
]);
|
||||
});
|
||||
test('calls onSuccess with response', async () => {
|
||||
expect(onFailure).toHaveBeenCalledWith(testData);
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const testNetworkRequestAction = ({
|
||||
action,
|
||||
args,
|
||||
expectedData,
|
||||
expectedString,
|
||||
}) => {
|
||||
let dispatchedAction;
|
||||
beforeEach(() => {
|
||||
action({ ...args, onSuccess, onFailure })(dispatch);
|
||||
[[dispatchedAction]] = dispatch.mock.calls;
|
||||
});
|
||||
it('dispatches networkRequest', () => {
|
||||
expect(dispatchedAction.networkRequest).not.toEqual(undefined);
|
||||
});
|
||||
test('forwards onSuccess and onFailure', () => {
|
||||
expect(dispatchedAction.networkRequest.onSuccess).toEqual(onSuccess);
|
||||
expect(dispatchedAction.networkRequest.onFailure).toEqual(onFailure);
|
||||
});
|
||||
test(expectedString, () => {
|
||||
expect(dispatchedAction.networkRequest).toEqual({
|
||||
...expectedData,
|
||||
onSuccess,
|
||||
onFailure,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe('network request actions', () => {
|
||||
const submissionUUID = 'test-submission-id';
|
||||
const locationId = 'test-location-id';
|
||||
beforeEach(() => {
|
||||
requests.networkRequest = jest.fn(args => ({ networkRequest: args }));
|
||||
});
|
||||
describe('initializeApp', () => {
|
||||
testNetworkRequestAction({
|
||||
action: requests.initializeApp,
|
||||
args: { locationId },
|
||||
expectedString: 'with initialize key, initializeApp promise',
|
||||
expectedData: {
|
||||
requestKey: RequestKeys.initialize,
|
||||
promise: api.initializeApp(locationId),
|
||||
},
|
||||
});
|
||||
});
|
||||
describe('fetchSubmissionStatus', () => {
|
||||
testNetworkRequestAction({
|
||||
action: requests.fetchSubmissionStatus,
|
||||
args: { submissionUUID },
|
||||
expectedString: 'with fetchSubmissionStatus promise',
|
||||
expectedData: {
|
||||
requestKey: RequestKeys.fetchSubmissionStatus,
|
||||
promise: api.fetchSubmissionStatus(submissionUUID),
|
||||
},
|
||||
});
|
||||
});
|
||||
describe('fetchSubmission', () => {
|
||||
testNetworkRequestAction({
|
||||
action: requests.fetchSubmission,
|
||||
args: { submissionUUID },
|
||||
expectedString: 'with fetchSubmission promise',
|
||||
expectedData: {
|
||||
requestKey: RequestKeys.fetchSubmission,
|
||||
promise: api.fetchSubmission(submissionUUID),
|
||||
},
|
||||
});
|
||||
});
|
||||
describe('setLock: true', () => {
|
||||
testNetworkRequestAction({
|
||||
action: requests.setLock,
|
||||
args: { submissionUUID, value: true },
|
||||
expectedString: 'with setLock promise',
|
||||
expectedData: {
|
||||
requestKey: RequestKeys.setLock,
|
||||
promise: api.lockSubmission(submissionUUID),
|
||||
},
|
||||
});
|
||||
});
|
||||
describe('setLock: false', () => {
|
||||
testNetworkRequestAction({
|
||||
action: requests.setLock,
|
||||
args: { submissionUUID, value: false },
|
||||
expectedString: 'with setLock promise',
|
||||
expectedData: {
|
||||
requestKey: RequestKeys.setLock,
|
||||
promise: api.unlockSubmission(submissionUUID),
|
||||
},
|
||||
});
|
||||
});
|
||||
describe('submitGrade', () => {
|
||||
const gradeData = 'test-grade-data';
|
||||
testNetworkRequestAction({
|
||||
action: requests.submitGrade,
|
||||
args: { submissionUUID, gradeData },
|
||||
expectedString: 'with submitGrade promise',
|
||||
expectedData: {
|
||||
requestKey: RequestKeys.submitGrade,
|
||||
promise: api.updateGrade(submissionUUID, gradeData),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
53
src/editors/data/redux/thunkActions/testUtils.js
Normal file
53
src/editors/data/redux/thunkActions/testUtils.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
|
||||
/** createTestFetcher(mockedMethod, thunkAction, args, onDispatch)
|
||||
* Creates a testFetch method, which will test a given thunkAction of the form:
|
||||
* ```
|
||||
* const <thunkAction> = (<args>) => (dispatch, getState) => {
|
||||
* ...
|
||||
* return <mockedMethod>.then().catch();
|
||||
* ```
|
||||
* The returned function will take a promise handler function, a list of expected actions
|
||||
* to have been dispatched (objects only), and an optional verifyFn method to be called after
|
||||
* the fetch has been completed.
|
||||
*
|
||||
* @param {fn} mockedMethod - already-mocked api method being exercised by the thunkAction.
|
||||
* @param {fn} thunkAction - thunkAction to call/test
|
||||
* @param {array} args - array of args to dispatch the thunkAction with
|
||||
* @param {[fn]} onDispatch - optional function to be called after dispatch
|
||||
*
|
||||
* @return {fn} testFetch method
|
||||
* @param {fn} resolveFn - promise handler of the form (resolve, reject) => {}.
|
||||
* should return a call to resolve or reject with response data.
|
||||
* @param {object[]} expectedActions - array of action objects expected to have been dispatched
|
||||
* will be verified after the thunkAction resolves
|
||||
* @param {[fn]} verifyFn - optional function to be called after dispatch
|
||||
*/
|
||||
export const createTestFetcher = (
|
||||
mockedMethod,
|
||||
thunkAction,
|
||||
args,
|
||||
onDispatch,
|
||||
) => (
|
||||
resolveFn,
|
||||
expectedActions,
|
||||
) => {
|
||||
const store = mockStore({});
|
||||
mockedMethod.mockReturnValue(new Promise(resolve => {
|
||||
resolve(new Promise(resolveFn));
|
||||
}));
|
||||
return store.dispatch(thunkAction(...args)).then(() => {
|
||||
onDispatch();
|
||||
if (expectedActions !== undefined) {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
createTestFetcher,
|
||||
};
|
||||
48
src/editors/data/services/lms/api.js
Normal file
48
src/editors/data/services/lms/api.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as urls from './urls';
|
||||
import { get, post } from './utils';
|
||||
|
||||
export const fetchBlockById = ({ blockId, studioEndpointUrl }) => get(
|
||||
urls.block({ blockId, studioEndpointUrl }),
|
||||
);
|
||||
|
||||
export const fetchByUnitId = ({ blockId, studioEndpointUrl }) => get(
|
||||
urls.blockAncestor({ studioEndpointUrl, blockId }),
|
||||
);
|
||||
|
||||
export const normalizeContent = ({
|
||||
blockId,
|
||||
blockType,
|
||||
content,
|
||||
courseId,
|
||||
title,
|
||||
}) => {
|
||||
if (blockType === 'html') {
|
||||
return {
|
||||
category: blockType,
|
||||
couseKey: courseId,
|
||||
data: content,
|
||||
has_changes: true,
|
||||
id: blockId,
|
||||
metadata: { display_name: title },
|
||||
};
|
||||
}
|
||||
throw new TypeError(`No Block in V2 Editors named /"${blockType}/", Cannot Save Content.`);
|
||||
};
|
||||
|
||||
export const saveBlock = ({
|
||||
blockId,
|
||||
blockType,
|
||||
content,
|
||||
courseId,
|
||||
studioEndpointUrl,
|
||||
title,
|
||||
}) => post(
|
||||
urls.block({ studioEndpointUrl, blockId }),
|
||||
normalizeContent({
|
||||
blockType,
|
||||
content,
|
||||
blockId,
|
||||
courseId,
|
||||
title,
|
||||
}),
|
||||
);
|
||||
11
src/editors/data/services/lms/urls.js
Normal file
11
src/editors/data/services/lms/urls.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export const unit = ({ studioEndpointUrl, unitUrl }) => (
|
||||
`${studioEndpointUrl}/container/${unitUrl.data.ancestors[0].id}`
|
||||
);
|
||||
|
||||
export const block = ({ studioEndpointUrl, blockId }) => (
|
||||
`${studioEndpointUrl}/xblock/${blockId}`
|
||||
);
|
||||
|
||||
export const blockAncestor = ({ studioEndpointUrl, blockId }) => (
|
||||
`${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`
|
||||
);
|
||||
17
src/editors/data/services/lms/utils.js
Normal file
17
src/editors/data/services/lms/utils.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
/**
|
||||
* get(url)
|
||||
* simple wrapper providing an authenticated Http client get action
|
||||
* @param {string} url - target url
|
||||
*/
|
||||
export const get = (...args) => getAuthenticatedHttpClient().get(...args);
|
||||
/**
|
||||
* post(url, data)
|
||||
* simple wrapper providing an authenticated Http client post action
|
||||
* @param {string} url - target url
|
||||
* @param {object|string} data - post payload
|
||||
*/
|
||||
export const post = (...args) => getAuthenticatedHttpClient().post(...args);
|
||||
|
||||
export const client = getAuthenticatedHttpClient;
|
||||
39
src/editors/data/services/lms/utils.test.js
Normal file
39
src/editors/data/services/lms/utils.test.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import queryString from 'query-string';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import * as utils from './utils';
|
||||
|
||||
jest.mock('query-string', () => ({
|
||||
stringifyUrl: jest.fn((url, options) => ({ url, options })),
|
||||
}));
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedHttpClient: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('lms service utils', () => {
|
||||
describe('get', () => {
|
||||
it('forwards arguments to authenticatedHttpClient().get', () => {
|
||||
const get = jest.fn((...args) => ({ get: args }));
|
||||
getAuthenticatedHttpClient.mockReturnValue({ get });
|
||||
const args = ['some', 'args', 'for', 'the', 'test'];
|
||||
expect(utils.get(...args)).toEqual(get(...args));
|
||||
});
|
||||
});
|
||||
describe('post', () => {
|
||||
it('forwards arguments to authenticatedHttpClient().post', () => {
|
||||
const post = jest.fn((...args) => ({ post: args }));
|
||||
getAuthenticatedHttpClient.mockReturnValue({ post });
|
||||
const args = ['some', 'args', 'for', 'the', 'test'];
|
||||
expect(utils.post(...args)).toEqual(post(...args));
|
||||
});
|
||||
});
|
||||
describe('stringifyUrl', () => {
|
||||
it('forwards url and query to stringifyUrl with options to skip null and ""', () => {
|
||||
const url = 'here.com';
|
||||
const query = { some: 'set', of: 'queryParams' };
|
||||
const options = { skipNull: true, skipEmptyString: true };
|
||||
expect(utils.stringifyUrl(url, query)).toEqual(
|
||||
queryString.stringifyUrl({ url, query }, options),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
32
src/editors/data/store.js
Executable file
32
src/editors/data/store.js
Executable file
@@ -0,0 +1,32 @@
|
||||
import * as redux from 'redux';
|
||||
import thunkMiddleware from 'redux-thunk';
|
||||
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
|
||||
import { createLogger } from 'redux-logger';
|
||||
|
||||
import reducer, { actions, selectors } from './redux';
|
||||
|
||||
export const createStore = () => {
|
||||
const loggerMiddleware = createLogger();
|
||||
|
||||
const middleware = [thunkMiddleware, loggerMiddleware];
|
||||
|
||||
const store = redux.createStore(
|
||||
reducer,
|
||||
composeWithDevTools(redux.applyMiddleware(...middleware)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Dev tools for redux work
|
||||
*/
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
window.store = store;
|
||||
window.actions = actions;
|
||||
window.selectors = selectors;
|
||||
}
|
||||
|
||||
return store;
|
||||
};
|
||||
|
||||
const store = createStore();
|
||||
|
||||
export default store;
|
||||
66
src/editors/data/store.test.js
Normal file
66
src/editors/data/store.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { applyMiddleware } from 'redux';
|
||||
import thunkMiddleware from 'redux-thunk';
|
||||
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
|
||||
import { createLogger } from 'redux-logger';
|
||||
|
||||
import rootReducer, { actions, selectors } from 'data/redux';
|
||||
|
||||
import exportedStore, { createStore } from './store';
|
||||
|
||||
jest.mock('data/redux', () => ({
|
||||
__esModule: true,
|
||||
default: 'REDUCER',
|
||||
actions: 'ACTIONS',
|
||||
selectors: 'SELECTORS',
|
||||
}));
|
||||
|
||||
jest.mock('redux-logger', () => ({
|
||||
createLogger: () => 'logger',
|
||||
}));
|
||||
jest.mock('redux-thunk', () => 'thunkMiddleware');
|
||||
jest.mock('redux', () => ({
|
||||
applyMiddleware: (...middleware) => ({ applied: middleware }),
|
||||
createStore: (reducer, middleware) => ({ reducer, middleware }),
|
||||
}));
|
||||
jest.mock('redux-devtools-extension/logOnlyInProduction', () => ({
|
||||
composeWithDevTools: (middleware) => ({ withDevTools: middleware }),
|
||||
}));
|
||||
|
||||
describe('store aggregator module', () => {
|
||||
describe('exported store', () => {
|
||||
it('is generated by createStore', () => {
|
||||
expect(exportedStore).toEqual(createStore());
|
||||
});
|
||||
it('creates store with connected reducers', () => {
|
||||
expect(createStore().reducer).toEqual(rootReducer);
|
||||
});
|
||||
describe('middleware', () => {
|
||||
it('exports thunk and logger middleware, composed and applied with dev tools', () => {
|
||||
expect(createStore().middleware).toEqual(
|
||||
composeWithDevTools(applyMiddleware(thunkMiddleware, createLogger())),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('dev exposed tools', () => {
|
||||
beforeEach(() => {
|
||||
window.store = undefined;
|
||||
window.actions = undefined;
|
||||
window.selectors = undefined;
|
||||
});
|
||||
it('exposes redux tools if in development env', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
const store = createStore();
|
||||
expect(window.store).toEqual(store);
|
||||
expect(window.actions).toEqual(actions);
|
||||
expect(window.selectors).toEqual(selectors);
|
||||
});
|
||||
it('does not expose redux tools if in production env', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
createStore();
|
||||
expect(window.store).toEqual(undefined);
|
||||
expect(window.actions).toEqual(undefined);
|
||||
expect(window.selectors).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
30
src/editors/hooks.js
Normal file
30
src/editors/hooks.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||
|
||||
export const initializeApp = ({ initialize, data }) => useEffect(() => initialize(data), []);
|
||||
|
||||
export const prepareEditorRef = () => {
|
||||
const editorRef = useRef(null);
|
||||
const setEditorRef = useCallback((ref) => {
|
||||
editorRef.current = ref;
|
||||
}, []);
|
||||
const [refReady, setRefReady] = useState(false);
|
||||
useEffect(() => setRefReady(true), []);
|
||||
return { editorRef, refReady, setEditorRef };
|
||||
};
|
||||
|
||||
export const navigateTo = (destination) => {
|
||||
window.location.assign(destination);
|
||||
};
|
||||
|
||||
export const navigateCallback = (destination) => () => navigateTo(destination);
|
||||
|
||||
export const saveTextBlock = ({
|
||||
editorRef,
|
||||
returnUrl,
|
||||
saveBlock,
|
||||
}) => {
|
||||
saveBlock({
|
||||
returnToUnit: module.navigateCallback(returnUrl),
|
||||
content: editorRef.current.getContent(),
|
||||
});
|
||||
};
|
||||
9
src/editors/messages.js
Normal file
9
src/editors/messages.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export const messages = {
|
||||
couldNotFindEditor: {
|
||||
id: 'authoring.editorpage.selecteditor.error',
|
||||
defaultMessage: 'Error: Could Not find Editor',
|
||||
description: 'Error Message Dispayed When An unsopported Editor is desired in V2',
|
||||
},
|
||||
};
|
||||
|
||||
export default messages;
|
||||
24
src/editors/utils/StrictDict.js
Normal file
24
src/editors/utils/StrictDict.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/* eslint-disable no-console */
|
||||
const strictGet = (target, name) => {
|
||||
if (name === Symbol.toStringTag) {
|
||||
return target;
|
||||
}
|
||||
|
||||
if (name in target || name === '_reactFragment') {
|
||||
return target[name];
|
||||
}
|
||||
|
||||
if (name === '$$typeof') {
|
||||
return typeof target;
|
||||
}
|
||||
|
||||
console.log(name.toString());
|
||||
console.error({ target, name });
|
||||
const e = Error(`invalid property "${name.toString()}"`);
|
||||
console.error(e.stack);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const StrictDict = (dict) => new Proxy(dict, { get: strictGet });
|
||||
|
||||
export default StrictDict;
|
||||
62
src/editors/utils/StrictDict.test.js
Normal file
62
src/editors/utils/StrictDict.test.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import StrictDict from './StrictDict';
|
||||
|
||||
const value1 = 'valUE1';
|
||||
const value2 = 'vALue2';
|
||||
const key1 = 'Key1';
|
||||
const key2 = 'keY2';
|
||||
|
||||
jest.spyOn(window, 'Error').mockImplementation(error => ({ stack: error }));
|
||||
|
||||
describe('StrictDict', () => {
|
||||
let consoleError;
|
||||
let consoleLog;
|
||||
let windowError;
|
||||
beforeEach(() => {
|
||||
consoleError = window.console.error;
|
||||
consoleLog = window.console.lot;
|
||||
windowError = window.Error;
|
||||
window.console.error = jest.fn();
|
||||
window.console.log = jest.fn();
|
||||
window.Error = jest.fn(error => ({ stack: error }));
|
||||
});
|
||||
afterAll(() => {
|
||||
window.console.error = consoleError;
|
||||
window.console.log = consoleLog;
|
||||
window.Error = windowError;
|
||||
});
|
||||
const rawDict = {
|
||||
[key1]: value1,
|
||||
[key2]: value2,
|
||||
};
|
||||
const dict = StrictDict(rawDict);
|
||||
it('provides key access like a normal dict object', () => {
|
||||
expect(dict[key1]).toEqual(value1);
|
||||
});
|
||||
it('allows key listing', () => {
|
||||
expect(Object.keys(dict)).toEqual([key1, key2]);
|
||||
});
|
||||
it('allows item listing', () => {
|
||||
expect(Object.values(dict)).toEqual([value1, value2]);
|
||||
});
|
||||
it('allows stringification', () => {
|
||||
expect(dict.toString()).toEqual(rawDict.toString());
|
||||
expect({ ...dict }).toEqual({ ...rawDict });
|
||||
});
|
||||
it('allows entry listing', () => {
|
||||
expect(Object.entries(dict)).toEqual(Object.entries(rawDict));
|
||||
});
|
||||
describe('missing key', () => {
|
||||
it('logs error with target, name, and error stack', () => {
|
||||
// eslint-ignore-next-line no-unused-vars
|
||||
const callBadKey = () => dict.fakeKey;
|
||||
callBadKey();
|
||||
expect(window.console.error.mock.calls).toEqual([
|
||||
[{ target: dict, name: 'fakeKey' }],
|
||||
[Error('invalid property "fakeKey"').stack],
|
||||
]);
|
||||
});
|
||||
it('returns undefined', () => {
|
||||
expect(dict.fakeKey).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
2
src/editors/utils/index.js
Normal file
2
src/editors/utils/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as StrictDict } from './StrictDict';
|
||||
Reference in New Issue
Block a user