!refactor: Breaking Change refactor use Redux. No release

This commit is contained in:
Ben Warzeski
2022-02-18 13:16:36 -05:00
committed by GitHub
parent eef30348fd
commit 5a1d71a62c
64 changed files with 1734 additions and 451 deletions

86
src/editors/Editor.jsx Normal file
View 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);

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,4 +0,0 @@
import React from 'react';
const EditorPageContext = React.createContext();
export default EditorPageContext;

View File

@@ -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;

View File

@@ -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;

View 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);

View 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;

View 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);

View 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,
};
};

View 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);

View 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;

View 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);

View 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),
};
};

View 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;

View File

@@ -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);
}

View File

@@ -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.`);
}
}

View 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',
});

View 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',
});

Binary file not shown.

View File

@@ -0,0 +1,2 @@
export { actions, reducer } from './reducer';
export { default as selectors } from './selectors';

View 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,
};

View 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,
};

View 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;

View File

@@ -0,0 +1,2 @@
export { actions, reducer } from './reducer';
export { default as selectors } from './selectors';

View 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,
};

View 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),
});

Binary file not shown.

View 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,
});

View 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)],
]);
});
});
});
});

View File

@@ -0,0 +1,7 @@
import { StrictDict } from '../../../utils';
import app from './app';
export default StrictDict({
app,
});

View 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,
});

View 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),
},
});
});
});
});

View 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,
};

View 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,
}),
);

View 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`
);

View 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;

View 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
View 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;

View 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
View 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
View 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;

View 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;

View 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);
});
});
});

View File

@@ -0,0 +1,2 @@
/* eslint-disable import/prefer-default-export */
export { default as StrictDict } from './StrictDict';