feat: 'frontend-lib-content-components' into this repo

This commit is contained in:
Braden MacDonald
2024-08-09 11:48:49 -07:00
507 changed files with 53000 additions and 0 deletions

68
src/editors/Editor.jsx Normal file
View File

@@ -0,0 +1,68 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from './messages';
import * as hooks from './hooks';
import supportedEditors from './supportedEditors';
export const Editor = ({
learningContextId,
blockType,
blockId,
lmsEndpointUrl,
studioEndpointUrl,
onClose,
returnFunction,
}) => {
const dispatch = useDispatch();
hooks.initializeApp({
dispatch,
data: {
blockId,
blockType,
learningContextId,
lmsEndpointUrl,
studioEndpointUrl,
},
});
const EditorComponent = supportedEditors[blockType];
return (
<div
className="d-flex flex-column"
>
<div
className="pgn__modal-fullscreen h-100"
role="dialog"
aria-label={blockType}
>
{(EditorComponent !== undefined)
? <EditorComponent {...{ onClose, returnFunction }} />
: <FormattedMessage {...messages.couldNotFindEditor} />}
</div>
</div>
);
};
Editor.defaultProps = {
blockId: null,
learningContextId: null,
lmsEndpointUrl: null,
onClose: null,
returnFunction: null,
studioEndpointUrl: null,
};
Editor.propTypes = {
blockId: PropTypes.string,
blockType: PropTypes.string.isRequired,
learningContextId: PropTypes.string,
lmsEndpointUrl: PropTypes.string,
onClose: PropTypes.func,
returnFunction: PropTypes.func,
studioEndpointUrl: PropTypes.string,
};
export default Editor;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { shallow } from '@edx/react-unit-test-utils';
import { Editor } from './Editor';
import supportedEditors from './supportedEditors';
import * as hooks from './hooks';
import { blockTypes } from './data/constants/app';
jest.mock('./hooks', () => ({
initializeApp: jest.fn(),
}));
jest.mock('./containers/TextEditor', () => 'TextEditor');
jest.mock('./containers/VideoEditor', () => 'VideoEditor');
jest.mock('./containers/ProblemEditor', () => 'ProblemEditor');
const initData = {
blockId: 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4',
blockType: blockTypes.html,
learningContextId: 'course-v1:edX+DemoX+Demo_Course',
lmsEndpointUrl: 'evenfakerurl.com',
studioEndpointUrl: 'fakeurl.com',
};
const props = {
initialize: jest.fn(),
onClose: jest.fn().mockName('props.onClose'),
courseId: 'course-v1:edX+DemoX+Demo_Course',
...initData,
};
let el;
describe('Editor', () => {
describe('render', () => {
test('snapshot: renders correct editor given blockType (html -> TextEditor)', () => {
expect(shallow(<Editor {...props} />).snapshot).toMatchSnapshot();
});
test('presents error message if no relevant editor found and ref ready', () => {
expect(shallow(<Editor {...props} blockType="fAkEBlock" />).snapshot).toMatchSnapshot();
});
test.each(Object.values(blockTypes))('renders %p editor when ref is ready', (blockType) => {
el = shallow(<Editor {...props} blockType={blockType} />);
expect(el.shallowWrapper.props.children.props.children.type).toBe(supportedEditors[blockType]);
});
});
describe('behavior', () => {
it('calls initializeApp hook with dispatch, and passed data', () => {
el = shallow(<Editor {...props} blockType={blockTypes.html} />);
expect(hooks.initializeApp).toHaveBeenCalledWith({
dispatch: useDispatch(),
data: initData,
});
});
});
});

View File

@@ -0,0 +1,58 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Provider } from 'react-redux';
import store from './data/store';
import Editor from './Editor';
import ErrorBoundary from './sharedComponents/ErrorBoundary';
export const EditorPage = ({
courseId,
blockType,
blockId,
lmsEndpointUrl,
studioEndpointUrl,
onClose,
returnFunction,
}) => (
<Provider store={store}>
<ErrorBoundary
{...{
learningContextId: courseId,
studioEndpointUrl,
}}
>
<Editor
{...{
onClose,
learningContextId: courseId,
blockType,
blockId,
lmsEndpointUrl,
studioEndpointUrl,
returnFunction,
}}
/>
</ErrorBoundary>
</Provider>
);
EditorPage.defaultProps = {
blockId: null,
courseId: null,
lmsEndpointUrl: null,
onClose: null,
returnFunction: null,
studioEndpointUrl: null,
};
EditorPage.propTypes = {
blockId: PropTypes.string,
blockType: PropTypes.string.isRequired,
courseId: PropTypes.string,
lmsEndpointUrl: PropTypes.string,
onClose: PropTypes.func,
returnFunction: PropTypes.func,
studioEndpointUrl: PropTypes.string,
};
export default EditorPage;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import EditorPage from './EditorPage';
const props = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
blockType: 'html',
blockId: 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4',
lmsEndpointUrl: 'evenfakerurl.com',
studioEndpointUrl: 'fakeurl.com',
onClose: jest.fn().mockName('props.onClose'),
};
jest.mock('react-redux', () => ({
Provider: 'Provider',
connect: (mapStateToProps, mapDispatchToProps) => (component) => ({
mapStateToProps,
mapDispatchToProps,
component,
}),
}));
jest.mock('./Editor', () => 'Editor');
describe('Editor Page', () => {
describe('snapshots', () => {
test('rendering correctly with expected Input', () => {
expect(shallow(<EditorPage {...props} />).snapshot).toMatchSnapshot();
});
test('props besides blockType default to null', () => {
expect(shallow(<EditorPage blockType={props.blockType} />).snapshot).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,13 @@
import React from 'react';
const Placeholder = () => (
<div className="Placeholder">
<h1>
Under Construction
<br />
Coming Soon
</h1>
</div>
);
export default Placeholder;

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import TestRenderer from 'react-test-renderer';
import { AppContext } from '@edx/frontend-platform/react';
import { Context as ResponsiveContext } from 'react-responsive';
import Placeholder from '../index';
describe('<Placeholder />', () => {
it('renders correctly', () => {
const component = (
<ResponsiveContext.Provider value={{ width: 1280 }}>
<IntlProvider locale="en" messages={{}}>
<AppContext.Provider
value={{
authenticatedUser: null,
config: {
LMS_BASE_URL: process.env.LMS_BASE_URL,
SITE_NAME: process.env.SITE_NAME,
LOGIN_URL: process.env.LOGIN_URL,
LOGOUT_URL: process.env.LOGOUT_URL,
LOGO_URL: process.env.LOGO_URL,
},
}}
>
<Placeholder />
</AppContext.Provider>
</IntlProvider>
</ResponsiveContext.Provider>
);
const wrapper = TestRenderer.create(component);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import VideoGallery from './containers/VideoGallery';
import * as hooks from './hooks';
export const VideoSelector = ({
blockId,
learningContextId,
lmsEndpointUrl,
studioEndpointUrl,
}) => {
const dispatch = useDispatch();
hooks.initializeApp({
dispatch,
data: {
blockId,
blockType: 'video',
learningContextId,
lmsEndpointUrl,
studioEndpointUrl,
},
});
return (
<VideoGallery />
);
};
VideoSelector.propTypes = {
blockId: PropTypes.string.isRequired,
learningContextId: PropTypes.string.isRequired,
lmsEndpointUrl: PropTypes.string.isRequired,
studioEndpointUrl: PropTypes.string.isRequired,
};
export default VideoSelector;

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { shallow } from '@edx/react-unit-test-utils';
import * as hooks from './hooks';
import VideoSelector from './VideoSelector';
jest.mock('./hooks', () => ({
initializeApp: jest.fn(),
}));
jest.mock('./containers/VideoGallery', () => 'VideoGallery');
const props = {
blockId: 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4',
learningContextId: 'course-v1:edX+DemoX+Demo_Course',
lmsEndpointUrl: 'evenfakerurl.com',
studioEndpointUrl: 'fakeurl.com',
};
const initData = {
blockType: 'video',
...props,
};
describe('Video Selector', () => {
describe('render', () => {
test('rendering correctly with expected Input', () => {
expect(shallow(<VideoSelector {...props} />).snapshot).toMatchSnapshot();
});
});
describe('behavior', () => {
it('calls initializeApp hook with dispatch, and passed data', () => {
shallow(<VideoSelector {...props} />);
expect(hooks.initializeApp).toHaveBeenCalledWith({
dispatch: useDispatch(),
data: initData,
});
});
});
});

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { Provider } from 'react-redux';
import PropTypes from 'prop-types';
import ErrorBoundary from './sharedComponents/ErrorBoundary';
import VideoSelector from './VideoSelector';
import store from './data/store';
const VideoSelectorPage = ({
blockId,
courseId,
lmsEndpointUrl,
studioEndpointUrl,
}) => (
<Provider store={store}>
<ErrorBoundary
{...{
learningContextId: courseId,
studioEndpointUrl,
}}
>
<VideoSelector
{...{
blockId,
learningContextId: courseId,
lmsEndpointUrl,
studioEndpointUrl,
}}
/>
</ErrorBoundary>
</Provider>
);
VideoSelectorPage.defaultProps = {
blockId: null,
courseId: null,
lmsEndpointUrl: null,
studioEndpointUrl: null,
};
VideoSelectorPage.propTypes = {
blockId: PropTypes.string,
courseId: PropTypes.string,
lmsEndpointUrl: PropTypes.string,
studioEndpointUrl: PropTypes.string,
};
export default VideoSelectorPage;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import VideoSelectorPage from './VideoSelectorPage';
const props = {
blockId: 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4',
courseId: 'course-v1:edX+DemoX+Demo_Course',
lmsEndpointUrl: 'evenfakerurl.com',
studioEndpointUrl: 'fakeurl.com',
};
jest.mock('react-redux', () => ({
Provider: 'Provider',
connect: (mapStateToProps, mapDispatchToProps) => (component) => ({
mapStateToProps,
mapDispatchToProps,
component,
}),
}));
jest.mock('./VideoSelector', () => 'VideoSelector');
describe('Video Selector Page', () => {
describe('snapshots', () => {
test('rendering correctly with expected Input', () => {
expect(shallow(<VideoSelectorPage {...props} />).snapshot).toMatchSnapshot();
});
test('rendering with props to null', () => {
expect(shallow(<VideoSelectorPage />).snapshot).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,36 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Editor render presents error message if no relevant editor found and ref ready 1`] = `
<div
className="d-flex flex-column"
>
<div
aria-label="fAkEBlock"
className="pgn__modal-fullscreen h-100"
role="dialog"
>
<FormattedMessage
defaultMessage="Error: Could Not find Editor"
description="Error Message Dispayed When An unsopported Editor is desired in V2"
id="authoring.editorpage.selecteditor.error"
/>
</div>
</div>
`;
exports[`Editor render snapshot: renders correct editor given blockType (html -> TextEditor) 1`] = `
<div
className="d-flex flex-column"
>
<div
aria-label="html"
className="pgn__modal-fullscreen h-100"
role="dialog"
>
<TextEditor
onClose={[MockFunction props.onClose]}
returnFunction={null}
/>
</div>
</div>
`;

View File

@@ -0,0 +1,59 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Editor Page snapshots props besides blockType default to null 1`] = `
<Provider
store={
{
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<ErrorBoundary
learningContextId={null}
studioEndpointUrl={null}
>
<Editor
blockId={null}
blockType="html"
learningContextId={null}
lmsEndpointUrl={null}
onClose={null}
returnFunction={null}
studioEndpointUrl={null}
/>
</ErrorBoundary>
</Provider>
`;
exports[`Editor Page snapshots rendering correctly with expected Input 1`] = `
<Provider
store={
{
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<ErrorBoundary
learningContextId="course-v1:edX+DemoX+Demo_Course"
studioEndpointUrl="fakeurl.com"
>
<Editor
blockId="block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4"
blockType="html"
learningContextId="course-v1:edX+DemoX+Demo_Course"
lmsEndpointUrl="evenfakerurl.com"
onClose={[MockFunction props.onClose]}
returnFunction={null}
studioEndpointUrl="fakeurl.com"
/>
</ErrorBoundary>
</Provider>
`;

View File

@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Video Selector render rendering correctly with expected Input 1`] = `<VideoGallery />`;

View File

@@ -0,0 +1,53 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Video Selector Page snapshots rendering correctly with expected Input 1`] = `
<Provider
store={
{
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<ErrorBoundary
learningContextId="course-v1:edX+DemoX+Demo_Course"
studioEndpointUrl="fakeurl.com"
>
<VideoSelector
blockId="block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4"
learningContextId="course-v1:edX+DemoX+Demo_Course"
lmsEndpointUrl="evenfakerurl.com"
studioEndpointUrl="fakeurl.com"
/>
</ErrorBoundary>
</Provider>
`;
exports[`Video Selector Page snapshots rendering with props to null 1`] = `
<Provider
store={
{
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<ErrorBoundary
learningContextId={null}
studioEndpointUrl={null}
>
<VideoSelector
blockId={null}
learningContextId={null}
lmsEndpointUrl={null}
studioEndpointUrl={null}
/>
</ErrorBoundary>
</Provider>
`;

View File

@@ -0,0 +1,159 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditorContainer component render snapshot: initialized. enable save and pass to header 1`] = `
<div
className="editor-container d-flex flex-column position-relative zindex-0"
style={
{
"minHeight": "100%",
}
}
>
<BaseModal
bodyStyle={null}
close={[MockFunction closeCancelConfirmModal]}
confirmAction={
<Button
onClick={[Function]}
variant="primary"
>
<FormattedMessage
defaultMessage="OK"
description="Label for OK button"
id="authoring.editorContainer.okButton.label"
/>
</Button>
}
footerAction={null}
headerComponent={null}
isFullscreenScroll={true}
isOpen={false}
size="md"
title="Exit the editor?"
>
<FormattedMessage
defaultMessage="Are you sure you want to exit the editor? Any unsaved changes will be lost."
description="Description text for modal confirming cancellation"
id="authoring.editorContainer.cancelConfirm.description"
/>
</BaseModal>
<ModalDialog.Header
className="shadow-sm zindex-10"
>
<div
className="d-flex flex-row justify-content-between"
>
<h2
className="h3 col pl-0"
>
<injectIntl(ShimmedIntlComponent)
isInitialized={true}
/>
</h2>
<IconButton
iconAs="Icon"
onClick={[MockFunction openCancelConfirmModal]}
src={[MockFunction icons.Close]}
/>
</div>
</ModalDialog.Header>
<ModalDialog.Body
className="pb-0 mb-6"
>
<h1>
My test content
</h1>
</ModalDialog.Body>
<injectIntl(ShimmedIntlComponent)
disableSave={false}
onCancel={[MockFunction openCancelConfirmModal]}
onSave={
{
"handleSaveClicked": {
"dispatch": [MockFunction react-redux.dispatch],
"getContent": [MockFunction props.getContent],
"returnFunction": [MockFunction props.returnFunction],
"validateEntry": [MockFunction props.validateEntry],
},
}
}
/>
</div>
`;
exports[`EditorContainer component render snapshot: not initialized. disable save and pass to header 1`] = `
<div
className="editor-container d-flex flex-column position-relative zindex-0"
style={
{
"minHeight": "100%",
}
}
>
<BaseModal
bodyStyle={null}
close={[MockFunction closeCancelConfirmModal]}
confirmAction={
<Button
onClick={[Function]}
variant="primary"
>
<FormattedMessage
defaultMessage="OK"
description="Label for OK button"
id="authoring.editorContainer.okButton.label"
/>
</Button>
}
footerAction={null}
headerComponent={null}
isFullscreenScroll={true}
isOpen={false}
size="md"
title="Exit the editor?"
>
<FormattedMessage
defaultMessage="Are you sure you want to exit the editor? Any unsaved changes will be lost."
description="Description text for modal confirming cancellation"
id="authoring.editorContainer.cancelConfirm.description"
/>
</BaseModal>
<ModalDialog.Header
className="shadow-sm zindex-10"
>
<div
className="d-flex flex-row justify-content-between"
>
<h2
className="h3 col pl-0"
>
<injectIntl(ShimmedIntlComponent)
isInitialized={false}
/>
</h2>
<IconButton
iconAs="Icon"
onClick={[MockFunction openCancelConfirmModal]}
src={[MockFunction icons.Close]}
/>
</div>
</ModalDialog.Header>
<ModalDialog.Body
className="pb-0 mb-6"
/>
<injectIntl(ShimmedIntlComponent)
disableSave={true}
onCancel={[MockFunction openCancelConfirmModal]}
onSave={
{
"handleSaveClicked": {
"dispatch": [MockFunction react-redux.dispatch],
"getContent": [MockFunction props.getContent],
"returnFunction": [MockFunction props.returnFunction],
"validateEntry": [MockFunction props.validateEntry],
},
}
}
/>
</div>
`;

View File

@@ -0,0 +1,114 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditorFooter render snapshot: default args (disableSave: false, saveFailed: false) 1`] = `
<div
className="editor-footer fixed-bottom"
>
<ModalDialog.Footer
className="shadow-sm"
>
<ActionRow>
<Button
aria-label="Discard changes and return to learning context"
onClick={[MockFunction args.onCancel]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Label for cancel button"
id="authoring.editorfooter.cancelButton.label"
/>
</Button>
<Button
aria-label="Save changes and return to learning context"
disabled={false}
onClick={[MockFunction args.onSave]}
>
<FormattedMessage
defaultMessage="Save"
description="Label for Save button"
id="authoring.editorfooter.savebutton.label"
/>
</Button>
</ActionRow>
</ModalDialog.Footer>
</div>
`;
exports[`EditorFooter render snapshot: save disabled. Show button spinner 1`] = `
<div
className="editor-footer fixed-bottom"
>
<ModalDialog.Footer
className="shadow-sm"
>
<ActionRow>
<Button
aria-label="Discard changes and return to learning context"
onClick={[MockFunction args.onCancel]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Label for cancel button"
id="authoring.editorfooter.cancelButton.label"
/>
</Button>
<Button
aria-label="Save changes and return to learning context"
disabled={true}
onClick={[MockFunction args.onSave]}
>
<Spinner
animation="border"
className="mr-3"
/>
</Button>
</ActionRow>
</ModalDialog.Footer>
</div>
`;
exports[`EditorFooter render snapshot: save failed. Show error message 1`] = `
<div
className="editor-footer fixed-bottom"
>
<Toast
show={true}
>
<FormattedMessage
defaultMessage="Error: Content save failed. Please check recent changes and try again later."
description="Error message displayed when content fails to save."
id="authoring.editorfooter.save.error"
/>
</Toast>
<ModalDialog.Footer
className="shadow-sm"
>
<ActionRow>
<Button
aria-label="Discard changes and return to learning context"
onClick={[MockFunction args.onCancel]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Label for cancel button"
id="authoring.editorfooter.cancelButton.label"
/>
</Button>
<Button
aria-label="Save changes and return to learning context"
disabled={false}
onClick={[MockFunction args.onSave]}
>
<FormattedMessage
defaultMessage="Save"
description="Label for Save button"
id="authoring.editorfooter.savebutton.label"
/>
</Button>
</ActionRow>
</ModalDialog.Footer>
</div>
`;

View File

@@ -0,0 +1,63 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Spinner,
ActionRow,
Button,
ModalDialog,
Toast,
} from '@openedx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
export const EditorFooter = ({
clearSaveFailed,
disableSave,
onCancel,
onSave,
saveFailed,
// injected
intl,
}) => (
<div className="editor-footer fixed-bottom">
{saveFailed && (
<Toast show onClose={clearSaveFailed}>
<FormattedMessage {...messages.contentSaveFailed} />
</Toast>
)}
<ModalDialog.Footer className="shadow-sm">
<ActionRow>
<Button
aria-label={intl.formatMessage(messages.cancelButtonAriaLabel)}
variant="tertiary"
onClick={onCancel}
>
<FormattedMessage {...messages.cancelButtonLabel} />
</Button>
<Button
aria-label={intl.formatMessage(messages.saveButtonAriaLabel)}
onClick={onSave}
disabled={disableSave}
>
{disableSave
? <Spinner animation="border" className="mr-3" />
: <FormattedMessage {...messages.saveButtonLabel} />}
</Button>
</ActionRow>
</ModalDialog.Footer>
</div>
);
EditorFooter.propTypes = {
clearSaveFailed: PropTypes.func.isRequired,
disableSave: PropTypes.bool.isRequired,
onCancel: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
saveFailed: PropTypes.bool.isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(EditorFooter);

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { formatMessage } from '../../../../../testUtils';
import { EditorFooter } from '.';
jest.mock('../../hooks', () => ({
nullMethod: jest.fn().mockName('hooks.nullMethod'),
}));
describe('EditorFooter', () => {
const props = {
intl: { formatMessage },
disableSave: false,
onCancel: jest.fn().mockName('args.onCancel'),
onSave: jest.fn().mockName('args.onSave'),
saveFailed: false,
};
describe('render', () => {
test('snapshot: default args (disableSave: false, saveFailed: false)', () => {
expect(shallow(<EditorFooter {...props} />).snapshot).toMatchSnapshot();
});
test('snapshot: save disabled. Show button spinner', () => {
expect(shallow(<EditorFooter {...props} disableSave />).snapshot).toMatchSnapshot();
});
test('snapshot: save failed. Show error message', () => {
expect(shallow(<EditorFooter {...props} saveFailed />).snapshot).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,32 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
contentSaveFailed: {
id: 'authoring.editorfooter.save.error',
defaultMessage: 'Error: Content save failed. Please check recent changes and try again later.',
description: 'Error message displayed when content fails to save.',
},
cancelButtonAriaLabel: {
id: 'authoring.editorfooter.cancelButton.ariaLabel',
defaultMessage: 'Discard changes and return to learning context',
description: 'Screen reader label for cancel button',
},
cancelButtonLabel: {
id: 'authoring.editorfooter.cancelButton.label',
defaultMessage: 'Cancel',
description: 'Label for cancel button',
},
saveButtonAriaLabel: {
id: 'authoring.editorfooter.savebutton.ariaLabel',
defaultMessage: 'Save changes and return to learning context',
description: 'Screen reader label for save button',
},
saveButtonLabel: {
id: 'authoring.editorfooter.savebutton.label',
defaultMessage: 'Save',
description: 'Label for Save button',
},
});
export default messages;

View File

@@ -0,0 +1,40 @@
import React from 'react';
import PropTypes from 'prop-types';
import { IconButtonWithTooltip, ButtonGroup, Icon } from '@openedx/paragon';
import { Check, Close } from '@openedx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
export const EditConfirmationButtons = ({
updateTitle,
cancelEdit,
// injected
intl,
}) => (
<ButtonGroup>
<IconButtonWithTooltip
tooltipPlacement="left"
tooltipContent={intl.formatMessage(messages.saveTitleEdit)}
src={Check}
iconAs={Icon}
onClick={updateTitle}
/>
<IconButtonWithTooltip
tooltipPlacement="right"
tooltipContent={intl.formatMessage(messages.cancelTitleEdit)}
src={Close}
iconAs={Icon}
onClick={cancelEdit}
/>
</ButtonGroup>
);
EditConfirmationButtons.propTypes = {
updateTitle: PropTypes.func.isRequired,
cancelEdit: PropTypes.func.isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(EditConfirmationButtons);

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { formatMessage } from '../../../../../testUtils';
import * as module from './EditConfirmationButtons';
describe('EditConfirmationButtons', () => {
const props = {
intl: { formatMessage },
updateTitle: jest.fn().mockName('args.updateTitle'),
cancelEdit: jest.fn().mockName('args.cancelEdit'),
};
describe('snapshot', () => {
test('snapshot', () => {
expect(shallow(<module.EditConfirmationButtons {...props} />).snapshot).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,44 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Form } from '@openedx/paragon';
import EditConfirmationButtons from './EditConfirmationButtons';
export const EditableHeader = ({
handleChange,
updateTitle,
handleKeyDown,
inputRef,
localTitle,
cancelEdit,
}) => (
<Form.Group onBlur={(e) => updateTitle(e)}>
<Form.Control
style={{ paddingInlineEnd: 'calc(1rem + 84px)' }}
autoFocus
trailingElement={<EditConfirmationButtons {...{ updateTitle, cancelEdit }} />}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="Title"
ref={inputRef}
value={localTitle}
/>
</Form.Group>
);
EditableHeader.defaultProps = {
inputRef: null,
};
EditableHeader.propTypes = {
inputRef: PropTypes.oneOfType([
PropTypes.func,
// eslint-disable-next-line react/forbid-prop-types
PropTypes.shape({ current: PropTypes.any }),
]),
handleChange: PropTypes.func.isRequired,
updateTitle: PropTypes.func.isRequired,
handleKeyDown: PropTypes.func.isRequired,
localTitle: PropTypes.string.isRequired,
cancelEdit: PropTypes.func.isRequired,
};
export default EditableHeader;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { Form } from '@openedx/paragon';
import * as module from './EditableHeader';
import EditConfirmationButtons from './EditConfirmationButtons';
describe('EditableHeader', () => {
const props = {
handleChange: jest.fn().mockName('args.handleChange'),
updateTitle: jest.fn().mockName('args.updateTitle'),
handleKeyDown: jest.fn().mockName('args.handleKeyDown'),
inputRef: jest.fn().mockName('args.inputRef'),
localTitle: 'test-title-text',
cancelEdit: jest.fn().mockName('args.cancelEdit'),
};
let el;
beforeEach(() => {
el = shallow(<module.EditableHeader {...props} />);
});
describe('snapshot', () => {
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
});
test('displays Edit Icon', () => {
const formControl = el.instance.findByType(Form.Control)[0];
expect(formControl.props.trailingElement).toMatchObject(
<EditConfirmationButtons updateTitle={props.updateTitle} cancelEdit={props.cancelEdit} />,
);
});
});
});

View File

@@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditConfirmationButtons snapshot snapshot 1`] = `
<ButtonGroup>
<IconButtonWithTooltip
iconAs="Icon"
onClick={[MockFunction args.updateTitle]}
tooltipContent="Save"
tooltipPlacement="left"
/>
<IconButtonWithTooltip
iconAs="Icon"
onClick={[MockFunction args.cancelEdit]}
src={[MockFunction icons.Close]}
tooltipContent="Cancel"
tooltipPlacement="right"
/>
</ButtonGroup>
`;

View File

@@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditableHeader snapshot snapshot 1`] = `
<Form.Group
onBlur={[Function]}
>
<Form.Control
autoFocus={true}
onChange={[MockFunction args.handleChange]}
onKeyDown={[MockFunction args.handleKeyDown]}
placeholder="Title"
style={
{
"paddingInlineEnd": "calc(1rem + 84px)",
}
}
trailingElement={
<injectIntl(ShimmedIntlComponent)
cancelEdit={[MockFunction args.cancelEdit]}
updateTitle={[MockFunction args.updateTitle]}
/>
}
value="test-title-text"
/>
</Form.Group>
`;

View File

@@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TitleHeader snapshots editing 1`] = `
<EditableHeader
handleChange={[MockFunction localTitleHooks.handleChange]}
handleKeyDown={[MockFunction localTitleHooks.handleKeyDown]}
inputRef={[MockFunction localTitleHooks.inputRef]}
localTitle="TeST LocALtitLE"
updateTitle={[MockFunction localTitleHooks.updateTitle]}
/>
`;
exports[`TitleHeader snapshots initialized 1`] = `
<div
className="d-flex flex-row align-items-center mt-1"
>
<Truncate>
{
"useSelector": [Function],
}
</Truncate>
<IconButton
alt="Edit Title"
className="mx-2"
iconAs="Icon"
onClick={[MockFunction localTitleHooks.startEditing]}
size="sm"
/>
</div>
`;
exports[`TitleHeader snapshots not initialized 1`] = `"Loading..."`;

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { actions, selectors } from '../../../../data/redux';
import * as textEditorHooks from '../../hooks';
import * as module from './hooks';
export const { navigateCallback } = textEditorHooks;
export const state = {
// eslint-disable-next-line react-hooks/rules-of-hooks
localTitle: (args) => React.useState(args),
};
export const hooks = {
isEditing: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [isEditing, setIsEditing] = React.useState(false);
return {
isEditing,
startEditing: () => setIsEditing(true),
stopEditing: () => setIsEditing(false),
};
},
localTitle: ({ dispatch, stopEditing }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const title = useSelector(selectors.app.displayTitle);
const [localTitle, setLocalTitle] = module.state.localTitle(title);
return {
updateTitle: (e) => {
if (localTitle.length <= 0) {
setLocalTitle(title);
stopEditing();
} else if (!e.currentTarget.contains(e.relatedTarget)) {
dispatch(actions.app.setBlockTitle(localTitle));
stopEditing();
}
},
handleChange: (e) => setLocalTitle(e.target.value),
cancelEdit: () => {
setLocalTitle(title);
stopEditing();
},
localTitle,
};
},
};
export const localTitleHooks = ({ dispatch }) => {
const { isEditing, startEditing, stopEditing } = module.hooks.isEditing();
const {
localTitle,
handleChange,
updateTitle,
cancelEdit,
} = module.hooks.localTitle({
dispatch,
stopEditing,
});
return {
isEditing,
startEditing,
stopEditing,
cancelEdit,
localTitle,
updateTitle,
handleChange,
inputRef: React.createRef(),
};
};

View File

@@ -0,0 +1,154 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { actions, selectors } from '../../../../data/redux';
import { MockUseState } from '../../../../../testUtils';
import * as hooks from './hooks';
jest.mock('react', () => {
const updateState = jest.fn();
return {
updateState,
useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])),
createRef: jest.fn(val => ({ ref: val })),
};
});
jest.mock('../../hooks', () => ({
navigateCallback: (args) => ({ navigateCallback: args }),
}));
jest.mock('../../../../data/redux', () => ({
actions: {
app: {
setBlockTitle: (args) => ({ setBlockTitle: args }),
},
},
selectors: {
app: {
displayTitle: (state) => ({ displayTitle: state }),
},
},
}));
const state = new MockUseState(hooks);
describe('TitleHeader hooks', () => {
let output;
let dispatch;
beforeEach(() => {
dispatch = jest.fn();
});
describe('state hooks', () => {
state.testGetter(state.keys.localTitle);
});
describe('non-state hooks', () => {
beforeEach(() => { state.mock(); });
afterEach(() => { state.restore(); });
describe('isEditing', () => {
beforeEach(() => {
output = hooks.hooks.isEditing();
});
test('returns isEditing field, defaulted to false', () => {
expect(output.isEditing).toEqual({ state: false });
});
test('startEditing calls the setter function with true', () => {
output.startEditing();
expect(React.updateState).toHaveBeenCalledWith({ val: false, newVal: true });
});
test('stopEditing calls the setter function with false', () => {
output.stopEditing();
expect(React.updateState).toHaveBeenCalledWith({ val: false, newVal: false });
});
});
describe('localTitle', () => {
let stopEditing;
beforeEach(() => {
stopEditing = jest.fn();
output = hooks.hooks.localTitle({ dispatch, stopEditing });
});
test('returns the state value for localTitle, defaulted to displayTitle', () => {
expect(output.localTitle).toEqual(useSelector(selectors.app.displayTitle));
});
describe('updateTitle hook', () => {
it('calls setBlockTitle with localTitle, and stopEditing', () => {
const div = document.createElement('div');
const mockEvent = { currentTarget: div };
output.updateTitle(mockEvent);
expect(dispatch).toHaveBeenCalledWith(actions.app.setBlockTitle(output.localTitle));
expect(stopEditing).toHaveBeenCalled();
});
});
describe('handleChange', () => {
it('calls setLocalTitle with the event target value', () => {
const value = 'SOME VALUe';
output.handleChange({ target: { value } });
expect(state.setState[state.keys.localTitle]).toHaveBeenCalledWith(value);
});
});
describe('cancelEdit', () => {
it('calls setLocalTitle with previously stored title, and stopEditing', () => {
output.cancelEdit();
expect(state.setState[state.keys.localTitle]).toHaveBeenCalledWith(useSelector(selectors.app.displayTitle));
expect(stopEditing).toHaveBeenCalled();
});
});
});
describe('local title hooks', () => {
let oldHooks;
const values = {
isEditing: 'ISeDITING',
startEditing: 'STARTeDITING',
stopEditing: 'STOPeDITING',
handleChange: 'HANDLEcHANGE',
localTitle: 'LOCALtITLE',
cancelEdit: 'CANCelEDit',
};
const newHooks = {
isEditing: () => ({
isEditing: values.isEditing,
startEditing: values.startEditing,
stopEditing: values.stopEditing,
}),
localTitle: jest.fn((args) => ({
updateTitle: args,
handleChange: values.handleChange,
localTitle: values.localTitle,
cancelEdit: values.cancelEdit,
})),
handleKeyDown: jest.fn(args => ({ handleKeyDown: args })),
};
beforeEach(() => {
oldHooks = hooks.hooks;
hooks.hooks.isEditing = newHooks.isEditing;
hooks.hooks.localTitle = newHooks.localTitle;
hooks.hooks.handleKeyDown = newHooks.handleKeyDown;
output = hooks.localTitleHooks({ dispatch });
});
afterEach(() => {
// eslint-disable-next-line no-import-assign
hooks.hooks = oldHooks;
});
it('returns isEditing, startEditing, and stopEditing, tied to the isEditing hook', () => {
expect(output.isEditing).toEqual(values.isEditing);
expect(output.startEditing).toEqual(values.startEditing);
expect(output.stopEditing).toEqual(values.stopEditing);
});
it('returns localTitle, updateTitle, handleChange, and cancelEdit tied to the localTitle hook', () => {
expect(output.updateTitle).toEqual({
dispatch,
stopEditing: values.stopEditing,
});
expect(output.handleChange).toEqual(values.handleChange);
expect(output.localTitle).toEqual(values.localTitle);
expect(output.cancelEdit).toEqual(values.cancelEdit);
});
it('returns a new ref for inputRef', () => {
expect(output.inputRef).toEqual({ ref: undefined });
});
});
});
});

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { Icon, IconButton, Truncate } from '@openedx/paragon';
import { EditOutline } from '@openedx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { selectors } from '../../../../data/redux';
import { localTitleHooks } from './hooks';
import messages from './messages';
import EditableHeader from './EditableHeader';
export const TitleHeader = ({
isInitialized,
// injected
intl,
}) => {
if (!isInitialized) { return intl.formatMessage(messages.loading); }
// eslint-disable-next-line react-hooks/rules-of-hooks
const dispatch = useDispatch();
// eslint-disable-next-line react-hooks/rules-of-hooks
const title = useSelector(selectors.app.displayTitle);
const {
inputRef,
isEditing,
handleChange,
handleKeyDown,
localTitle,
startEditing,
cancelEdit,
updateTitle,
} = localTitleHooks({ dispatch });
if (isEditing) {
return (
<EditableHeader
{...{
inputRef,
handleChange,
handleKeyDown,
localTitle,
updateTitle,
cancelEdit,
}}
/>
);
}
return (
<div className="d-flex flex-row align-items-center mt-1">
<Truncate>
{title}
</Truncate>
<IconButton
alt={intl.formatMessage(messages.editTitleLabel)}
iconAs={Icon}
className="mx-2"
onClick={startEditing}
size="sm"
src={EditOutline}
/>
</div>
);
};
TitleHeader.defaultProps = {};
TitleHeader.propTypes = {
isInitialized: PropTypes.bool.isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(TitleHeader);

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { shallow } from '@edx/react-unit-test-utils';
import { formatMessage } from '../../../../../testUtils';
import { localTitleHooks } from './hooks';
import * as module from '.';
jest.mock('./hooks', () => ({
localTitleHooks: jest.fn(),
}));
jest.mock('@openedx/paragon', () => ({
...jest.requireActual('@openedx/paragon'),
Truncate: ({ children }) => <div>{children}</div>, // eslint-disable-line react/prop-types
IconButton: 'IconButton',
Icon: 'Icon',
}));
jest.mock('./EditableHeader');
describe('TitleHeader', () => {
const props = {
intl: { formatMessage },
isInitialized: false,
setTitle: jest.fn().mockName('args.setTitle'),
title: 'html',
};
const localTitleHooksProps = {
inputRef: jest.fn().mockName('localTitleHooks.inputRef'),
isEditing: false,
handleChange: jest.fn().mockName('localTitleHooks.handleChange'),
handleKeyDown: jest.fn().mockName('localTitleHooks.handleKeyDown'),
localTitle: 'TeST LocALtitLE',
startEditing: jest.fn().mockName('localTitleHooks.startEditing'),
updateTitle: jest.fn().mockName('localTitleHooks.updateTitle'),
};
describe('behavior', () => {
it(' calls localTitleHooks with initialization args', () => {
localTitleHooks.mockReturnValue(localTitleHooksProps);
shallow(<module.TitleHeader {...props} isInitialized />);
const dispatch = useDispatch();
expect(localTitleHooks).toHaveBeenCalledWith({
dispatch,
});
});
});
describe('snapshots', () => {
test('not initialized', () => {
expect(shallow(<module.TitleHeader {...props} />).snapshot).toMatchSnapshot();
});
test('initialized', () => {
localTitleHooks.mockReturnValue(localTitleHooksProps);
expect(shallow(<module.TitleHeader {...props} isInitialized />).shallowWrapper).toMatchSnapshot();
});
test('editing', () => {
localTitleHooks.mockReturnValue({ ...localTitleHooksProps, isEditing: true });
expect(shallow(<module.TitleHeader {...props} isInitialized />).snapshot).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,32 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
loading: {
id: 'authoring.texteditor.title.loading',
defaultMessage: 'Loading...',
description: 'Message displayed while loading content',
},
cancelChangesLabel: {
id: 'authoring.texteditor.header.cancelChangesLabel',
defaultMessage: 'Cancel Changes and Return to Learning Context',
description: 'Screen reader label title for icon button to return to learning context',
},
editTitleLabel: {
id: 'authoring.texteditor.header.editTitleLabel',
defaultMessage: 'Edit Title',
description: 'Screen reader label title for icon button to edit the xblock title',
},
cancelTitleEdit: {
id: 'authoring.texteditor.header.cancelTitleEdit',
defaultMessage: 'Cancel',
description: 'Screen reader label title for icon button to edit the xblock title',
},
saveTitleEdit: {
id: 'authoring.texteditor.header.saveTitleEdit',
defaultMessage: 'Save',
description: 'Screen reader label title for icon button to edit the xblock title',
},
});
export default messages;

View File

@@ -0,0 +1,76 @@
import { useState } from 'react';
import { useSelector } from 'react-redux';
import analyticsEvt from '../../data/constants/analyticsEvt';
import { RequestKeys } from '../../data/constants/requests';
import { selectors } from '../../data/redux';
import { StrictDict } from '../../utils';
import * as appHooks from '../../hooks';
import * as module from './hooks';
export const {
clearSaveError,
navigateCallback,
nullMethod,
saveBlock,
} = appHooks;
export const state = StrictDict({
// eslint-disable-next-line react-hooks/rules-of-hooks
isCancelConfirmModalOpen: (val) => useState(val),
});
export const handleSaveClicked = ({
dispatch,
getContent,
validateEntry,
returnFunction,
}) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const returnUrl = useSelector(selectors.app.returnUrl);
const destination = returnFunction ? '' : returnUrl;
// eslint-disable-next-line react-hooks/rules-of-hooks
const analytics = useSelector(selectors.app.analytics);
return () => saveBlock({
analytics,
content: getContent({ dispatch }),
destination,
dispatch,
returnFunction,
validateEntry,
});
};
export const cancelConfirmModalToggle = () => {
const [isCancelConfirmOpen, setIsOpen] = module.state.isCancelConfirmModalOpen(false);
return {
isCancelConfirmOpen,
openCancelConfirmModal: () => setIsOpen(true),
closeCancelConfirmModal: () => setIsOpen(false),
};
};
export const handleCancel = ({ onClose, returnFunction }) => {
if (onClose) {
return onClose;
}
// eslint-disable-next-line react-hooks/rules-of-hooks
const returnUrl = useSelector(selectors.app.returnUrl);
return navigateCallback({
returnFunction,
// eslint-disable-next-line react-hooks/rules-of-hooks
destination: returnFunction ? '' : returnUrl,
analyticsEvent: analyticsEvt.editorCancelClick,
// eslint-disable-next-line react-hooks/rules-of-hooks
analytics: useSelector(selectors.app.analytics),
});
};
// eslint-disable-next-line react-hooks/rules-of-hooks
export const isInitialized = () => useSelector(selectors.app.isInitialized);
// eslint-disable-next-line react-hooks/rules-of-hooks
export const saveFailed = () => useSelector((rootState) => (
selectors.requests.isFailed(rootState, { requestKey: RequestKeys.saveBlock })
));

View File

@@ -0,0 +1,149 @@
import * as reactRedux from 'react-redux';
import { MockUseState } from '../../../testUtils';
import { RequestKeys } from '../../data/constants/requests';
import { selectors } from '../../data/redux';
import * as appHooks from '../../hooks';
import * as hooks from './hooks';
import analyticsEvt from '../../data/constants/analyticsEvt';
const hookState = new MockUseState(hooks);
jest.mock('../../data/redux', () => ({
selectors: {
app: {
isInitialized: (state) => ({ isInitialized: state }),
images: (state) => ({ images: state }),
},
requests: {
isFailed: (...args) => ({ requestFailed: args }),
},
},
}));
jest.mock('../../hooks', () => ({
...jest.requireActual('../../hooks'),
navigateCallback: jest.fn((args) => ({ navigateCallback: args })),
saveBlock: jest.fn((args) => ({ saveBlock: args })),
}));
const dispatch = jest.fn();
describe('EditorContainer hooks', () => {
describe('forwarded hooks', () => {
it('forwards clearSaveError from app hooks', () => {
expect(hooks.clearSaveError).toEqual(appHooks.clearSaveError);
});
it('forwards navigateCallback from app hooks', () => {
expect(hooks.navigateCallback).toEqual(appHooks.navigateCallback);
});
it('forwards nullMethod from app hooks', () => {
expect(hooks.nullMethod).toEqual(appHooks.nullMethod);
});
it('forwards saveBlock from app hooks', () => {
expect(hooks.saveBlock).toEqual(appHooks.saveBlock);
});
});
describe('local hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('handleSaveClicked', () => {
it('returns callback to saveBlock with dispatch and content from setAssetToStaticUrl', () => {
const getContent = () => 'myTestContentValue';
const setAssetToStaticUrl = () => 'myTestContentValue';
const validateEntry = () => 'vaLIdAteENTry';
const output = hooks.handleSaveClicked({
getContent,
images: {
portableUrl: '/static/sOmEuiMAge.jpeg',
displayName: 'sOmEuiMAge',
},
destination: 'testDEsTURL',
analytics: 'soMEanALytics',
dispatch,
validateEntry,
});
output();
expect(appHooks.saveBlock).toHaveBeenCalledWith({
content: setAssetToStaticUrl(reactRedux.useSelector(selectors.app.images), getContent),
destination: reactRedux.useSelector(selectors.app.returnUrl),
analytics: reactRedux.useSelector(selectors.app.analytics),
dispatch,
validateEntry,
});
});
});
describe('cancelConfirmModalToggle', () => {
const hookKey = hookState.keys.isCancelConfirmModalOpen;
beforeEach(() => {
jest.clearAllMocks();
});
describe('state hook', () => {
hookState.testGetter(hookKey);
});
describe('using state', () => {
beforeEach(() => {
hookState.mock();
});
afterEach(() => {
hookState.restore();
});
describe('cancelConfirmModalToggle', () => {
let hook;
beforeEach(() => {
hook = hooks.cancelConfirmModalToggle();
});
test('isCancelConfirmOpen: state value', () => {
expect(hook.isCancelConfirmOpen).toEqual(hookState.stateVals[hookKey]);
});
test('openCancelConfirmModal: calls setter with true', () => {
hook.openCancelConfirmModal();
expect(hookState.setState[hookKey]).toHaveBeenCalledWith(true);
});
test('closeCancelConfirmModal: calls setter with false', () => {
hook.closeCancelConfirmModal();
expect(hookState.setState[hookKey]).toHaveBeenCalledWith(false);
});
});
});
});
describe('handleCancel', () => {
it('calls navigateCallback to returnUrl if onClose is not passed', () => {
expect(hooks.handleCancel({})).toEqual(
appHooks.navigateCallback({
destination: reactRedux.useSelector(selectors.app.returnUrl),
analyticsEvent: analyticsEvt.editorCancelClick,
analytics: reactRedux.useSelector(selectors.app.analytics),
}),
);
});
it('calls onClose and not navigateCallback if onClose is passed', () => {
const onClose = () => 'my close value';
expect(hooks.handleCancel({ onClose })).toEqual(onClose);
expect(appHooks.navigateCallback).not.toHaveBeenCalled();
});
});
describe('isInitialized', () => {
it('forwards selectors.app.isInitialized', () => {
expect(hooks.isInitialized()).toEqual(
reactRedux.useSelector(selectors.app.isInitialized),
);
});
});
describe('saveFailed', () => {
it('forwards requests.isFailed selector for saveBlock request', () => {
const testState = { some: 'state' };
const testValue = 'Some data';
reactRedux.useSelector.mockReturnValueOnce(testValue);
expect(hooks.saveFailed()).toEqual(testValue);
const [[cb]] = reactRedux.useSelector.mock.calls;
expect(cb(testState)).toEqual(
selectors.requests.isFailed(testState, { requestKey: RequestKeys.saveBlock }),
);
});
});
});
});

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import {
Icon, ModalDialog, IconButton, Button,
} from '@openedx/paragon';
import { Close } from '@openedx/paragon/icons';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import BaseModal from '../../sharedComponents/BaseModal';
import EditorFooter from './components/EditorFooter';
import TitleHeader from './components/TitleHeader';
import * as hooks from './hooks';
import messages from './messages';
import './index.scss';
export const EditorContainer = ({
children,
getContent,
onClose,
validateEntry,
returnFunction,
// injected
intl,
}) => {
const dispatch = useDispatch();
const isInitialized = hooks.isInitialized();
const { isCancelConfirmOpen, openCancelConfirmModal, closeCancelConfirmModal } = hooks.cancelConfirmModalToggle();
const handleCancel = hooks.handleCancel({ onClose, returnFunction });
return (
<div
className="editor-container d-flex flex-column position-relative zindex-0"
style={{ minHeight: '100%' }}
>
<BaseModal
size="md"
confirmAction={(
<Button
variant="primary"
onClick={() => {
handleCancel();
if (returnFunction) {
closeCancelConfirmModal();
}
}}
>
<FormattedMessage {...messages.okButtonLabel} />
</Button>
)}
isOpen={isCancelConfirmOpen}
close={closeCancelConfirmModal}
title={intl.formatMessage(messages.cancelConfirmTitle)}
>
<FormattedMessage {...messages.cancelConfirmDescription} />
</BaseModal>
<ModalDialog.Header className="shadow-sm zindex-10">
<div className="d-flex flex-row justify-content-between">
<h2 className="h3 col pl-0">
<TitleHeader isInitialized={isInitialized} />
</h2>
<IconButton
src={Close}
iconAs={Icon}
onClick={openCancelConfirmModal}
/>
</div>
</ModalDialog.Header>
<ModalDialog.Body className="pb-0 mb-6">
{isInitialized && children}
</ModalDialog.Body>
<EditorFooter
clearSaveFailed={hooks.clearSaveError({ dispatch })}
disableSave={!isInitialized}
onCancel={openCancelConfirmModal}
onSave={hooks.handleSaveClicked({
dispatch,
getContent,
validateEntry,
returnFunction,
})}
saveFailed={hooks.saveFailed()}
/>
</div>
);
};
EditorContainer.defaultProps = {
onClose: null,
returnFunction: null,
validateEntry: null,
};
EditorContainer.propTypes = {
children: PropTypes.node.isRequired,
getContent: PropTypes.func.isRequired,
onClose: PropTypes.func,
returnFunction: PropTypes.func,
validateEntry: PropTypes.func,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(EditorContainer);

View File

@@ -0,0 +1,6 @@
// fix double scrollbars
.editor-container {
.pgn__modal-body {
overflow: visible;
}
}

View File

@@ -0,0 +1,67 @@
import { shallow } from '@edx/react-unit-test-utils';
import { useDispatch } from 'react-redux';
import { EditorContainer } from '.';
import * as hooks from './hooks';
import { formatMessage } from '../../../testUtils';
const props = {
getContent: jest.fn().mockName('props.getContent'),
onClose: jest.fn().mockName('props.onClose'),
validateEntry: jest.fn().mockName('props.validateEntry'),
returnFunction: jest.fn().mockName('props.returnFunction'),
// inject
intl: { formatMessage },
};
jest.mock('./hooks', () => ({
clearSaveError: jest.fn().mockName('hooks.clearSaveError'),
isInitialized: jest.fn().mockReturnValue(true),
handleCancel: (args) => ({ handleCancel: args }),
handleSaveClicked: (args) => ({ handleSaveClicked: args }),
saveFailed: jest.fn().mockName('hooks.saveFailed'),
cancelConfirmModalToggle: jest.fn(() => ({
isCancelConfirmOpen: false,
openCancelConfirmModal: jest.fn().mockName('openCancelConfirmModal'),
closeCancelConfirmModal: jest.fn().mockName('closeCancelConfirmModal'),
})),
}));
let el;
describe('EditorContainer component', () => {
describe('render', () => {
const testContent = (<h1>My test content</h1>);
test('snapshot: not initialized. disable save and pass to header', () => {
hooks.isInitialized.mockReturnValueOnce(false);
expect(
shallow(<EditorContainer {...props}>{testContent}</EditorContainer>).snapshot,
).toMatchSnapshot();
});
test('snapshot: initialized. enable save and pass to header', () => {
expect(
shallow(<EditorContainer {...props}>{testContent}</EditorContainer>).snapshot,
).toMatchSnapshot();
});
describe('behavior inspection', () => {
beforeEach(() => {
el = shallow(<EditorContainer {...props}>{testContent}</EditorContainer>);
});
test('save behavior is linked to footer onSave', () => {
const expected = hooks.handleSaveClicked({
dispatch: useDispatch(),
getContent: props.getContent,
validateEntry: props.validateEntry,
returnFunction: props.returnFunction,
});
expect(el.shallowWrapper.props.children[3]
.props.onSave).toEqual(expected);
});
test('behavior is linked to clearSaveError', () => {
const expected = hooks.clearSaveError({ dispatch: useDispatch() });
expect(el.shallowWrapper.props.children[3]
.props.clearSaveFailed).toEqual(expected);
});
});
});
});

View File

@@ -0,0 +1,22 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
cancelConfirmTitle: {
id: 'authoring.editorContainer.cancelConfirm.title',
defaultMessage: 'Exit the editor?',
description: 'Label for modal confirming cancellation',
},
cancelConfirmDescription: {
id: 'authoring.editorContainer.cancelConfirm.description',
defaultMessage: 'Are you sure you want to exit the editor? Any unsaved changes will be lost.',
description: 'Description text for modal confirming cancellation',
},
okButtonLabel: {
id: 'authoring.editorContainer.okButton.label',
defaultMessage: 'OK',
description: 'Label for OK button',
},
});
export default messages;

View File

@@ -0,0 +1,102 @@
/* eslint-disable import/extensions */
/* eslint-disable import/no-unresolved */
/**
* This is an example component for an xblock Editor
* It uses pre-existing components to handle the saving of a the result of a function into the xblock's data.
* To use run npm run-script addXblock <your>
*/
/* eslint-disable no-unused-vars */
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { Spinner } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import EditorContainer from '../EditorContainer';
import * as module from '.';
import { actions, selectors } from '../../data/redux';
import { RequestKeys } from '../../data/constants/requests';
export const hooks = {
getContent: () => ({
some: 'content',
}),
};
export const thumbEditor = ({
onClose,
// redux
blockValue,
lmsEndpointUrl,
blockFailed,
blockFinished,
initializeEditor,
exampleValue,
// inject
intl,
}) => (
<EditorContainer
getContent={module.hooks.getContent}
onClose={onClose}
>
<div>
{exampleValue}
</div>
<div className="editor-body h-75 overflow-auto">
{!blockFinished
? (
<div className="text-center p-6">
<Spinner
animation="border"
className="m-3"
// Use a messages.js file for intl messages.
screenreadertext={intl.formatMessage('Loading Spinner')}
/>
</div>
)
: (
<p>
Your Editor Goes here.
You can get at the xblock data with the blockValue field.
here is what is in your xblock: {JSON.stringify(blockValue)}
</p>
)}
</div>
</EditorContainer>
);
thumbEditor.defaultProps = {
blockValue: null,
lmsEndpointUrl: null,
};
thumbEditor.propTypes = {
onClose: PropTypes.func.isRequired,
// redux
blockValue: PropTypes.shape({
data: PropTypes.shape({ data: PropTypes.string }),
}),
lmsEndpointUrl: PropTypes.string,
blockFailed: PropTypes.bool.isRequired,
blockFinished: PropTypes.bool.isRequired,
initializeEditor: PropTypes.func.isRequired,
// inject
intl: intlShape.isRequired,
};
export const mapStateToProps = (state) => ({
blockValue: selectors.app.blockValue(state),
lmsEndpointUrl: selectors.app.lmsEndpointUrl(state),
blockFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchBlock }),
blockFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchBlock }),
// TODO fill with redux state here if needed
exampleValue: selectors.game.exampleValue(state),
});
export const mapDispatchToProps = {
initializeEditor: actions.app.initializeEditor,
// TODO fill with dispatches here if needed
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(thumbEditor));

View File

@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ProblemEditor snapshots block failed, message appears 1`] = `
<div
className="text-center p-6"
>
<FormattedMessage
defaultMessage="Problem failed to load"
description="Error message for problem block failing to load"
id="authoring.problemEditor.blockFailed"
/>
</div>
`;
exports[`ProblemEditor snapshots renders as expected with default behavior 1`] = `
<div
className="text-center p-6"
>
<Spinner
animation="border"
className="m-3"
screenreadertext="Loading Problem Editor"
/>
</div>
`;

View File

@@ -0,0 +1,145 @@
import React, { memo } from 'react';
import { connect, useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import {
Collapsible,
Icon,
IconButton,
Form,
} from '@openedx/paragon';
import { FeedbackOutline, DeleteOutline } from '@openedx/paragon/icons';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import { selectors } from '../../../../../data/redux';
import { answerOptionProps } from '../../../../../data/services/cms/types';
import Checker from './components/Checker';
import { FeedbackBox } from './components/Feedback';
import * as hooks from './hooks';
import { ProblemTypeKeys } from '../../../../../data/constants/problem';
import ExpandableTextArea from '../../../../../sharedComponents/ExpandableTextArea';
export const AnswerOption = ({
answer,
hasSingleAnswer,
// injected
intl,
// redux
problemType,
}) => {
const dispatch = useDispatch();
const removeAnswer = hooks.removeAnswer({ answer, dispatch });
const setAnswer = hooks.setAnswer({ answer, hasSingleAnswer, dispatch });
const setAnswerTitle = hooks.setAnswerTitle({
answer,
hasSingleAnswer,
dispatch,
problemType,
});
const setSelectedFeedback = hooks.setSelectedFeedback({ answer, hasSingleAnswer, dispatch });
const setUnselectedFeedback = hooks.setUnselectedFeedback({ answer, hasSingleAnswer, dispatch });
const { isFeedbackVisible, toggleFeedback } = hooks.useFeedback(answer);
const getInputArea = () => {
if ([ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT].includes(problemType)) {
return (
<ExpandableTextArea
value={answer.title}
setContent={setAnswerTitle}
placeholder={intl.formatMessage(messages.answerTextboxPlaceholder)}
id={`answer-${answer.id}`}
/>
);
}
if (problemType !== ProblemTypeKeys.NUMERIC || !answer.isAnswerRange) {
return (
<Form.Control
as="textarea"
className="answer-option-textarea text-gray-500 small"
autoResize
rows={1}
value={answer.title}
onChange={setAnswerTitle}
placeholder={intl.formatMessage(messages.answerTextboxPlaceholder)}
/>
);
}
// Return Answer Range View
return (
<div>
<Form.Control
as="textarea"
className="answer-option-textarea text-gray-500 small"
autoResize
rows={1}
value={answer.title}
onChange={setAnswerTitle}
placeholder={intl.formatMessage(messages.answerRangeTextboxPlaceholder)}
/>
<div className="pgn__form-switch-helper-text">
<FormattedMessage {...messages.answerRangeHelperText} />
</div>
</div>
);
};
return (
<Collapsible.Advanced
open={isFeedbackVisible}
onToggle={toggleFeedback}
className="answer-option d-flex flex-row justify-content-between flex-nowrap pb-2 pt-2"
>
<div className="mr-1 d-flex">
<Checker
hasSingleAnswer={hasSingleAnswer}
answer={answer}
setAnswer={setAnswer}
disabled={problemType === ProblemTypeKeys.NUMERIC}
/>
</div>
<div className="ml-1 flex-grow-1">
{getInputArea()}
<Collapsible.Body>
<FeedbackBox
problemType={problemType}
answer={answer}
setSelectedFeedback={setSelectedFeedback}
setUnselectedFeedback={setUnselectedFeedback}
intl={intl}
/>
</Collapsible.Body>
</div>
<div className="d-flex flex-row flex-nowrap">
<Collapsible.Trigger aria-label="Toggle feedback" className="btn-icon btn-icon-primary btn-icon-md align-items-center">
<Icon
src={FeedbackOutline}
alt={intl.formatMessage(messages.feedbackToggleIconAltText)}
/>
</Collapsible.Trigger>
<IconButton
src={DeleteOutline}
iconAs={Icon}
alt={intl.formatMessage(messages.answerDeleteIconAltText)}
onClick={removeAnswer}
variant="primary"
/>
</div>
</Collapsible.Advanced>
);
};
AnswerOption.propTypes = {
answer: answerOptionProps.isRequired,
hasSingleAnswer: PropTypes.bool.isRequired,
// injected
intl: intlShape.isRequired,
// redux
problemType: PropTypes.string.isRequired,
};
export const mapStateToProps = (state) => ({
problemType: selectors.problem.problemType(state),
});
export const mapDispatchToProps = {};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(memo(AnswerOption)));

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { formatMessage } from '../../../../../../testUtils';
import { selectors } from '../../../../../data/redux';
import { AnswerOption, mapStateToProps } from './AnswerOption';
jest.mock('../../../../../data/redux', () => ({
__esModule: true,
default: jest.fn(),
selectors: {
problem: {
answers: jest.fn(state => ({ answers: state })),
problemType: jest.fn(state => ({ problemType: state })),
},
},
thunkActions: {
video: jest.fn(),
},
}));
describe('AnswerOption', () => {
const answerWithOnlyFeedback = {
id: 'A',
title: 'Answer 1',
correct: true,
selectedFeedback: 'some feedback',
};
const answerWithSelectedUnselectedFeedback = {
id: 'A',
title: 'Answer 1',
correct: true,
selectedFeedback: 'selected feedback',
unselectedFeedback: 'unselected feedback',
};
const answerRange = {
id: 'A',
title: 'Answer 1',
correct: true,
selectedFeedback: 'selected feedback',
unselectedFeedback: 'unselected feedback',
isAnswerRange: true,
};
const props = {
hasSingleAnswer: false,
answer: answerWithOnlyFeedback,
// inject
intl: { formatMessage },
// redux
problemType: 'multiplechoiceresponse',
};
describe('render', () => {
test('snapshot: renders correct option with feedback', () => {
expect(shallow(<AnswerOption {...props} />).snapshot).toMatchSnapshot();
});
test('snapshot: renders correct option with selected unselected feedback', () => {
expect(shallow(<AnswerOption {...props} problemType="choiceresponse" answer={answerWithSelectedUnselectedFeedback} />).snapshot).toMatchSnapshot();
});
test('snapshot: renders correct option with numeric input problem', () => {
expect(shallow(<AnswerOption {...props} problemType="numericalresponse" />).snapshot).toMatchSnapshot();
});
test('snapshot: renders correct option with numeric input problem and answer range', () => {
expect(shallow(<AnswerOption {...props} problemType="numericalresponse" answer={answerRange} />).snapshot).toMatchSnapshot();
});
});
describe('mapStateToProps', () => {
const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
test('problemType from problem.problemType', () => {
expect(
mapStateToProps(testState).problemType,
).toEqual(selectors.problem.problemType(testState));
});
});
});

View File

@@ -0,0 +1,98 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Dropdown, Icon } from '@openedx/paragon';
import { Add } from '@openedx/paragon/icons';
import messages from './messages';
import { useAnswerContainer, isSingleAnswerProblem } from './hooks';
import { actions, selectors } from '../../../../../data/redux';
import { answerOptionProps } from '../../../../../data/services/cms/types';
import AnswerOption from './AnswerOption';
import Button from '../../../../../sharedComponents/Button';
import { ProblemTypeKeys } from '../../../../../data/constants/problem';
export const AnswersContainer = ({
problemType,
// Redux
answers,
addAnswer,
addAnswerRange,
updateField,
}) => {
const hasSingleAnswer = isSingleAnswerProblem(problemType);
useAnswerContainer({ answers, problemType, updateField });
return (
<div className="answers-container border border-light-700 rounded py-4 pl-4 pr-3">
{answers.map((answer) => (
<AnswerOption
key={answer.id}
hasSingleAnswer={hasSingleAnswer}
answer={answer}
/>
))}
{problemType !== ProblemTypeKeys.NUMERIC ? (
<Button
variant="add"
onClick={addAnswer}
>
<FormattedMessage {...messages.addAnswerButtonText} />
</Button>
) : (
<Dropdown>
<Dropdown.Toggle
id="Add-Answer-Or-Answer-Range"
variant="tertiary"
className="pl-0"
>
<Icon
src={Add}
/>
<FormattedMessage {...messages.addAnswerButtonText} />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
key="add-answer"
onClick={addAnswer}
className={`AddAnswerRange ${answers.length === 1 && answers[0].isAnswerRange ? 'disabled' : ''}`}
>
<FormattedMessage {...messages.addAnswerButtonText} />
</Dropdown.Item>
<Dropdown.Item
key="add-answer-range"
onClick={addAnswerRange}
className={`AddAnswerRange ${answers.length > 1 || (answers.length === 1 && answers[0].isAnswerRange) ? 'disabled' : ''}`}
>
<FormattedMessage {...messages.addAnswerRangeButtonText} />
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
)}
</div>
);
};
AnswersContainer.propTypes = {
problemType: PropTypes.string.isRequired,
answers: PropTypes.arrayOf(answerOptionProps).isRequired,
addAnswer: PropTypes.func.isRequired,
addAnswerRange: PropTypes.func.isRequired,
updateField: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
answers: selectors.problem.answers(state),
});
export const mapDispatchToProps = {
addAnswer: actions.problem.addAnswer,
addAnswerRange: actions.problem.addAnswerRange,
updateField: actions.problem.updateField,
};
export default connect(mapStateToProps, mapDispatchToProps)(AnswersContainer);

View File

@@ -0,0 +1,166 @@
/* eslint-disable react/prop-types */
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { act, render, waitFor } from '@testing-library/react';
import { actions, selectors } from '../../../../../data/redux';
import * as module from './AnswersContainer';
import { AnswersContainer as AnswersContainerWithoutHOC } from './AnswersContainer';
import { ProblemTypeKeys } from '../../../../../data/constants/problem';
jest.mock('@edx/frontend-platform/i18n', () => ({
FormattedMessage: ({ defaultMessage }) => (<p>{defaultMessage}</p>),
defineMessages: m => m,
injectIntl: (args) => args,
intlShape: {},
getLocale: jest.fn(),
}));
jest.mock('./AnswerOption', () => function mockAnswerOption() {
return <div>MockAnswerOption</div>;
});
jest.mock('../../../../../data/redux', () => ({
actions: {
problem: {
updateField: jest.fn().mockName('actions.problem.updateField'),
addAnswer: jest.fn().mockName('actions.problem.addAnswer'),
},
},
selectors: {
problem: {
answers: jest.fn(state => ({ answers: state })),
},
},
}));
describe('AnswersContainer', () => {
const props = {
answers: [],
updateField: jest.fn(),
addAnswer: jest.fn(),
};
describe('render', () => {
test('snapshot: renders correct default', () => {
act(() => {
expect(shallow(<module.AnswersContainer {...props} />).snapshot).toMatchSnapshot();
});
});
test('snapshot: renders correctly with answers', () => {
act(() => {
expect(shallow(
<module.AnswersContainer
{...props}
answers={[{ id: 'a', title: 'sOMetITlE', correct: true }, { id: 'b', title: 'sOMetITlE', correct: true }]}
/>,
).snapshot).toMatchSnapshot();
});
});
test('snapshot: numeric problems: answer range/answer select button: empty', () => {
act(() => {
const emptyAnswerProps = {
problemType: ProblemTypeKeys.NUMERIC,
answers: [],
updateField: jest.fn(),
addAnswer: jest.fn(),
addAnswerRange: jest.fn(),
};
expect(shallow(
<module.AnswersContainer
{...emptyAnswerProps}
/>,
).snapshot).toMatchSnapshot();
});
});
test('snapshot: numeric problems: answer range/answer select button: Range disables the additon of more adds', () => {
act(() => {
const answerRangeProps = {
problemType: ProblemTypeKeys.NUMERIC,
answers: [{
id: 'A',
title: 'Answer 1',
correct: true,
selectedFeedback: 'selected feedback',
unselectedFeedback: 'unselected feedback',
isAnswerRange: true,
}],
updateField: jest.fn(),
addAnswer: jest.fn(),
addAnswerRange: jest.fn(),
};
expect(shallow(
<module.AnswersContainer
{...answerRangeProps}
/>,
).snapshot).toMatchSnapshot();
});
});
test('snapshot: numeric problems: answer range/answer select button: multiple answers disables range.', () => {
act(() => {
const answersProps = {
problemType: ProblemTypeKeys.NUMERIC,
answers: [{
id: 'A',
title: 'Answer 1',
correct: true,
selectedFeedback: 'selected feedback',
unselectedFeedback: 'unselected feedback',
isAnswerRange: false,
},
{
id: 'B',
title: 'Answer 1',
correct: true,
selectedFeedback: 'selected feedback',
unselectedFeedback: 'unselected feedback',
isAnswerRange: false,
},
],
updateField: jest.fn(),
addAnswer: jest.fn(),
addAnswerRange: jest.fn(),
};
expect(shallow(
<module.AnswersContainer
{...answersProps}
/>,
).snapshot).toMatchSnapshot();
});
});
test('useAnswerContainer', async () => {
let container = null;
await act(async () => {
const wrapper = render(
<AnswersContainerWithoutHOC
{...props}
answers={[{ id: 'a', title: 'sOMetITlE', correct: true }, { id: 'b', title: 'sOMetITlE', correct: true }]}
/>,
);
container = wrapper.container;
});
await waitFor(() => expect(container.querySelector('button')).toBeTruthy());
await new Promise(resolve => setTimeout(resolve, 500));
expect(props.updateField).toHaveBeenCalledWith(expect.objectContaining({ correctAnswerCount: 2 }));
});
});
describe('mapStateToProps', () => {
const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
test('answers from problem.answers', () => {
expect(
module.mapStateToProps(testState).answers,
).toEqual(selectors.problem.answers(testState));
});
});
describe('mapDispatchToProps', () => {
test('updateField from actions.problem.updateField', () => {
expect(module.mapDispatchToProps.updateField).toEqual(actions.problem.updateField);
});
test('updateField from actions.problem.addAnswer', () => {
expect(module.mapDispatchToProps.addAnswer).toEqual(actions.problem.addAnswer);
});
});
});

View File

@@ -0,0 +1,324 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AnswerOption render snapshot: renders correct option with feedback 1`] = `
<Advanced
className="answer-option d-flex flex-row justify-content-between flex-nowrap pb-2 pt-2"
onToggle={[Function]}
open={false}
>
<div
className="mr-1 d-flex"
>
<Checker
answer={
{
"correct": true,
"id": "A",
"selectedFeedback": "some feedback",
"title": "Answer 1",
}
}
disabled={false}
hasSingleAnswer={false}
setAnswer={[Function]}
/>
</div>
<div
className="ml-1 flex-grow-1"
>
<ExpandableTextArea
error={false}
errorMessage={null}
id="answer-A"
placeholder="Enter an answer"
setContent={[Function]}
value="Answer 1"
/>
<Body>
<injectIntl(ShimmedIntlComponent)
answer={
{
"correct": true,
"id": "A",
"selectedFeedback": "some feedback",
"title": "Answer 1",
}
}
intl={
{
"formatMessage": [Function],
}
}
problemType="multiplechoiceresponse"
setSelectedFeedback={[Function]}
setUnselectedFeedback={[Function]}
/>
</Body>
</div>
<div
className="d-flex flex-row flex-nowrap"
>
<Trigger
aria-label="Toggle feedback"
className="btn-icon btn-icon-primary btn-icon-md align-items-center"
>
<Icon
alt="Toggle feedback"
/>
</Trigger>
<IconButton
alt="Delete answer"
iconAs="Icon"
onClick={[Function]}
variant="primary"
/>
</div>
</Advanced>
`;
exports[`AnswerOption render snapshot: renders correct option with numeric input problem 1`] = `
<Advanced
className="answer-option d-flex flex-row justify-content-between flex-nowrap pb-2 pt-2"
onToggle={[Function]}
open={false}
>
<div
className="mr-1 d-flex"
>
<Checker
answer={
{
"correct": true,
"id": "A",
"selectedFeedback": "some feedback",
"title": "Answer 1",
}
}
disabled={true}
hasSingleAnswer={false}
setAnswer={[Function]}
/>
</div>
<div
className="ml-1 flex-grow-1"
>
<Form.Control
as="textarea"
autoResize={true}
className="answer-option-textarea text-gray-500 small"
onChange={[Function]}
placeholder="Enter an answer"
rows={1}
value="Answer 1"
/>
<Body>
<injectIntl(ShimmedIntlComponent)
answer={
{
"correct": true,
"id": "A",
"selectedFeedback": "some feedback",
"title": "Answer 1",
}
}
intl={
{
"formatMessage": [Function],
}
}
problemType="numericalresponse"
setSelectedFeedback={[Function]}
setUnselectedFeedback={[Function]}
/>
</Body>
</div>
<div
className="d-flex flex-row flex-nowrap"
>
<Trigger
aria-label="Toggle feedback"
className="btn-icon btn-icon-primary btn-icon-md align-items-center"
>
<Icon
alt="Toggle feedback"
/>
</Trigger>
<IconButton
alt="Delete answer"
iconAs="Icon"
onClick={[Function]}
variant="primary"
/>
</div>
</Advanced>
`;
exports[`AnswerOption render snapshot: renders correct option with numeric input problem and answer range 1`] = `
<Advanced
className="answer-option d-flex flex-row justify-content-between flex-nowrap pb-2 pt-2"
onToggle={[Function]}
open={false}
>
<div
className="mr-1 d-flex"
>
<Checker
answer={
{
"correct": true,
"id": "A",
"isAnswerRange": true,
"selectedFeedback": "selected feedback",
"title": "Answer 1",
"unselectedFeedback": "unselected feedback",
}
}
disabled={true}
hasSingleAnswer={false}
setAnswer={[Function]}
/>
</div>
<div
className="ml-1 flex-grow-1"
>
<div>
<Form.Control
as="textarea"
autoResize={true}
className="answer-option-textarea text-gray-500 small"
onChange={[Function]}
placeholder="Enter an answer range"
rows={1}
value="Answer 1"
/>
<div
className="pgn__form-switch-helper-text"
>
<FormattedMessage
defaultMessage="Enter min and max values separated by a comma. Use a bracket to include the number next to it in the range, or a parenthesis to exclude the number. For example, to identify the correct answers as 5, 6, or 7, but not 8, specify [5,8)."
description="Helper text describing usage of answer ranges"
id="authoring.answerwidget.answer.answerRangeHelperText"
/>
</div>
</div>
<Body>
<injectIntl(ShimmedIntlComponent)
answer={
{
"correct": true,
"id": "A",
"isAnswerRange": true,
"selectedFeedback": "selected feedback",
"title": "Answer 1",
"unselectedFeedback": "unselected feedback",
}
}
intl={
{
"formatMessage": [Function],
}
}
problemType="numericalresponse"
setSelectedFeedback={[Function]}
setUnselectedFeedback={[Function]}
/>
</Body>
</div>
<div
className="d-flex flex-row flex-nowrap"
>
<Trigger
aria-label="Toggle feedback"
className="btn-icon btn-icon-primary btn-icon-md align-items-center"
>
<Icon
alt="Toggle feedback"
/>
</Trigger>
<IconButton
alt="Delete answer"
iconAs="Icon"
onClick={[Function]}
variant="primary"
/>
</div>
</Advanced>
`;
exports[`AnswerOption render snapshot: renders correct option with selected unselected feedback 1`] = `
<Advanced
className="answer-option d-flex flex-row justify-content-between flex-nowrap pb-2 pt-2"
onToggle={[Function]}
open={false}
>
<div
className="mr-1 d-flex"
>
<Checker
answer={
{
"correct": true,
"id": "A",
"selectedFeedback": "selected feedback",
"title": "Answer 1",
"unselectedFeedback": "unselected feedback",
}
}
disabled={false}
hasSingleAnswer={false}
setAnswer={[Function]}
/>
</div>
<div
className="ml-1 flex-grow-1"
>
<ExpandableTextArea
error={false}
errorMessage={null}
id="answer-A"
placeholder="Enter an answer"
setContent={[Function]}
value="Answer 1"
/>
<Body>
<injectIntl(ShimmedIntlComponent)
answer={
{
"correct": true,
"id": "A",
"selectedFeedback": "selected feedback",
"title": "Answer 1",
"unselectedFeedback": "unselected feedback",
}
}
intl={
{
"formatMessage": [Function],
}
}
problemType="choiceresponse"
setSelectedFeedback={[Function]}
setUnselectedFeedback={[Function]}
/>
</Body>
</div>
<div
className="d-flex flex-row flex-nowrap"
>
<Trigger
aria-label="Toggle feedback"
className="btn-icon btn-icon-primary btn-icon-md align-items-center"
>
<Icon
alt="Toggle feedback"
/>
</Trigger>
<IconButton
alt="Delete answer"
iconAs="Icon"
onClick={[Function]}
variant="primary"
/>
</div>
</Advanced>
`;

View File

@@ -0,0 +1,238 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AnswersContainer render snapshot: numeric problems: answer range/answer select button: Range disables the additon of more adds 1`] = `
<div
className="answers-container border border-light-700 rounded py-4 pl-4 pr-3"
>
<mockAnswerOption
answer={
{
"correct": true,
"id": "A",
"isAnswerRange": true,
"selectedFeedback": "selected feedback",
"title": "Answer 1",
"unselectedFeedback": "unselected feedback",
}
}
hasSingleAnswer={false}
key="A"
/>
<Dropdown>
<Dropdown.Toggle
className="pl-0"
id="Add-Answer-Or-Answer-Range"
variant="tertiary"
>
<Icon />
<FormattedMessage
defaultMessage="Add answer"
description="Button text to add answer"
id="authoring.answerwidget.answer.addAnswerButton"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
className="AddAnswerRange disabled"
key="add-answer"
onClick={[MockFunction]}
>
<FormattedMessage
defaultMessage="Add answer"
description="Button text to add answer"
id="authoring.answerwidget.answer.addAnswerButton"
/>
</Dropdown.Item>
<Dropdown.Item
className="AddAnswerRange disabled"
key="add-answer-range"
onClick={[MockFunction]}
>
<FormattedMessage
defaultMessage="Add answer range"
description="Button text to add a range of answers"
id="authoring.answerwidget.answer.addAnswerRangeButton"
/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
`;
exports[`AnswersContainer render snapshot: numeric problems: answer range/answer select button: empty 1`] = `
<div
className="answers-container border border-light-700 rounded py-4 pl-4 pr-3"
>
<Dropdown>
<Dropdown.Toggle
className="pl-0"
id="Add-Answer-Or-Answer-Range"
variant="tertiary"
>
<Icon />
<FormattedMessage
defaultMessage="Add answer"
description="Button text to add answer"
id="authoring.answerwidget.answer.addAnswerButton"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
className="AddAnswerRange "
key="add-answer"
onClick={[MockFunction]}
>
<FormattedMessage
defaultMessage="Add answer"
description="Button text to add answer"
id="authoring.answerwidget.answer.addAnswerButton"
/>
</Dropdown.Item>
<Dropdown.Item
className="AddAnswerRange "
key="add-answer-range"
onClick={[MockFunction]}
>
<FormattedMessage
defaultMessage="Add answer range"
description="Button text to add a range of answers"
id="authoring.answerwidget.answer.addAnswerRangeButton"
/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
`;
exports[`AnswersContainer render snapshot: numeric problems: answer range/answer select button: multiple answers disables range. 1`] = `
<div
className="answers-container border border-light-700 rounded py-4 pl-4 pr-3"
>
<mockAnswerOption
answer={
{
"correct": true,
"id": "A",
"isAnswerRange": false,
"selectedFeedback": "selected feedback",
"title": "Answer 1",
"unselectedFeedback": "unselected feedback",
}
}
hasSingleAnswer={false}
key="A"
/>
<mockAnswerOption
answer={
{
"correct": true,
"id": "B",
"isAnswerRange": false,
"selectedFeedback": "selected feedback",
"title": "Answer 1",
"unselectedFeedback": "unselected feedback",
}
}
hasSingleAnswer={false}
key="B"
/>
<Dropdown>
<Dropdown.Toggle
className="pl-0"
id="Add-Answer-Or-Answer-Range"
variant="tertiary"
>
<Icon />
<FormattedMessage
defaultMessage="Add answer"
description="Button text to add answer"
id="authoring.answerwidget.answer.addAnswerButton"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
className="AddAnswerRange "
key="add-answer"
onClick={[MockFunction]}
>
<FormattedMessage
defaultMessage="Add answer"
description="Button text to add answer"
id="authoring.answerwidget.answer.addAnswerButton"
/>
</Dropdown.Item>
<Dropdown.Item
className="AddAnswerRange disabled"
key="add-answer-range"
onClick={[MockFunction]}
>
<FormattedMessage
defaultMessage="Add answer range"
description="Button text to add a range of answers"
id="authoring.answerwidget.answer.addAnswerRangeButton"
/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
`;
exports[`AnswersContainer render snapshot: renders correct default 1`] = `
<div
className="answers-container border border-light-700 rounded py-4 pl-4 pr-3"
>
<Button
className={null}
onClick={[MockFunction]}
text={null}
variant="add"
>
<FormattedMessage
defaultMessage="Add answer"
description="Button text to add answer"
id="authoring.answerwidget.answer.addAnswerButton"
/>
</Button>
</div>
`;
exports[`AnswersContainer render snapshot: renders correctly with answers 1`] = `
<div
className="answers-container border border-light-700 rounded py-4 pl-4 pr-3"
>
<mockAnswerOption
answer={
{
"correct": true,
"id": "a",
"title": "sOMetITlE",
}
}
hasSingleAnswer={false}
key="a"
/>
<mockAnswerOption
answer={
{
"correct": true,
"id": "b",
"title": "sOMetITlE",
}
}
hasSingleAnswer={false}
key="b"
/>
<Button
className={null}
onClick={[MockFunction]}
text={null}
variant="add"
>
<FormattedMessage
defaultMessage="Add answer"
description="Button text to add answer"
id="authoring.answerwidget.answer.addAnswerButton"
/>
</Button>
</div>
`;

View File

@@ -0,0 +1,55 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Checker component with disabled 1`] = `
<Fragment>
<Radio
checked={true}
className="pt-2.5"
disabled={true}
isValid={true}
onChange={[Function]}
value="A"
/>
<Form.Label
className="pt-2"
>
A
</Form.Label>
</Fragment>
`;
exports[`Checker component with multiple answers 1`] = `
<Fragment>
<Form.Checkbox
checked={true}
className="pt-2.5"
disabled={false}
isValid={true}
onChange={[Function]}
value="A"
/>
<Form.Label
className="pt-2"
>
A
</Form.Label>
</Fragment>
`;
exports[`Checker component with single answer 1`] = `
<Fragment>
<Radio
checked={true}
className="pt-2.5"
disabled={false}
isValid={true}
onChange={[Function]}
value="A"
/>
<Form.Label
className="pt-2"
>
A
</Form.Label>
</Fragment>
`;

View File

@@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Form } from '@openedx/paragon';
const Checker = ({
hasSingleAnswer,
answer,
setAnswer,
disabled,
}) => {
let CheckerType = Form.Checkbox;
if (hasSingleAnswer) {
CheckerType = Form.Radio;
}
return (
<>
<CheckerType
className="pt-2.5"
value={answer.id}
onChange={(e) => setAnswer({ correct: e.target.checked })}
checked={answer.correct}
isValid={answer.correct}
disabled={disabled}
/>
<Form.Label
className="pt-2"
>
{answer.id}
</Form.Label>
</>
);
};
Checker.defaultProps = {
disabled: false,
};
Checker.propTypes = {
hasSingleAnswer: PropTypes.bool.isRequired,
answer: PropTypes.shape({
correct: PropTypes.bool,
id: PropTypes.number,
}).isRequired,
setAnswer: PropTypes.func.isRequired,
disabled: PropTypes.bool,
};
export default Checker;

View File

@@ -0,0 +1,26 @@
import { shallow } from '@edx/react-unit-test-utils';
import Checker from '.';
const props = {
hasSingleAnswer: true,
answer: {
id: 'A',
title: 'Answer 1',
correct: true,
selectedFeedback: 'some feedback',
},
setAnswer: jest.fn(),
};
describe('Checker component', () => {
test('with single answer', () => {
expect(shallow(<Checker {...props} />).snapshot).toMatchSnapshot();
});
test('with multiple answers', () => {
expect(shallow(<Checker {...props} hasSingleAnswer={false} />).snapshot).toMatchSnapshot();
});
test('with disabled', () => {
expect(shallow(<Checker {...props} disabled />).snapshot).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,67 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { answerOptionProps } from '../../../../../../../data/services/cms/types';
import FeedbackControl from './FeedbackControl';
import messages from './messages';
import { ProblemTypeKeys } from '../../../../../../../data/constants/problem';
export const FeedbackBox = ({
answer,
problemType,
setSelectedFeedback,
setUnselectedFeedback,
// injected
intl,
}) => {
const props = {
answer,
intl,
};
return ((problemType === ProblemTypeKeys.MULTISELECT) ? (
<div className="bg-light-300 p-4 mt-3 rounded text-primary-500">
<FeedbackControl
key={`selectedfeedback-${answer.id}`}
feedback={answer.selectedFeedback}
labelMessage={messages.selectedFeedbackLabel}
labelMessageBoldUnderline={messages.selectedFeedbackLabelBoldUnderlineText}
onChange={setSelectedFeedback}
type="selected"
{...props}
/>
<FeedbackControl
key={`unselectedfeedback-${answer.id}`}
feedback={answer.unselectedFeedback}
labelMessage={messages.unSelectedFeedbackLabel}
labelMessageBoldUnderline={messages.unSelectedFeedbackLabelBoldUnderlineText}
onChange={setUnselectedFeedback}
type="unselected"
{...props}
/>
</div>
) : (
<div className="bg-light-300 p-4 mt-3 rounded text-primary-500">
<FeedbackControl
key={`selectedfeedback-${answer.id}`}
feedback={answer.selectedFeedback}
labelMessage={messages.selectedFeedbackLabel}
labelMessageBoldUnderline={messages.selectedFeedbackLabelBoldUnderlineText}
onChange={setSelectedFeedback}
type="selected"
{...props}
/>
</div>
));
};
FeedbackBox.propTypes = {
answer: answerOptionProps.isRequired,
problemType: PropTypes.string.isRequired,
setAnswer: PropTypes.func.isRequired,
setSelectedFeedback: PropTypes.func.isRequired,
setUnselectedFeedback: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(FeedbackBox);

View File

@@ -0,0 +1,28 @@
import { shallow } from '@edx/react-unit-test-utils';
import { FeedbackBox } from './FeedbackBox';
const answerWithFeedback = {
id: 'A',
title: 'Answer 1',
correct: true,
selectedFeedback: 'some feedback',
unselectedFeedback: 'unselectedFeedback',
problemType: 'sOMepRObleM',
};
const props = {
answer: answerWithFeedback,
intl: {},
};
describe('FeedbackBox component', () => {
test('renders as expected with default props', () => {
expect(shallow(<FeedbackBox {...props} />).snapshot).toMatchSnapshot();
});
test('renders as expected with a numeric input problem', () => {
expect(shallow(<FeedbackBox {...props} problemType="numericalresponse" />).snapshot).toMatchSnapshot();
});
test('renders as expected with a multi select problem', () => {
expect(shallow(<FeedbackBox {...props} problemType="choiceresponse" />).snapshot).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,47 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
import { Form } from '@openedx/paragon';
import { answerOptionProps } from '../../../../../../../data/services/cms/types';
import ExpandableTextArea from '../../../../../../../sharedComponents/ExpandableTextArea';
import messages from './messages';
const FeedbackControl = ({
feedback,
onChange,
labelMessage,
labelMessageBoldUnderline,
answer,
intl,
type,
}) => (
<Form.Group>
<Form.Label className="mb-3">
<FormattedMessage
{...labelMessage}
values={{
answerId: answer.id,
boldunderline: <b><u><FormattedMessage {...labelMessageBoldUnderline} /></u></b>,
}}
/>
</Form.Label>
<ExpandableTextArea
id={`${type}Feedback-${answer.id}`}
value={feedback}
setContent={onChange}
placeholder={intl.formatMessage(messages.feedbackPlaceholder)}
/>
</Form.Group>
);
FeedbackControl.propTypes = {
feedback: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
labelMessage: PropTypes.string.isRequired,
labelMessageBoldUnderline: PropTypes.string.isRequired,
answer: answerOptionProps.isRequired,
type: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default FeedbackControl;

View File

@@ -0,0 +1,26 @@
import { shallow } from '@edx/react-unit-test-utils';
import FeedbackControl from './FeedbackControl';
const answerWithFeedback = {
id: 'A',
title: 'Answer 1',
correct: true,
selectedFeedback: 'some feedback',
unselectedFeedback: 'unselectedFeedback',
};
const props = {
answer: answerWithFeedback,
intl: { formatMessage: jest.fn() },
setAnswer: jest.fn(),
feedback: 'feedback',
onChange: jest.fn(),
labelMessage: 'msg',
labelMessageBoldUnderline: 'msg',
};
describe('FeedbackControl component', () => {
test('renders', () => {
expect(shallow(<FeedbackControl {...props} />).snapshot).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,142 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FeedbackBox component renders as expected with a multi select problem 1`] = `
<div
className="bg-light-300 p-4 mt-3 rounded text-primary-500"
>
<FeedbackControl
answer={
{
"correct": true,
"id": "A",
"problemType": "sOMepRObleM",
"selectedFeedback": "some feedback",
"title": "Answer 1",
"unselectedFeedback": "unselectedFeedback",
}
}
feedback="some feedback"
intl={{}}
key="selectedfeedback-A"
labelMessage={
{
"defaultMessage": "Show following feedback when {answerId} {boldunderline}:",
"description": "Label text for feedback if option is selected",
"id": "authoring.answerwidget.feedback.selected.label",
}
}
labelMessageBoldUnderline={
{
"defaultMessage": "is selected",
"description": "Bold & underlined text for feedback if option is selected",
"id": "authoring.answerwidget.feedback.selected.label.boldunderline",
}
}
type="selected"
/>
<FeedbackControl
answer={
{
"correct": true,
"id": "A",
"problemType": "sOMepRObleM",
"selectedFeedback": "some feedback",
"title": "Answer 1",
"unselectedFeedback": "unselectedFeedback",
}
}
feedback="unselectedFeedback"
intl={{}}
key="unselectedfeedback-A"
labelMessage={
{
"defaultMessage": "Show following feedback when {answerId} {boldunderline}:",
"description": "Label text for feedback if option is not selected",
"id": "authoring.answerwidget.feedback.unselected.label",
}
}
labelMessageBoldUnderline={
{
"defaultMessage": "is not selected",
"description": "Bold & underlined text for feedback if option is not selected",
"id": "authoring.answerwidget.feedback.unselected.label.boldunderline",
}
}
type="unselected"
/>
</div>
`;
exports[`FeedbackBox component renders as expected with a numeric input problem 1`] = `
<div
className="bg-light-300 p-4 mt-3 rounded text-primary-500"
>
<FeedbackControl
answer={
{
"correct": true,
"id": "A",
"problemType": "sOMepRObleM",
"selectedFeedback": "some feedback",
"title": "Answer 1",
"unselectedFeedback": "unselectedFeedback",
}
}
feedback="some feedback"
intl={{}}
key="selectedfeedback-A"
labelMessage={
{
"defaultMessage": "Show following feedback when {answerId} {boldunderline}:",
"description": "Label text for feedback if option is selected",
"id": "authoring.answerwidget.feedback.selected.label",
}
}
labelMessageBoldUnderline={
{
"defaultMessage": "is selected",
"description": "Bold & underlined text for feedback if option is selected",
"id": "authoring.answerwidget.feedback.selected.label.boldunderline",
}
}
type="selected"
/>
</div>
`;
exports[`FeedbackBox component renders as expected with default props 1`] = `
<div
className="bg-light-300 p-4 mt-3 rounded text-primary-500"
>
<FeedbackControl
answer={
{
"correct": true,
"id": "A",
"problemType": "sOMepRObleM",
"selectedFeedback": "some feedback",
"title": "Answer 1",
"unselectedFeedback": "unselectedFeedback",
}
}
feedback="some feedback"
intl={{}}
key="selectedfeedback-A"
labelMessage={
{
"defaultMessage": "Show following feedback when {answerId} {boldunderline}:",
"description": "Label text for feedback if option is selected",
"id": "authoring.answerwidget.feedback.selected.label",
}
}
labelMessageBoldUnderline={
{
"defaultMessage": "is selected",
"description": "Bold & underlined text for feedback if option is selected",
"id": "authoring.answerwidget.feedback.selected.label.boldunderline",
}
}
type="selected"
/>
</div>
`;

View File

@@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FeedbackControl component renders 1`] = `
<Form.Group>
<Form.Label
className="mb-3"
>
<FormattedMessage
0="m"
1="s"
2="g"
values={
{
"answerId": "A",
"boldunderline": <b>
<u>
<FormattedMessage
0="m"
1="s"
2="g"
/>
</u>
</b>,
}
}
/>
</Form.Label>
<ExpandableTextArea
error={false}
errorMessage={null}
id="undefinedFeedback-A"
placeholder={null}
setContent={[MockFunction]}
value="feedback"
/>
</Form.Group>
`;

View File

@@ -0,0 +1,2 @@
export { default as FeedbackBox } from './FeedbackBox';
export { default as FeedbackControl } from './FeedbackControl';

View File

@@ -0,0 +1,37 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
feedbackPlaceholder: {
id: 'authoring.answerwidget.feedback.placeholder',
defaultMessage: 'Feedback message',
description: 'Placeholder text for feedback text',
},
feedbackToggleIconAltText: {
id: 'authoring.answerwidget.feedback.icon.alt',
defaultMessage: 'Toggle feedback',
description: 'Alt text for feedback toggle icon',
},
selectedFeedbackLabel: {
id: 'authoring.answerwidget.feedback.selected.label',
defaultMessage: 'Show following feedback when {answerId} {boldunderline}:',
description: 'Label text for feedback if option is selected',
},
selectedFeedbackLabelBoldUnderlineText: {
id: 'authoring.answerwidget.feedback.selected.label.boldunderline',
defaultMessage: 'is selected',
description: 'Bold & underlined text for feedback if option is selected',
},
unSelectedFeedbackLabel: {
id: 'authoring.answerwidget.feedback.unselected.label',
defaultMessage: 'Show following feedback when {answerId} {boldunderline}:',
description: 'Label text for feedback if option is not selected',
},
unSelectedFeedbackLabelBoldUnderlineText: {
id: 'authoring.answerwidget.feedback.unselected.label.boldunderline',
defaultMessage: 'is not selected',
description: 'Bold & underlined text for feedback if option is not selected',
},
});
export default messages;

View File

@@ -0,0 +1,103 @@
import { useState, useEffect } from 'react';
import { StrictDict } from '../../../../../utils';
import * as module from './hooks';
import { actions } from '../../../../../data/redux';
import { ProblemTypeKeys } from '../../../../../data/constants/problem';
import { fetchEditorContent } from '../hooks';
export const state = StrictDict({
// eslint-disable-next-line react-hooks/rules-of-hooks
isFeedbackVisible: (val) => useState(val),
});
export const removeAnswer = ({
answer,
dispatch,
}) => () => {
dispatch(actions.problem.deleteAnswer({
id: answer.id,
correct: answer.correct,
editorState: fetchEditorContent({ format: '' }),
}));
};
export const setAnswer = ({ answer, hasSingleAnswer, dispatch }) => (payload) => {
dispatch(actions.problem.updateAnswer({ id: answer.id, hasSingleAnswer, ...payload }));
};
export const setAnswerTitle = ({
answer,
hasSingleAnswer,
dispatch,
problemType,
}) => (updatedTitle) => {
let title = updatedTitle;
if ([ProblemTypeKeys.TEXTINPUT, ProblemTypeKeys.NUMERIC, ProblemTypeKeys.DROPDOWN].includes(problemType)) {
title = updatedTitle.target.value;
}
dispatch(actions.problem.updateAnswer({ id: answer.id, hasSingleAnswer, title }));
};
export const setSelectedFeedback = ({ answer, hasSingleAnswer, dispatch }) => (e) => {
if (e.target) {
dispatch(actions.problem.updateAnswer({
id: answer.id,
hasSingleAnswer,
selectedFeedback: e.target.value,
}));
}
};
export const setUnselectedFeedback = ({ answer, hasSingleAnswer, dispatch }) => (e) => {
if (e.target) {
dispatch(actions.problem.updateAnswer({
id: answer.id,
hasSingleAnswer,
unselectedFeedback: e.target.value,
}));
}
};
export const useFeedback = (answer) => {
const [isFeedbackVisible, setIsFeedbackVisible] = module.state.isFeedbackVisible(false);
useEffect(() => {
// Show feedback fields if feedback is present
const isVisible = !!answer.selectedFeedback || !!answer.unselectedFeedback;
setIsFeedbackVisible(isVisible);
}, [answer]);
const toggleFeedback = (open) => {
// Do not allow to hide if feedback is added
const { selectedFeedback, unselectedFeedback } = fetchEditorContent({ format: '' });
if (!!selectedFeedback?.[answer.id] || !!unselectedFeedback?.[answer.id]) {
setIsFeedbackVisible(true);
return;
}
setIsFeedbackVisible(open);
};
return {
isFeedbackVisible,
toggleFeedback,
};
};
export const isSingleAnswerProblem = (problemType) => (
problemType === ProblemTypeKeys.DROPDOWN
);
export const useAnswerContainer = ({ answers, updateField }) => {
useEffect(() => {
let answerCount = 0;
answers.forEach(answer => {
if (answer.correct) {
answerCount += 1;
}
});
updateField({ correctAnswerCount: answerCount });
}, []);
};
export default {
state, removeAnswer, setAnswer, setAnswerTitle, useFeedback, isSingleAnswerProblem, useAnswerContainer,
};

View File

@@ -0,0 +1,205 @@
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { actions } from '../../../../../data/redux';
import { MockUseState } from '../../../../../../testUtils';
import { ProblemTypeKeys } from '../../../../../data/constants/problem';
import * as module from './hooks';
jest.mock('react', () => {
const updateState = jest.fn();
return {
useEffect: jest.fn(),
useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])),
};
});
jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m,
}));
jest.mock('../../../../../data/redux', () => ({
actions: {
problem: {
deleteAnswer: (args) => ({ deleteAnswer: args }),
updateAnswer: (args) => ({ updateAnswer: args }),
},
},
}));
const state = new MockUseState(module);
let output;
const answerWithOnlyFeedback = {
id: 'A',
title: 'Answer 1',
correct: true,
selectedFeedback: 'some feedback',
};
let windowSpy;
describe('Answer Options Hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('state hooks', () => {
state.testGetter(state.keys.isFeedbackVisible);
});
describe('removeAnswer', () => {
beforeEach(() => {
windowSpy = jest.spyOn(window, 'window', 'get');
});
afterEach(() => {
windowSpy.mockRestore();
});
const answer = { id: 'A', correct: false };
const dispatch = useDispatch();
it('dispatches actions.problem.deleteAnswer', () => {
windowSpy.mockImplementation(() => ({ tinymce: { editors: { 'answer-A': { getContent: () => 'string' } } } }));
module.removeAnswer({
answer,
dispatch,
})();
expect(dispatch).toHaveBeenCalledWith(actions.problem.deleteAnswer({
id: answer.id,
correct: answer.correct,
editorState: {
answers: { A: 'string' },
hints: [],
},
}));
});
});
describe('setAnswer', () => {
test('it dispatches actions.problem.updateAnswer', () => {
const answer = { id: 'A' };
const hasSingleAnswer = false;
const dispatch = useDispatch();
const payload = { random: 'string' };
module.setAnswer({ answer, hasSingleAnswer, dispatch })(payload);
expect(dispatch).toHaveBeenCalledWith(actions.problem.updateAnswer({
id: answer.id,
hasSingleAnswer,
...payload,
}));
});
});
describe('setAnswerTitle', () => {
test('it dispatches actions.problem.updateAnswer for numeric problem', () => {
const answer = { id: 'A' };
const hasSingleAnswer = false;
const dispatch = useDispatch();
const updatedTitle = { target: { value: 'string' } };
const problemType = 'numericalresponse';
module.setAnswerTitle({
answer,
hasSingleAnswer,
dispatch,
problemType,
})(updatedTitle);
expect(dispatch).toHaveBeenCalledWith(actions.problem.updateAnswer({
id: answer.id,
hasSingleAnswer,
title: updatedTitle.target.value,
}));
});
test('it dispatches actions.problem.updateAnswer for single select problem', () => {
const answer = { id: 'A' };
const hasSingleAnswer = false;
const dispatch = useDispatch();
const updatedTitle = 'string';
const problemType = 'multiplechoiceresponse';
module.setAnswerTitle({
answer,
hasSingleAnswer,
dispatch,
problemType,
})(updatedTitle);
expect(dispatch).toHaveBeenCalledWith(actions.problem.updateAnswer({
id: answer.id,
hasSingleAnswer,
title: updatedTitle,
}));
});
});
describe('setSelectedFeedback', () => {
test('it dispatches actions.problem.updateAnswer', () => {
const answer = { id: 'A' };
const hasSingleAnswer = false;
const dispatch = useDispatch();
const e = { target: { value: 'string' } };
module.setSelectedFeedback({ answer, hasSingleAnswer, dispatch })(e);
expect(dispatch).toHaveBeenCalledWith(actions.problem.updateAnswer({
id: answer.id,
hasSingleAnswer,
selectedFeedback: e.target.value,
}));
});
});
describe('setUnselectedFeedback', () => {
test('it dispatches actions.problem.updateAnswer', () => {
const answer = { id: 'A' };
const hasSingleAnswer = false;
const dispatch = useDispatch();
const e = { target: { value: 'string' } };
module.setUnselectedFeedback({ answer, hasSingleAnswer, dispatch })(e);
expect(dispatch).toHaveBeenCalledWith(actions.problem.updateAnswer({
id: answer.id,
hasSingleAnswer,
unselectedFeedback: e.target.value,
}));
});
});
describe('useFeedback hook', () => {
beforeEach(() => {
state.mock();
windowSpy = jest.spyOn(window, 'window', 'get');
});
afterEach(() => {
state.restore();
windowSpy.mockRestore();
});
test('default state is false', () => {
output = module.useFeedback(answerWithOnlyFeedback);
expect(output.isFeedbackVisible).toBeFalsy();
});
test('when useEffect triggers, isFeedbackVisible is set to true', () => {
const key = state.keys.isFeedbackVisible;
output = module.useFeedback(answerWithOnlyFeedback);
expect(state.setState[key]).not.toHaveBeenCalled();
const [cb] = useEffect.mock.calls[0];
cb();
expect(state.setState[key]).toHaveBeenCalledWith(true);
});
test('toggleFeedback with selected feedback', () => {
const key = state.keys.isFeedbackVisible;
output = module.useFeedback(answerWithOnlyFeedback);
windowSpy.mockImplementation(() => ({ tinymce: { editors: { 'selectedFeedback-A': { getContent: () => 'string' } } } }));
output.toggleFeedback(false);
expect(state.setState[key]).toHaveBeenCalledWith(true);
});
test('toggleFeedback with unselected feedback', () => {
const key = state.keys.isFeedbackVisible;
output = module.useFeedback(answerWithOnlyFeedback);
windowSpy.mockImplementation(() => ({ tinymce: { editors: { 'unselectedFeedback-A': { getContent: () => 'string' } } } }));
output.toggleFeedback(false);
expect(state.setState[key]).toHaveBeenCalledWith(true);
});
test('toggleFeedback with unselected feedback', () => {
const key = state.keys.isFeedbackVisible;
output = module.useFeedback(answerWithOnlyFeedback);
windowSpy.mockImplementation(() => ({ tinymce: { editors: { 'answer-A': { getContent: () => 'string' } } } }));
output.toggleFeedback(false);
expect(state.setState[key]).toHaveBeenCalledWith(false);
});
});
describe('isSingleAnswerProblem()', () => {
test('singleSelect', () => {
expect(module.isSingleAnswerProblem(ProblemTypeKeys.SINGLESELECT)).toBe(false);
});
test('multiSelect', () => {
expect(module.isSingleAnswerProblem(ProblemTypeKeys.MULTISELECT)).toBe(false);
});
test('dropdown', () => {
expect(module.isSingleAnswerProblem(ProblemTypeKeys.DROPDOWN)).toBe(true);
});
});
});

View File

@@ -0,0 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import { ProblemTypes } from '../../../../../data/constants/problem';
import AnswersContainer from './AnswersContainer';
// This widget should be connected, grab all answers from store, update them as needed.
const AnswerWidget = ({
// Redux
problemType,
// injected
intl,
}) => {
const problemStaticData = ProblemTypes[problemType];
return (
<div>
<div className="mt-4 text-primary-500">
<div className="h4">
<FormattedMessage {...messages.answerWidgetTitle} />
</div>
<div className="small">
{intl.formatMessage(messages.answerHelperText, { helperText: problemStaticData.description })}
</div>
</div>
<AnswersContainer problemType={problemType} />
</div>
);
};
AnswerWidget.propTypes = {
problemType: PropTypes.string.isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(AnswerWidget);

View File

@@ -0,0 +1,77 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
answerWidgetTitle: {
id: 'authoring.answerwidget.answer.answerWidgetTitle',
defaultMessage: 'Answers',
description: 'Main title for Answers widget',
},
answerHelperText: {
id: 'authoring.problemEditor.answerWidget.answer.answerHelperText',
defaultMessage: '{helperText}',
description: 'Helper text describing how the user should input answers',
},
addAnswerButtonText: {
id: 'authoring.answerwidget.answer.addAnswerButton',
defaultMessage: 'Add answer',
description: 'Button text to add answer',
},
answerTextboxPlaceholder: {
id: 'authoring.answerwidget.answer.placeholder',
defaultMessage: 'Enter an answer',
description: 'Placeholder text for answer option text',
},
feedbackPlaceholder: {
id: 'authoring.answerwidget.feedback.placeholder',
defaultMessage: 'Feedback message',
description: 'Placeholder text for feedback text',
},
feedbackToggleIconAltText: {
id: 'authoring.answerwidget.feedback.icon.alt',
defaultMessage: 'Toggle feedback',
description: 'Alt text for feedback toggle icon',
},
answerDeleteIconAltText: {
id: 'authoring.answerwidget.answer.delete.icon.alt',
defaultMessage: 'Delete answer',
description: 'Alt text for delete icon',
},
selectedFeedbackLabel: {
id: 'authoring.answerwidget.feedback.selected.label',
defaultMessage: 'Show following feedback when {answerId} {boldunderline}:',
description: 'Label text for feedback if option is selected',
},
selectedFeedbackLabelBoldUnderlineText: {
id: 'authoring.answerwidget.feedback.selected.label.boldunderline',
defaultMessage: 'is selected',
description: 'Bold & underlined text for feedback if option is selected',
},
unSelectedFeedbackLabel: {
id: 'authoring.answerwidget.feedback.unselected.label',
defaultMessage: 'Show following feedback when {answerId} {boldunderline}:',
description: 'Label text for feedback if option is not selected',
},
unSelectedFeedbackLabelBoldUnderlineText: {
id: 'authoring.answerwidget.feedback.unselected.label.boldunderline',
defaultMessage: 'is not selected',
description: 'Bold & underlined text for feedback if option is not selected',
},
addAnswerRangeButtonText: {
id: 'authoring.answerwidget.answer.addAnswerRangeButton',
defaultMessage: 'Add answer range',
description: 'Button text to add a range of answers',
},
answerRangeTextboxPlaceholder: {
id: 'authoring.answerwidget.answer.answerRangeTextboxPlaceholder',
defaultMessage: 'Enter an answer range',
description: 'Text to prompt the user to add an answer range to the textbox.',
},
answerRangeHelperText: {
id: 'authoring.answerwidget.answer.answerRangeHelperText',
defaultMessage: 'Enter min and max values separated by a comma. Use a bracket to include the number next to it in the range, or a parenthesis to exclude the number. For example, to identify the correct answers as 5, 6, or 7, but not 8, specify [5,8).',
description: 'Helper text describing usage of answer ranges',
},
});
export default messages;

View File

@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SolutionWidget render snapshot: renders correct default 1`] = `
<div
className="tinyMceWidget mt-4 text-primary-500"
>
<div
className="h4 mb-3"
>
<FormattedMessage
defaultMessage="Explanation"
description="Explanation Title"
id="authoring.problemEditor.explanationwidget.explanationWidgetTitle"
/>
</div>
<div
className="small mb-3"
>
<FormattedMessage
defaultMessage="Provide an explanation for the correct answer"
description="Description of the solution widget"
id="authoring.problemEditor.solutionwidget.solutionDescriptionText"
/>
</div>
<[object Object]
editorContentHtml="This is my solution"
editorType="solution"
id="solution"
minHeight={150}
placeholder="Enter your explanation"
setEditorRef={[MockFunction prepareEditorRef.setEditorRef]}
/>
</div>
`;

View File

@@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
import { selectors } from '../../../../../data/redux';
import messages from './messages';
import TinyMceWidget from '../../../../../sharedComponents/TinyMceWidget';
import { prepareEditorRef, replaceStaticWithAsset } from '../../../../../sharedComponents/TinyMceWidget/hooks';
export const ExplanationWidget = ({
// redux
settings,
learningContextId,
// injected
intl,
}) => {
const { editorRef, refReady, setEditorRef } = prepareEditorRef();
const initialContent = settings?.solutionExplanation || '';
const newContent = replaceStaticWithAsset({
initialContent,
learningContextId,
});
const solutionContent = newContent || initialContent;
if (!refReady) { return null; }
return (
<div className="tinyMceWidget mt-4 text-primary-500">
<div className="h4 mb-3">
<FormattedMessage {...messages.solutionWidgetTitle} />
</div>
<div className="small mb-3">
<FormattedMessage {...messages.solutionDescriptionText} />
</div>
<TinyMceWidget
id="solution"
editorType="solution"
editorRef={editorRef}
editorContentHtml={solutionContent}
setEditorRef={setEditorRef}
minHeight={150}
placeholder={intl.formatMessage(messages.placeholder)}
/>
</div>
);
};
ExplanationWidget.propTypes = {
// redux
// eslint-disable-next-line
settings: PropTypes.any.isRequired,
learningContextId: PropTypes.string.isRequired,
// injected
intl: intlShape.isRequired,
};
export const mapStateToProps = (state) => ({
settings: selectors.problem.settings(state),
learningContextId: selectors.app.learningContextId(state),
});
export default injectIntl(connect(mapStateToProps)(ExplanationWidget));

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { formatMessage } from '../../../../../../testUtils';
import { selectors } from '../../../../../data/redux';
import { ExplanationWidget, mapStateToProps } from '.';
jest.mock('../../../../../data/redux', () => ({
__esModule: true,
default: jest.fn(),
selectors: {
problem: {
settings: jest.fn(state => ({ question: state })),
},
app: {
learningContextId: jest.fn(state => ({ learningContextId: state })),
},
},
thunkActions: {
video: {
importTranscript: jest.fn(),
},
},
}));
jest.mock('../../../../../sharedComponents/TinyMceWidget/hooks', () => ({
...jest.requireActual('../../../../../sharedComponents/TinyMceWidget/hooks'),
prepareEditorRef: jest.fn(() => ({
refReady: true,
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
})),
}));
describe('SolutionWidget', () => {
const props = {
settings: { solutionExplanation: 'This is my solution' },
learningContextId: 'course+org+run',
// injected
intl: { formatMessage },
};
describe('render', () => {
test('snapshot: renders correct default', () => {
expect(shallow(<ExplanationWidget {...props} />).snapshot).toMatchSnapshot();
});
});
describe('mapStateToProps', () => {
const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
test('settings from problem.settings', () => {
expect(mapStateToProps(testState).settings).toEqual(selectors.problem.settings(testState));
});
test('learningContextId from app.learningContextId', () => {
expect(mapStateToProps(testState).learningContextId).toEqual(selectors.app.learningContextId(testState));
});
});
});

View File

@@ -0,0 +1,22 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
solutionWidgetTitle: {
id: 'authoring.problemEditor.explanationwidget.explanationWidgetTitle',
defaultMessage: 'Explanation',
description: 'Explanation Title',
},
solutionDescriptionText: {
id: 'authoring.problemEditor.solutionwidget.solutionDescriptionText',
defaultMessage: 'Provide an explanation for the correct answer',
description: 'Description of the solution widget',
},
placeholder: {
id: 'authoring.problemEditor.questionwidget.placeholder',
defaultMessage: 'Enter your explanation',
description: 'Placeholder text for tinyMCE editor',
},
});
export default messages;

View File

@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`QuestionWidget render snapshot: renders correct default 1`] = `
<div
className="tinyMceWidget"
>
<div
className="h4 mb-3"
>
<FormattedMessage
defaultMessage="Question"
description="Question Title"
id="authoring.questionwidget.question.questionWidgetTitle"
/>
</div>
<[object Object]
editorContentHtml="This is my question"
editorType="question"
id="question"
minHeight={150}
placeholder="Enter your question"
setEditorRef={[MockFunction prepareEditorRef.setEditorRef]}
/>
</div>
`;

View File

@@ -0,0 +1,56 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
import { selectors } from '../../../../../data/redux';
import messages from './messages';
import TinyMceWidget from '../../../../../sharedComponents/TinyMceWidget';
import { prepareEditorRef, replaceStaticWithAsset } from '../../../../../sharedComponents/TinyMceWidget/hooks';
export const QuestionWidget = ({
// redux
question,
learningContextId,
// injected
intl,
}) => {
const { editorRef, refReady, setEditorRef } = prepareEditorRef();
const initialContent = question;
const newContent = replaceStaticWithAsset({
initialContent,
learningContextId,
});
const questionContent = newContent || initialContent;
if (!refReady) { return null; }
return (
<div className="tinyMceWidget">
<div className="h4 mb-3">
<FormattedMessage {...messages.questionWidgetTitle} />
</div>
<TinyMceWidget
id="question"
editorType="question"
editorRef={editorRef}
editorContentHtml={questionContent}
setEditorRef={setEditorRef}
minHeight={150}
placeholder={intl.formatMessage(messages.placeholder)}
/>
</div>
);
};
QuestionWidget.propTypes = {
// redux
question: PropTypes.string.isRequired,
learningContextId: PropTypes.string.isRequired,
// injected
intl: intlShape.isRequired,
};
export const mapStateToProps = (state) => ({
question: selectors.problem.question(state),
learningContextId: selectors.app.learningContextId(state),
});
export default injectIntl(connect(mapStateToProps)(QuestionWidget));

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { formatMessage } from '../../../../../../testUtils';
import { selectors } from '../../../../../data/redux';
import { QuestionWidget, mapStateToProps } from '.';
jest.mock('../../../../../data/redux', () => ({
__esModule: true,
default: jest.fn(),
actions: {
problem: {
updateQuestion: jest.fn().mockName('actions.problem.updateQuestion'),
},
},
selectors: {
app: {
learningContextId: jest.fn(state => ({ learningContextId: state })),
},
problem: {
question: jest.fn(state => ({ question: state })),
},
},
thunkActions: {
video: {
importTranscript: jest.fn(),
},
},
}));
jest.mock('../../../../../sharedComponents/TinyMceWidget/hooks', () => ({
...jest.requireActual('../../../../../sharedComponents/TinyMceWidget/hooks'),
prepareEditorRef: jest.fn(() => ({
refReady: true,
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
})),
}));
describe('QuestionWidget', () => {
const props = {
question: 'This is my question',
updateQuestion: jest.fn(),
learningContextId: 'course+org+run',
// injected
intl: { formatMessage },
};
describe('render', () => {
test('snapshot: renders correct default', () => {
expect(shallow(<QuestionWidget {...props} />).snapshot).toMatchSnapshot();
});
});
describe('mapStateToProps', () => {
const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
test('question from problem.question', () => {
expect(mapStateToProps(testState).question).toEqual(selectors.problem.question(testState));
});
test('learningContextId from app.learningContextId', () => {
expect(mapStateToProps(testState).learningContextId).toEqual(selectors.app.learningContextId(testState));
});
});
});

View File

@@ -0,0 +1,17 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
questionWidgetTitle: {
id: 'authoring.questionwidget.question.questionWidgetTitle',
defaultMessage: 'Question',
description: 'Question Title',
},
placeholder: {
id: 'authoring.problemEditor.questionwidget.placeholder',
defaultMessage: 'Enter your question',
description: 'Placeholder text for tinyMCE editor',
},
});
export default messages;

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { Collapsible, Card } from '@openedx/paragon';
import {
bool, string, node,
} from 'prop-types';
const CardSection = ({
children, none, isCardCollapsibleOpen, summary,
}) => {
const show = isCardCollapsibleOpen || summary;
if (!show) { return null; }
return (
<Card.Section className="pt-0">
<Collapsible.Advanced
open={!isCardCollapsibleOpen}
>
<Collapsible.Body className="collapsible-body">
<span className={`small ${none ? 'text-gray-500' : 'text-primary-500'}`}>{summary}</span>
</Collapsible.Body>
</Collapsible.Advanced>
<Collapsible.Advanced
open={isCardCollapsibleOpen}
>
<Collapsible.Body className="collapsible-body text-primary-500 x-small">
{children}
</Collapsible.Body>
</Collapsible.Advanced>
</Card.Section>
);
};
CardSection.propTypes = {
none: bool,
children: node.isRequired,
summary: string,
isCardCollapsibleOpen: bool.isRequired,
};
CardSection.defaultProps = {
none: false,
summary: null,
};
export default CardSection;

View File

@@ -0,0 +1,14 @@
import { shallow } from '@edx/react-unit-test-utils';
import CardSection from './CardSection';
describe('CardSection', () => {
test('open', () => {
expect(shallow(<CardSection summary="summary" isCardCollapsibleOpen><h1>Section Text</h1></CardSection>).snapshot).toMatchSnapshot();
});
test('closed', () => {
expect(
shallow(<CardSection isCardCollapsibleOpen={false}><h1>Section Text</h1></CardSection>).snapshot,
).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { Collapsible, Icon, Card } from '@openedx/paragon';
import { KeyboardArrowUp, KeyboardArrowDown } from '@openedx/paragon/icons';
import {
arrayOf,
shape,
string,
node,
bool,
} from 'prop-types';
import { showFullCard } from './hooks';
import CardSection from './CardSection';
export const SettingsOption = ({
title, className, extraSections, children, summary, hasExpandableTextArea, ...passThroughProps
}) => {
const { isCardCollapsibleOpen, toggleCardCollapse } = showFullCard(hasExpandableTextArea);
return (
<Card className={`${className} settingsOption border border-light-700 shadow-none`}>
<Card.Section className="settingsCardTitleSection" key={`settingsOption-${title}-header`}>
<Collapsible.Advanced
open={isCardCollapsibleOpen}
onToggle={toggleCardCollapse}
>
<Collapsible.Trigger className="collapsible-trigger d-flex">
<span className="flex-grow-1 text-primary-500 x-small">{title}</span>
<Collapsible.Visible whenClosed>
<Icon src={KeyboardArrowDown} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={KeyboardArrowUp} />
</Collapsible.Visible>
</Collapsible.Trigger>
</Collapsible.Advanced>
</Card.Section>
<CardSection {...passThroughProps} isCardCollapsibleOpen={isCardCollapsibleOpen} summary={summary} key={`settingsOption-${title}-children`}>
{children}
</CardSection>
{extraSections.map((section, index) => (
<>
{isCardCollapsibleOpen && <hr />}
{/* eslint-disable-next-line react/no-array-index-key */}
<CardSection {...passThroughProps} isCardCollapsibleOpen={isCardCollapsibleOpen} key={`settingsOption-${title}-${index}`}>
{section.children}
</CardSection>
</>
))}
</Card>
);
};
SettingsOption.propTypes = {
title: string.isRequired,
children: node.isRequired,
className: string,
summary: string.isRequired,
extraSections: arrayOf(shape({
children: node,
})),
hasExpandableTextArea: bool,
};
SettingsOption.defaultProps = {
className: '',
extraSections: [],
hasExpandableTextArea: false,
};
export default SettingsOption;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import SettingsOption from './SettingsOption';
describe('SettingsOption', () => {
describe('default with children', () => {
const children = (<h1>My test content</h1>);
test('snapshot: renders correct', () => {
expect(shallow(<SettingsOption title="Settings Option Title" summary="Settings Option Summary">{children}</SettingsOption>).snapshot).toMatchSnapshot();
});
});
describe('with additional sections', () => {
const children = (<h1>First Section</h1>);
const sections = [<h1>Second Section</h1>, <h1>Third Section</h1>];
test('snapshot: renders correct', () => {
expect(shallow(
<SettingsOption title="Settings Option Title" summary="Settings Option Summary" extraSections={sections}>
{children}
</SettingsOption>,
).snapshot).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CardSection closed 1`] = `null`;
exports[`CardSection open 1`] = `
<Card.Section
className="pt-0"
>
<Advanced
open={false}
>
<Body
className="collapsible-body"
>
<span
className="small text-primary-500"
>
summary
</span>
</Body>
</Advanced>
<Advanced
open={true}
>
<Body
className="collapsible-body text-primary-500 x-small"
>
<h1>
Section Text
</h1>
</Body>
</Advanced>
</Card.Section>
`;

View File

@@ -0,0 +1,109 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SettingsOption default with children snapshot: renders correct 1`] = `
<Card
className=" settingsOption border border-light-700 shadow-none"
>
<Card.Section
className="settingsCardTitleSection"
key="settingsOption-Settings Option Title-header"
>
<Advanced
onToggle={[Function]}
open={false}
>
<Trigger
className="collapsible-trigger d-flex"
>
<span
className="flex-grow-1 text-primary-500 x-small"
>
Settings Option Title
</span>
<Visible
whenClosed={true}
>
<Icon />
</Visible>
<Visible
whenOpen={true}
>
<Icon />
</Visible>
</Trigger>
</Advanced>
</Card.Section>
<CardSection
isCardCollapsibleOpen={false}
key="settingsOption-Settings Option Title-children"
none={false}
summary="Settings Option Summary"
>
<h1>
My test content
</h1>
</CardSection>
</Card>
`;
exports[`SettingsOption with additional sections snapshot: renders correct 1`] = `
<Card
className=" settingsOption border border-light-700 shadow-none"
>
<Card.Section
className="settingsCardTitleSection"
key="settingsOption-Settings Option Title-header"
>
<Advanced
onToggle={[Function]}
open={false}
>
<Trigger
className="collapsible-trigger d-flex"
>
<span
className="flex-grow-1 text-primary-500 x-small"
>
Settings Option Title
</span>
<Visible
whenClosed={true}
>
<Icon />
</Visible>
<Visible
whenOpen={true}
>
<Icon />
</Visible>
</Trigger>
</Advanced>
</Card.Section>
<CardSection
isCardCollapsibleOpen={false}
key="settingsOption-Settings Option Title-children"
none={false}
summary="Settings Option Summary"
>
<h1>
First Section
</h1>
</CardSection>
<Fragment>
<CardSection
isCardCollapsibleOpen={false}
key="settingsOption-Settings Option Title-0"
none={false}
summary={null}
/>
</Fragment>
<Fragment>
<CardSection
isCardCollapsibleOpen={false}
key="settingsOption-Settings Option Title-1"
none={false}
summary={null}
/>
</Fragment>
</Card>
`;

View File

@@ -0,0 +1,253 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SettingsWidget snapshot snapshot: renders Settings widget for Advanced Problem with correct widgets 1`] = `
<div
className="settingsWidget ml-4"
>
<div
className="mb-3"
>
<TypeCard
problemType="stringresponse"
/>
</div>
<div
className="my-3"
>
<ScoringCard
defaultValue={2}
/>
</div>
<div
className="mt-3"
>
<HintsCard
problemType="stringresponse"
/>
</div>
<Fragment />
<div>
<Advanced
open={false}
>
<Body
className="collapsible-body small"
>
<Button
className="my-3 px-0 text-info-500"
size="inline"
variant="link"
>
<FormattedMessage
defaultMessage="Show advanced settings"
description="Button text to show advanced settings"
id="authoring.problemeditor.settings.showAdvancedButton"
/>
</Button>
</Body>
</Advanced>
</div>
<Advanced
open={true}
>
<Body
className="collapsible-body"
>
<div
className="my-3"
>
<ShowAnswerCard
defaultValue="finished"
/>
</div>
<div
className="my-3"
>
<ResetCard
defaultValue={false}
/>
</div>
<div
className="my-3"
>
<TimerCard />
</div>
<div
className="my-3"
>
<SwitchToAdvancedEditorCard
problemType="stringresponse"
/>
</div>
</Body>
</Advanced>
</div>
`;
exports[`SettingsWidget snapshot snapshot: renders Settings widget page 1`] = `
<div
className="settingsWidget ml-4"
>
<div
className="mb-3"
>
<TypeCard
problemType="stringresponse"
/>
</div>
<div
className="my-3"
>
<ScoringCard
defaultValue={2}
/>
</div>
<div
className="mt-3"
>
<HintsCard
problemType="stringresponse"
/>
</div>
<Fragment />
<div>
<Advanced
open={true}
>
<Body
className="collapsible-body small"
>
<Button
className="my-3 px-0 text-info-500"
size="inline"
variant="link"
>
<FormattedMessage
defaultMessage="Show advanced settings"
description="Button text to show advanced settings"
id="authoring.problemeditor.settings.showAdvancedButton"
/>
</Button>
</Body>
</Advanced>
</div>
<Advanced
open={false}
>
<Body
className="collapsible-body"
>
<div
className="my-3"
>
<ShowAnswerCard
defaultValue="finished"
/>
</div>
<div
className="my-3"
>
<ResetCard
defaultValue={false}
/>
</div>
<div
className="my-3"
>
<TimerCard />
</div>
<div
className="my-3"
>
<SwitchToAdvancedEditorCard
problemType="stringresponse"
/>
</div>
</Body>
</Advanced>
</div>
`;
exports[`SettingsWidget snapshot snapshot: renders Settings widget page advanced settings visible 1`] = `
<div
className="settingsWidget ml-4"
>
<div
className="mb-3"
>
<TypeCard
problemType="stringresponse"
/>
</div>
<div
className="my-3"
>
<ScoringCard
defaultValue={2}
/>
</div>
<div
className="mt-3"
>
<HintsCard
problemType="stringresponse"
/>
</div>
<Fragment />
<div>
<Advanced
open={false}
>
<Body
className="collapsible-body small"
>
<Button
className="my-3 px-0 text-info-500"
size="inline"
variant="link"
>
<FormattedMessage
defaultMessage="Show advanced settings"
description="Button text to show advanced settings"
id="authoring.problemeditor.settings.showAdvancedButton"
/>
</Button>
</Body>
</Advanced>
</div>
<Advanced
open={true}
>
<Body
className="collapsible-body"
>
<div
className="my-3"
>
<ShowAnswerCard
defaultValue="finished"
/>
</div>
<div
className="my-3"
>
<ResetCard
defaultValue={false}
/>
</div>
<div
className="my-3"
>
<TimerCard />
</div>
<div
className="my-3"
>
<SwitchToAdvancedEditorCard
problemType="stringresponse"
/>
</div>
</Body>
</Advanced>
</div>
`;

View File

@@ -0,0 +1,329 @@
import { useState, useEffect } from 'react';
import _ from 'lodash-es';
import * as module from './hooks';
import messages from './messages';
import {
ProblemTypeKeys,
ProblemTypes,
RichTextProblems,
ShowAnswerTypesKeys,
} from '../../../../../data/constants/problem';
import { fetchEditorContent } from '../hooks';
export const state = {
// eslint-disable-next-line react-hooks/rules-of-hooks
showAdvanced: (val) => useState(val),
// eslint-disable-next-line react-hooks/rules-of-hooks
cardCollapsed: (val) => useState(val),
// eslint-disable-next-line react-hooks/rules-of-hooks
summary: (val) => useState(val),
// eslint-disable-next-line react-hooks/rules-of-hooks
showAttempts: (val) => useState(val),
// eslint-disable-next-line react-hooks/rules-of-hooks
attemptDisplayValue: (val) => useState(val),
};
export const showAdvancedSettingsCards = () => {
const [isAdvancedCardsVisible, setIsAdvancedCardsVisible] = module.state.showAdvanced(false);
return {
isAdvancedCardsVisible,
showAdvancedCards: () => setIsAdvancedCardsVisible(true),
};
};
export const showFullCard = (hasExpandableTextArea) => {
const [isCardCollapsibleOpen, setIsCardCollapsibleOpen] = module.state.cardCollapsed(hasExpandableTextArea);
return {
isCardCollapsibleOpen,
toggleCardCollapse: () => {
if (hasExpandableTextArea) {
setIsCardCollapsibleOpen(true);
} else {
setIsCardCollapsibleOpen(!isCardCollapsibleOpen);
}
},
};
};
export const hintsCardHooks = (hints, updateSettings) => {
const [summary, setSummary] = module.state.summary({ message: messages.noHintSummary, values: {} });
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
const hintsNumber = hints.length;
if (hintsNumber === 0) {
setSummary({ message: messages.noHintSummary, values: {} });
} else {
setSummary({ message: messages.hintSummary, values: { hint: hints[0].value, count: (hintsNumber - 1) } });
}
}, [hints]);
const handleAdd = () => {
let newId = 0;
if (!_.isEmpty(hints)) {
newId = Math.max(...hints.map(hint => hint.id)) + 1;
}
const hint = { id: newId, value: '' };
const modifiedHints = [...hints, hint];
updateSettings({ hints: modifiedHints });
};
return {
summary,
handleAdd,
};
};
export const hintsRowHooks = (id, hints, updateSettings) => {
const handleChange = (value) => {
const modifiedHints = hints.map(hint => {
if (hint.id === id) {
return { ...hint, value };
}
return hint;
});
updateSettings({ hints: modifiedHints });
};
const handleDelete = () => {
const modifiedHints = hints.filter((hint) => (hint.id !== id));
updateSettings({ hints: modifiedHints });
};
return {
handleChange,
handleDelete,
};
};
export const resetCardHooks = (updateSettings) => {
const setReset = (value) => {
updateSettings({ showResetButton: value });
};
return {
setResetTrue: () => setReset(true),
setResetFalse: () => setReset(false),
};
};
export const scoringCardHooks = (scoring, updateSettings, defaultValue) => {
let loadedAttemptsNumber = scoring.attempts.number;
if ((loadedAttemptsNumber === defaultValue || !_.isFinite(loadedAttemptsNumber)) && _.isFinite(defaultValue)) {
loadedAttemptsNumber = `${defaultValue} (Default)`;
} else if (loadedAttemptsNumber === defaultValue && _.isNil(defaultValue)) {
loadedAttemptsNumber = '';
}
const [attemptDisplayValue, setAttemptDisplayValue] = module.state.attemptDisplayValue(loadedAttemptsNumber);
const handleUnlimitedChange = (event) => {
const isUnlimited = event.target.checked;
if (isUnlimited) {
setAttemptDisplayValue('');
updateSettings({ scoring: { ...scoring, attempts: { number: null, unlimited: true } } });
} else {
updateSettings({ scoring: { ...scoring, attempts: { number: null, unlimited: false } } });
}
};
const handleMaxAttemptChange = (event) => {
let unlimitedAttempts = false;
let attemptNumber = parseInt(event.target.value);
if (!_.isFinite(attemptNumber) || attemptNumber === defaultValue) {
attemptNumber = null;
if (_.isFinite(defaultValue)) {
setAttemptDisplayValue(`${defaultValue} (Default)`);
} else {
setAttemptDisplayValue('');
unlimitedAttempts = true;
}
} else if (attemptNumber <= 0) {
attemptNumber = 0;
}
updateSettings({ scoring: { ...scoring, attempts: { number: attemptNumber, unlimited: unlimitedAttempts } } });
};
const handleOnChange = (event) => {
let newMaxAttempt = parseInt(event.target.value);
if (newMaxAttempt === defaultValue) {
newMaxAttempt = `${defaultValue} (Default)`;
} else if (_.isNaN(newMaxAttempt)) {
newMaxAttempt = '';
} else if (newMaxAttempt < 0) {
newMaxAttempt = 0;
}
setAttemptDisplayValue(newMaxAttempt);
};
const handleWeightChange = (event) => {
let weight = parseFloat(event.target.value);
if (_.isNaN(weight)) {
weight = 0;
}
updateSettings({ scoring: { ...scoring, weight } });
};
return {
attemptDisplayValue,
handleUnlimitedChange,
handleMaxAttemptChange,
handleOnChange,
handleWeightChange,
};
};
export const useAnswerSettings = (showAnswer, updateSettings) => {
const [showAttempts, setShowAttempts] = module.state.showAttempts(false);
const numberOfAttemptsChoice = [
ShowAnswerTypesKeys.AFTER_SOME_NUMBER_OF_ATTEMPTS,
];
useEffect(() => {
setShowAttempts(_.includes(numberOfAttemptsChoice, showAnswer.on));
}, [showAttempts]);
const handleShowAnswerChange = (event) => {
const { value } = event.target;
setShowAttempts(_.includes(numberOfAttemptsChoice, value));
updateSettings({ showAnswer: { ...showAnswer, on: value } });
};
const handleAttemptsChange = (event) => {
let attempts = parseInt(event.target.value);
if (_.isNaN(attempts)) {
attempts = 0;
}
updateSettings({ showAnswer: { ...showAnswer, afterAttempts: attempts } });
};
return {
handleShowAnswerChange,
handleAttemptsChange,
showAttempts,
};
};
export const timerCardHooks = (updateSettings) => ({
handleChange: (event) => {
let time = parseInt(event.target.value);
if (_.isNaN(time)) {
time = 0;
}
updateSettings({ timeBetween: time });
},
});
export const typeRowHooks = ({
answers,
blockTitle,
correctAnswerCount,
problemType,
setBlockTitle,
typeKey,
updateField,
updateAnswer,
}) => {
const clearPreviouslySelectedAnswers = () => {
let currentAnswerTitles;
const { selectedFeedback, unselectedFeedback, ...editorContent } = fetchEditorContent({ format: 'text' });
if (RichTextProblems.includes(problemType)) {
currentAnswerTitles = editorContent.answers;
}
answers.forEach(answer => {
const title = currentAnswerTitles?.[answer.id] || answer.title;
if (answer.correct) {
updateAnswer({
...answer,
title,
selectedFeedback,
unselectedFeedback,
correct: false,
});
} else {
updateAnswer({
...answer,
selectedFeedback,
unselectedFeedback,
title,
});
}
});
};
const updateAnswersToCorrect = () => {
let currentAnswerTitles;
const { selectedFeedback, unselectedFeedback, ...editorContent } = fetchEditorContent({ format: 'text' });
if (RichTextProblems.includes(problemType)) {
currentAnswerTitles = editorContent.answers;
}
answers.forEach(answer => {
const title = currentAnswerTitles ? currentAnswerTitles[answer.id] : answer.title;
updateAnswer({
...answer,
title,
selectedFeedback,
unselectedFeedback,
correct: true,
});
});
};
const convertToPlainText = () => {
const { selectedFeedback, unselectedFeedback, ...editorContent } = fetchEditorContent({ format: 'text' });
const currentAnswerTitles = editorContent.answers;
answers.forEach(answer => {
updateAnswer({
...answer,
selectedFeedback,
unselectedFeedback,
title: currentAnswerTitles[answer.id],
});
});
};
const onClick = () => {
// Numeric, text, and dropdowns cannot render HTML as answer values, so if switching from a single select
// or multi-select problem the rich text needs to covert to plain text
if (typeKey === ProblemTypeKeys.TEXTINPUT && RichTextProblems.includes(problemType)) {
convertToPlainText();
}
// Dropdown problems can only have one correct answer. When there is more than one correct answer
// from a previous problem type, the correct attribute for selected answers need to be set to false.
if (typeKey === ProblemTypeKeys.DROPDOWN) {
if (correctAnswerCount > 1) {
clearPreviouslySelectedAnswers();
} else if (RichTextProblems.includes(problemType)) {
convertToPlainText();
}
}
// Numeric input problems can only have correct answers. Switch all answers to correct when switching
// to numeric input.
if (typeKey === ProblemTypeKeys.NUMERIC) {
updateAnswersToCorrect();
}
if (blockTitle === ProblemTypes[problemType].title) {
setBlockTitle(ProblemTypes[typeKey].title);
}
updateField({ problemType: typeKey });
};
return {
onClick,
};
};
export const confirmSwitchToAdvancedEditor = ({
switchToAdvancedEditor,
setConfirmOpen,
}) => {
switchToAdvancedEditor();
setConfirmOpen(false);
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};

View File

@@ -0,0 +1,395 @@
import { useEffect } from 'react';
import { MockUseState } from '../../../../../../testUtils';
import messages from './messages';
import { keyStore } from '../../../../../utils';
import * as hooks from './hooks';
import { ProblemTypeKeys, ProblemTypes } from '../../../../../data/constants/problem';
import * as editHooks from '../hooks';
jest.mock('react', () => {
const updateState = jest.fn();
return {
updateState,
useEffect: jest.fn(),
useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])),
};
});
jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m,
}));
jest.mock('../../../../../data/redux', () => ({
actions: {
problem: {
updateSettings: (args) => ({ updateSettings: args }),
updateField: (args) => ({ updateField: args }),
updateAnswer: (args) => ({ updateAnswer: args }),
},
},
}));
const state = new MockUseState(hooks);
const moduleKeys = keyStore(editHooks);
describe('Problem settings hooks', () => {
let output;
let updateSettings;
beforeEach(() => {
updateSettings = jest.fn();
state.mock();
});
afterEach(() => {
state.restore();
useEffect.mockClear();
});
describe('Show advanced settings', () => {
beforeEach(() => {
output = hooks.showAdvancedSettingsCards();
});
test('test default state is false', () => {
expect(output.isAdvancedCardsVisible).toBeFalsy();
});
test('test showAdvancedCards sets state to true', () => {
output.showAdvancedCards();
expect(state.setState[state.keys.showAdvanced]).toHaveBeenCalledWith(true);
});
});
describe('Show full card', () => {
beforeEach(() => {
output = hooks.showFullCard();
});
test('test default state is false', () => {
expect(output.isCardCollapsibleOpen).toBeFalsy();
});
test('test toggleCardCollapse to true', () => {
output.toggleCardCollapse();
expect(state.setState[state.keys.cardCollapsed]).toHaveBeenCalledWith(true);
});
test('test toggleCardCollapse to true', () => {
output = hooks.showFullCard(true);
output.toggleCardCollapse();
expect(state.setState[state.keys.cardCollapsed]).toHaveBeenCalledWith(true);
});
});
describe('Hint card hooks', () => {
test('test useEffect triggers set hints summary no hint', () => {
const hints = [];
hooks.hintsCardHooks(hints, updateSettings);
expect(state.setState[state.keys.summary]).not.toHaveBeenCalled();
const [cb, prereqs] = useEffect.mock.calls[0];
expect(prereqs).toStrictEqual([[]]);
cb();
expect(state.setState[state.keys.summary])
.toHaveBeenCalledWith({ message: messages.noHintSummary, values: {} });
});
test('test useEffect triggers set hints summary', () => {
const hints = [{ id: 1, value: 'hint1' }];
output = hooks.hintsCardHooks(hints, updateSettings);
expect(state.setState[state.keys.summary]).not.toHaveBeenCalled();
const [cb, prereqs] = useEffect.mock.calls[0];
expect(prereqs).toStrictEqual([[{ id: 1, value: 'hint1' }]]);
cb();
expect(state.setState[state.keys.summary])
.toHaveBeenCalledWith({
message: messages.hintSummary,
values: { hint: hints[0].value, count: (hints.length - 1) },
});
});
test('test handleAdd triggers updateSettings', () => {
const hint1 = { id: 1, value: 'hint1' };
const hint2 = { id: 2, value: '' };
const hints = [hint1];
output = hooks.hintsCardHooks(hints, updateSettings);
output.handleAdd();
expect(updateSettings).toHaveBeenCalledWith({ hints: [hint1, hint2] });
});
});
describe('Hint rows hooks', () => {
const hint1 = { id: 1, value: 'hint1' };
const hint2 = { id: 2, value: '' };
const value = 'modifiedHint';
const modifiedHint = { id: 2, value };
const hints = [hint1, hint2];
beforeEach(() => {
output = hooks.hintsRowHooks(2, hints, updateSettings);
});
test('test handleChange', () => {
output.handleChange(value);
expect(updateSettings).toHaveBeenCalledWith({ hints: [hint1, modifiedHint] });
});
test('test handleDelete', () => {
output.handleDelete();
expect(updateSettings).toHaveBeenCalledWith({ hints: [hint1] });
});
});
describe('Reset card hooks', () => {
beforeEach(() => {
output = hooks.resetCardHooks(updateSettings);
});
test('test setResetTrue', () => {
output.setResetTrue();
expect(updateSettings).toHaveBeenCalledWith({ showResetButton: true });
});
test('test setResetFalse', () => {
output.setResetFalse();
expect(updateSettings).toHaveBeenCalledWith({ showResetButton: false });
});
});
describe('Scoring card hooks', () => {
const scoring = {
weight: 1.5,
attempts: {
unlimited: false,
number: 5,
},
};
const defaultValue = 1;
test('test scoringCardHooks initializes display value when attempts.number is null', () => {
const nilScoring = { ...scoring, attempts: { unlimited: false, number: null } };
output = hooks.scoringCardHooks(nilScoring, updateSettings, defaultValue);
expect(state.stateVals[state.keys.attemptDisplayValue]).toEqual(`${defaultValue} (Default)`);
});
test('test scoringCardHooks initializes display value when attempts.number is blank', () => {
const nilScoring = { ...scoring, attempts: { unlimited: false, number: '' } };
output = hooks.scoringCardHooks(nilScoring, updateSettings, defaultValue);
expect(state.stateVals[state.keys.attemptDisplayValue]).toEqual(`${defaultValue} (Default)`);
});
test('test scoringCardHooks initializes display value when attempts.number is not null', () => {
const nonNilScoring = { ...scoring, attempts: { unlimited: false, number: 2 } };
output = hooks.scoringCardHooks(nonNilScoring, updateSettings, defaultValue);
expect(state.stateVals[state.keys.attemptDisplayValue]).toEqual(2);
});
test('test scoringCardHooks initializes display value when attempts.number and defaultValue is null', () => {
const nonNilScoring = { ...scoring, attempts: { unlimited: false, number: null } };
output = hooks.scoringCardHooks(nonNilScoring, updateSettings, null);
expect(state.stateVals[state.keys.attemptDisplayValue]).toEqual('');
});
beforeEach(() => {
output = hooks.scoringCardHooks(scoring, updateSettings, defaultValue);
});
test('test handleUnlimitedChange sets attempts.unlimited to true when checked', () => {
output.handleUnlimitedChange({ target: { checked: true } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith('');
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: null, unlimited: true } } });
});
test('test handleUnlimitedChange sets attempts.unlimited to false when unchecked', () => {
output.handleUnlimitedChange({ target: { checked: false } });
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: null, unlimited: false } } });
});
test('test handleMaxAttemptChange', () => {
const value = 6;
output.handleMaxAttemptChange({ target: { value } });
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: value, unlimited: false } } });
});
test('test handleMaxAttemptChange set attempts to zero', () => {
const value = 0;
output.handleMaxAttemptChange({ target: { value } });
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: value, unlimited: false } } });
});
test('test handleMaxAttemptChange set attempts to null value when default max_attempts is present', () => {
const value = null;
output.handleMaxAttemptChange({ target: { value } });
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: null, unlimited: false } } });
});
test('test handleMaxAttemptChange set attempts to null when default value is inputted', () => {
const value = '1 (Default)';
output.handleMaxAttemptChange({ target: { value } });
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: null, unlimited: false } } });
});
test('test handleMaxAttemptChange set attempts to non-numeric value', () => {
const value = 'abc';
output.handleMaxAttemptChange({ target: { value } });
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: null, unlimited: false } } });
});
test('test handleMaxAttemptChange set attempts to empty value', () => {
const value = '';
output.handleMaxAttemptChange({ target: { value } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(`${defaultValue} (Default)`);
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: null, unlimited: false } } });
});
test('test handleMaxAttemptChange set attempts to negative value', () => {
const value = -1;
output.handleMaxAttemptChange({ target: { value } });
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: 0, unlimited: false } } });
});
test('test handleMaxAttemptChange set attempts to empty value with no default', () => {
const value = '';
output = hooks.scoringCardHooks(scoring, updateSettings, null);
output.handleMaxAttemptChange({ target: { value } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith('');
expect(updateSettings)
.toHaveBeenCalledWith({ scoring: { ...scoring, attempts: { number: null, unlimited: true } } });
});
test('test handleOnChange', () => {
const value = 6;
output.handleOnChange({ target: { value } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(value);
});
test('test handleOnChange set attempts to zero', () => {
const value = 0;
output.handleOnChange({ target: { value } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(value);
});
test('test handleOnChange set attempts to default value from empty string', () => {
const value = '';
output.handleOnChange({ target: { value } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith('');
});
test('test handleOnChange set attempts to default value', () => {
const value = 1;
output.handleOnChange({ target: { value } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith('1 (Default)');
});
test('test handleOnChange set attempts to non-numeric value', () => {
const value = '';
output.handleOnChange({ target: { value } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(value);
});
test('test handleOnChange set attempts to negative value', () => {
const value = -1;
output.handleOnChange({ target: { value } });
expect(state.setState[state.keys.attemptDisplayValue]).toHaveBeenCalledWith(0);
});
test('test handleWeightChange', () => {
const value = 2;
output.handleWeightChange({ target: { value } });
expect(updateSettings).toHaveBeenCalledWith({ scoring: { ...scoring, weight: parseFloat(value) } });
});
});
describe('Show answer card hooks', () => {
const showAnswer = {
on: 'after_attempts',
afterAttempts: 5,
};
beforeEach(() => {
output = hooks.useAnswerSettings(showAnswer, updateSettings);
});
test('test handleShowAnswerChange', () => {
const value = 'always';
output.handleShowAnswerChange({ target: { value } });
expect(updateSettings).toHaveBeenCalledWith({ showAnswer: { ...showAnswer, on: value } });
});
test('test handleAttemptsChange', () => {
const value = 3;
output.handleAttemptsChange({ target: { value } });
expect(updateSettings).toHaveBeenCalledWith({ showAnswer: { ...showAnswer, afterAttempts: parseInt(value) } });
});
});
describe('Timer card hooks', () => {
test('test handleChange', () => {
output = hooks.timerCardHooks(updateSettings);
const value = 5;
output.handleChange({ target: { value } });
expect(updateSettings).toHaveBeenCalledWith({ timeBetween: value });
});
});
describe('Type row hooks', () => {
const typeRowProps = {
problemType: ProblemTypeKeys.MULTISELECT,
typeKey: ProblemTypeKeys.DROPDOWN,
blockTitle: ProblemTypes[ProblemTypeKeys.MULTISELECT].title,
setBlockTitle: jest.fn(),
updateField: jest.fn(),
updateAnswer: jest.fn(),
correctAnswerCount: 2,
answers: [
{ correct: true, id: 'a', title: '<p>testA</p>' },
{ correct: true, id: 'b', title: '<p>testB</p>' },
{ correct: false, id: 'c', title: '<p>testC</p>' },
],
};
const fetchEditorContent = () => ({
answers: {
a: 'testA',
b: 'testB',
c: 'testC',
},
});
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(editHooks, moduleKeys.fetchEditorContent)
.mockImplementationOnce(fetchEditorContent);
});
test('test onClick Multi-select to Dropdown', () => {
output = hooks.typeRowHooks(typeRowProps);
output.onClick();
expect(typeRowProps.setBlockTitle).toHaveBeenCalledWith(ProblemTypes[ProblemTypeKeys.DROPDOWN].title);
expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(1, { ...typeRowProps.answers[0], correct: false, title: 'testA' });
expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(2, { ...typeRowProps.answers[1], correct: false, title: 'testB' });
expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(3, { ...typeRowProps.answers[2], correct: false, title: 'testC' });
expect(typeRowProps.updateField).toHaveBeenCalledWith({ problemType: ProblemTypeKeys.DROPDOWN });
});
test('test onClick Multi-select to Dropdown with one correct answer', () => {
const oneAnswerTypeRowProps = {
...typeRowProps,
correctAnswerCount: 1,
answers: [
{ correct: true, id: 'a', title: '<p>testA</p>' },
{ correct: false, id: 'b', title: '<p>testB</p>' },
{ correct: false, id: 'c', title: '<p>testC</p>' },
],
};
output = hooks.typeRowHooks(oneAnswerTypeRowProps);
output.onClick();
expect(typeRowProps.setBlockTitle).toHaveBeenCalledWith(ProblemTypes[ProblemTypeKeys.DROPDOWN].title);
expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(1, { ...oneAnswerTypeRowProps.answers[0], title: 'testA' });
expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(2, { ...oneAnswerTypeRowProps.answers[1], title: 'testB' });
expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(3, { ...oneAnswerTypeRowProps.answers[2], title: 'testC' });
expect(typeRowProps.updateField).toHaveBeenCalledWith({ problemType: ProblemTypeKeys.DROPDOWN });
});
test('test onClick Multi-select to Numeric', () => {
output = hooks.typeRowHooks({
...typeRowProps,
typeKey: ProblemTypeKeys.NUMERIC,
});
output.onClick();
expect(typeRowProps.setBlockTitle).toHaveBeenCalledWith(ProblemTypes[ProblemTypeKeys.NUMERIC].title);
expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(1, { ...typeRowProps.answers[0], correct: true, title: 'testA' });
expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(2, { ...typeRowProps.answers[1], correct: true, title: 'testB' });
expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(3, { ...typeRowProps.answers[2], correct: true, title: 'testC' });
expect(typeRowProps.updateField).toHaveBeenCalledWith({ problemType: ProblemTypeKeys.NUMERIC });
});
test('test onClick Multi-select to Text Input', () => {
output = hooks.typeRowHooks({
...typeRowProps,
typeKey: ProblemTypeKeys.TEXTINPUT,
});
output.onClick();
expect(typeRowProps.setBlockTitle).toHaveBeenCalledWith(ProblemTypes[ProblemTypeKeys.TEXTINPUT].title);
expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(1, { ...typeRowProps.answers[0], title: 'testA' });
expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(2, { ...typeRowProps.answers[1], title: 'testB' });
expect(typeRowProps.updateAnswer).toHaveBeenNthCalledWith(3, { ...typeRowProps.answers[2], title: 'testC' });
expect(typeRowProps.updateField).toHaveBeenCalledWith({ problemType: ProblemTypeKeys.TEXTINPUT });
});
});
test('test confirmSwitchToAdvancedEditor hook', () => {
const switchToAdvancedEditor = jest.fn();
const setConfirmOpen = jest.fn();
window.scrollTo = jest.fn();
hooks.confirmSwitchToAdvancedEditor({
switchToAdvancedEditor,
setConfirmOpen,
});
expect(switchToAdvancedEditor).toHaveBeenCalled();
expect(setConfirmOpen).toHaveBeenCalledWith(false);
expect(window.scrollTo).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,195 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { connect } from 'react-redux';
import {
Button, Collapsible,
} from '@openedx/paragon';
import { selectors, actions } from '../../../../../data/redux';
import ScoringCard from './settingsComponents/ScoringCard';
import ShowAnswerCard from './settingsComponents/ShowAnswerCard';
import HintsCard from './settingsComponents/HintsCard';
import ResetCard from './settingsComponents/ResetCard';
import TimerCard from './settingsComponents/TimerCard';
import TypeCard from './settingsComponents/TypeCard';
import ToleranceCard from './settingsComponents/Tolerance';
import GroupFeedbackCard from './settingsComponents/GroupFeedback/index';
import SwitchToAdvancedEditorCard from './settingsComponents/SwitchToAdvancedEditorCard';
import messages from './messages';
import { showAdvancedSettingsCards } from './hooks';
import './index.scss';
import { ProblemTypeKeys } from '../../../../../data/constants/problem';
import Randomization from './settingsComponents/Randomization';
// This widget should be connected, grab all settings from store, update them as needed.
export const SettingsWidget = ({
problemType,
// redux
answers,
groupFeedbackList,
blockTitle,
correctAnswerCount,
settings,
setBlockTitle,
updateSettings,
updateField,
updateAnswer,
defaultSettings,
}) => {
const { isAdvancedCardsVisible, showAdvancedCards } = showAdvancedSettingsCards();
const feedbackCard = () => {
if ([ProblemTypeKeys.MULTISELECT].includes(problemType)) {
return (
<div className="mt-3"><GroupFeedbackCard
groupFeedbacks={groupFeedbackList}
updateSettings={updateField}
answers={answers}
/>
</div>
);
}
// eslint-disable-next-line react/jsx-no-useless-fragment
return (<></>);
};
return (
<div className="settingsWidget ml-4">
<div className="mb-3">
<TypeCard
answers={answers}
blockTitle={blockTitle}
correctAnswerCount={correctAnswerCount}
problemType={problemType}
setBlockTitle={setBlockTitle}
updateField={updateField}
updateAnswer={updateAnswer}
/>
</div>
{ProblemTypeKeys.NUMERIC === problemType
&& (
<div className="my-3">
<ToleranceCard
updateSettings={updateSettings}
answers={answers}
tolerance={settings.tolerance}
/>
</div>
)}
<div className="my-3">
<ScoringCard
scoring={settings.scoring}
defaultValue={defaultSettings.maxAttempts}
updateSettings={updateSettings}
/>
</div>
<div className="mt-3">
<HintsCard problemType={problemType} hints={settings.hints} updateSettings={updateSettings} />
</div>
{feedbackCard()}
<div>
<Collapsible.Advanced open={!isAdvancedCardsVisible}>
<Collapsible.Body className="collapsible-body small">
<Button
className="my-3 px-0 text-info-500"
variant="link"
size="inline"
onClick={showAdvancedCards}
>
<FormattedMessage {...messages.showAdvanceSettingsButtonText} />
</Button>
</Collapsible.Body>
</Collapsible.Advanced>
</div>
<Collapsible.Advanced open={isAdvancedCardsVisible}>
<Collapsible.Body className="collapsible-body">
<div className="my-3">
<ShowAnswerCard
showAnswer={settings.showAnswer}
defaultValue={defaultSettings.showanswer}
updateSettings={updateSettings}
/>
</div>
<div className="my-3">
<ResetCard
showResetButton={settings.showResetButton}
defaultValue={defaultSettings.showResetButton}
updateSettings={updateSettings}
/>
</div>
{
problemType === ProblemTypeKeys.ADVANCED && (
<div className="my-3">
<Randomization
randomization={settings.randomization}
defaultValue={defaultSettings.rerandomize}
updateSettings={updateSettings}
/>
</div>
)
}
<div className="my-3">
<TimerCard timeBetween={settings.timeBetween} updateSettings={updateSettings} />
</div>
<div className="my-3">
<SwitchToAdvancedEditorCard problemType={problemType} />
</div>
</Collapsible.Body>
</Collapsible.Advanced>
</div>
);
};
SettingsWidget.propTypes = {
answers: PropTypes.arrayOf(PropTypes.shape({
correct: PropTypes.bool,
id: PropTypes.string,
selectedFeedback: PropTypes.string,
title: PropTypes.string,
unselectedFeedback: PropTypes.string,
})).isRequired,
groupFeedbackList: PropTypes.arrayOf(
PropTypes.shape(
{
id: PropTypes.number,
feedback: PropTypes.string,
answers: PropTypes.arrayOf(PropTypes.string),
},
),
).isRequired,
blockTitle: PropTypes.string.isRequired,
correctAnswerCount: PropTypes.number.isRequired,
problemType: PropTypes.string.isRequired,
setBlockTitle: PropTypes.func.isRequired,
updateAnswer: PropTypes.func.isRequired,
updateField: PropTypes.func.isRequired,
updateSettings: PropTypes.func.isRequired,
defaultSettings: PropTypes.shape({
maxAttempts: PropTypes.number,
showanswer: PropTypes.string,
showResetButton: PropTypes.bool,
rerandomize: PropTypes.string,
}).isRequired,
// eslint-disable-next-line
settings: PropTypes.any.isRequired,
};
const mapStateToProps = (state) => ({
groupFeedbackList: selectors.problem.groupFeedbackList(state),
settings: selectors.problem.settings(state),
answers: selectors.problem.answers(state),
blockTitle: selectors.app.blockTitle(state),
correctAnswerCount: selectors.problem.correctAnswerCount(state),
defaultSettings: selectors.problem.defaultSettings(state),
});
export const mapDispatchToProps = {
setBlockTitle: actions.app.setBlockTitle,
updateSettings: actions.problem.updateSettings,
updateField: actions.problem.updateField,
updateAnswer: actions.problem.updateAnswer,
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(SettingsWidget));

View File

@@ -0,0 +1,24 @@
.settingsCardTitleSection {
padding-bottom: 0rem;
}
.halfSpacedMessage {
padding-bottom: .5rem;
}
.spacedMessage {
padding-bottom: 1.5rem;
}
.settingsWidget {
margin-top: 40px;
.pgn__form-text {
font-size: small;
}
}
.resetCard {
.resetSettingsButtons {
width: 100%;
}
}

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { showAdvancedSettingsCards } from './hooks';
import { SettingsWidget, mapDispatchToProps } from '.';
import { ProblemTypeKeys } from '../../../../../data/constants/problem';
import { actions } from '../../../../../data/redux';
jest.mock('./hooks', () => ({
showAdvancedSettingsCards: jest.fn(),
}));
jest.mock('./settingsComponents/GeneralFeedback', () => 'GeneralFeedback');
jest.mock('./settingsComponents/GroupFeedback', () => 'GroupFeedback');
jest.mock('./settingsComponents/Randomization', () => 'Randomization');
jest.mock('./settingsComponents/HintsCard', () => 'HintsCard');
jest.mock('./settingsComponents/ResetCard', () => 'ResetCard');
jest.mock('./settingsComponents/ScoringCard', () => 'ScoringCard');
jest.mock('./settingsComponents/ShowAnswerCard', () => 'ShowAnswerCard');
jest.mock('./settingsComponents/SwitchToAdvancedEditorCard', () => 'SwitchToAdvancedEditorCard');
jest.mock('./settingsComponents/TimerCard', () => 'TimerCard');
jest.mock('./settingsComponents/TypeCard', () => 'TypeCard');
describe('SettingsWidget', () => {
const props = {
problemType: ProblemTypeKeys.TEXTINPUT,
settings: {},
defaultSettings: {
maxAttempts: 2,
showanswer: 'finished',
showResetButton: false,
},
};
describe('behavior', () => {
it(' calls showAdvancedSettingsCards when initialized', () => {
const showAdvancedSettingsCardsProps = {
isAdvancedCardsVisible: false,
setResetTrue: jest.fn().mockName('showAdvancedSettingsCards.setResetTrue'),
};
showAdvancedSettingsCards.mockReturnValue(showAdvancedSettingsCardsProps);
shallow(<SettingsWidget {...props} />);
expect(showAdvancedSettingsCards).toHaveBeenCalledWith();
});
});
describe('snapshot', () => {
test('snapshot: renders Settings widget page', () => {
const showAdvancedSettingsCardsProps = {
isAdvancedCardsVisible: false,
setResetTrue: jest.fn().mockName('showAdvancedSettingsCards.setResetTrue'),
};
showAdvancedSettingsCards.mockReturnValue(showAdvancedSettingsCardsProps);
expect(shallow(<SettingsWidget {...props} />).snapshot).toMatchSnapshot();
});
test('snapshot: renders Settings widget page advanced settings visible', () => {
const showAdvancedSettingsCardsProps = {
isAdvancedCardsVisible: true,
setResetTrue: jest.fn().mockName('showAdvancedSettingsCards.setResetTrue'),
};
showAdvancedSettingsCards.mockReturnValue(showAdvancedSettingsCardsProps);
expect(shallow(<SettingsWidget {...props} />).snapshot).toMatchSnapshot();
});
test('snapshot: renders Settings widget for Advanced Problem with correct widgets', () => {
const showAdvancedSettingsCardsProps = {
isAdvancedCardsVisible: true,
setResetTrue: jest.fn().mockName('showAdvancedSettingsCards.setResetTrue'),
};
showAdvancedSettingsCards.mockReturnValue(showAdvancedSettingsCardsProps);
expect(shallow(<SettingsWidget problemType={ProblemTypeKeys.ADVANCED} {...props} />).snapshot).toMatchSnapshot();
});
});
describe('mapDispatchToProps', () => {
test('setBlockTitle from actions.app.setBlockTitle', () => {
expect(mapDispatchToProps.setBlockTitle).toEqual(actions.app.setBlockTitle);
});
});
describe('mapDispatchToProps', () => {
test('updateSettings from actions.problem.updateSettings', () => {
expect(mapDispatchToProps.updateSettings).toEqual(actions.problem.updateSettings);
});
});
describe('mapDispatchToProps', () => {
test('updateField from actions.problem.updateField', () => {
expect(mapDispatchToProps.updateField).toEqual(actions.problem.updateField);
});
});
describe('mapDispatchToProps', () => {
test('updateAnswer from actions.problem.updateAnswer', () => {
expect(mapDispatchToProps.updateAnswer).toEqual(actions.problem.updateAnswer);
});
});
});

View File

@@ -0,0 +1,192 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
settingsWidgetTitle: {
id: 'authoring.problemeditor.settings.settingsWidgetTitle',
defaultMessage: 'Settings',
description: 'Settings Title',
},
showAdvanceSettingsButtonText: {
id: 'authoring.problemeditor.settings.showAdvancedButton',
defaultMessage: 'Show advanced settings',
description: 'Button text to show advanced settings',
},
settingsDeleteIconAltText: {
id: 'authoring.problemeditor.settings.delete.icon.alt',
defaultMessage: 'Delete answer',
description: 'Alt text for delete icon',
},
advancedSettingsLinkText: {
id: 'authoring.problemeditor.settings.advancedSettingLink.text',
defaultMessage: 'Set a default value in advanced settings',
description: 'Advanced settings link text',
},
hintSettingTitle: {
id: 'authoring.problemeditor.settings.hint.title',
defaultMessage: 'Hints',
description: 'Hint settings card title',
},
hintInputLabel: {
id: 'authoring.problemeditor.settings.hint.inputLabel',
defaultMessage: 'Hint',
description: 'Hint text input label',
},
addHintButtonText: {
id: 'authoring.problemeditor.settings.hint.addHintButton',
defaultMessage: 'Add hint',
description: 'Add hint button text',
},
noHintSummary: {
id: 'authoring.problemeditor.settings.hint.noHintSummary',
defaultMessage: 'None',
description: 'Summary text for no hints',
},
hintSummary: {
id: 'authoring.problemeditor.settings.hint.summary',
defaultMessage: '{hint} {count, plural, =0 {} other {(+# more)}}',
description: 'Summary text for hint settings',
},
resetSettingsTitle: {
id: 'authoring.problemeditor.settings.reset.title',
defaultMessage: 'Show reset option',
description: 'Reset settings card title',
},
resetSettingsTrue: {
id: 'authoring.problemeditor.settings.reset.true',
defaultMessage: 'True',
description: 'True option for reset',
},
resetSettingsFalse: {
id: 'authoring.problemeditor.settings.reset.false',
defaultMessage: 'False',
description: 'False option for reset',
},
resetSettingText: {
id: 'authoring.problemeditor.settings.reset.text',
defaultMessage: "Determines whether a 'Reset' button is shown so the user may reset their answer, generally for use in practice or formative assessments.",
description: 'Reset settings card text',
},
scoringSettingsTitle: {
id: 'authoring.problemeditor.settings.scoring.title',
defaultMessage: 'Scoring',
description: 'Scoring settings card title',
},
scoringAttemptsInputLabel: {
id: 'authoring.problemeditor.settings.scoring.attempts.inputLabel',
defaultMessage: 'Attempts',
description: 'Scoring attempts text input label',
},
scoringWeightInputLabel: {
id: 'authoring.problemeditor.settings.scoring.weight.inputLabel',
defaultMessage: 'Points',
description: 'Scoring weight input label',
},
unlimitedAttemptsSummary: {
id: 'authoring.problemeditor.settings.scoring.unlimited',
defaultMessage: 'Unlimited attempts',
description: 'Summary text for unlimited attempts',
},
attemptsSummary: {
id: 'authoring.problemeditor.settings.scoring.attempts',
defaultMessage: '{attempts, plural, =1 {# attempt} other {# attempts}}',
description: 'Summary text for number of attempts',
},
unlimitedAttemptsCheckboxLabel: {
id: 'authoring.problemeditor.settings.scoring.attempts.unlimitedCheckbox',
defaultMessage: 'Unlimited attempts',
description: 'Label for unlimited attempts checkbox',
},
weightSummary: {
id: 'authoring.problemeditor.settings.scoring.weight',
defaultMessage: '{weight, plural, =0 {Ungraded} other {# points}}',
description: 'Summary text for scoring weight',
},
scoringSettingsLabel: {
id: 'authoring.problemeditor.settings.scoring.label',
defaultMessage: 'Specify point weight and the number of answer attempts',
description: 'Descriptive text for scoring settings',
},
attemptsHint: {
id: 'authoring.problemeditor.settings.scoring.attempts.hint',
defaultMessage: 'If a default value is not set in advanced settings, unlimited attempts are allowed',
description: 'Summary text for scoring weight',
},
weightHint: {
id: 'authoring.problemeditor.settings.scoring.weight.hint',
defaultMessage: 'If a value is not set, the problem is worth one point',
description: 'Summary text for scoring weight',
},
showAnswerSettingsTitle: {
id: 'authoring.problemeditor.settings.showAnswer.title',
defaultMessage: 'Show answer',
description: 'Show Answer settings card title',
},
showAnswerAttemptsInputLabel: {
id: 'authoring.problemeditor.settings.showAnswer.attempts.inputLabel',
defaultMessage: 'Number of Attempts',
description: 'Show Answer attempts text input label',
},
showAnswerSettingText: {
id: 'authoring.problemeditor.settings.showAnswer.text',
defaultMessage: 'Define when learners can see the correct answer.',
description: 'Show Answer settings card text',
},
timerSettingsTitle: {
id: 'authoring.problemeditor.settings.timer.title',
defaultMessage: 'Time between attempts',
description: 'Timer settings card title',
},
timerSummary: {
id: 'authoring.problemeditor.settings.timer.summary',
defaultMessage: '{time} seconds',
description: 'Summary text for timer settings',
},
timerSettingText: {
id: 'authoring.problemeditor.settings.timer.text',
defaultMessage: 'Seconds a student must wait between submissions for a problem with multiple attempts.',
description: 'Timer settings card text',
},
timerInputLabel: {
id: 'authoring.problemeditor.settings.timer.inputLabel',
defaultMessage: 'Seconds',
description: 'Timer text input label',
},
typeSettingTitle: {
id: 'authoring.problemeditor.settings.type.title',
defaultMessage: 'Type',
description: 'Type settings card title',
},
SwitchButtonLabel: {
id: 'authoring.problemeditor.settings.switchtoadvancededitor.label',
defaultMessage: 'Switch to advanced editor',
description: 'button to switch to the advanced mode of the editor.',
},
ConfirmSwitchMessage: {
id: 'authoring.problemeditor.settings.switchtoadvancededitor.ConfirmSwitchMessage',
defaultMessage: 'If you use the advanced editor, this problem will be converted to OLX and you will not be able to return to the simple editor.',
description: 'message to confirm that a user wants to use the advanced editor',
},
ConfirmSwitchMessageTitle: {
id: 'authoring.problemeditor.settings.switchtoadvancededitor.ConfirmSwitchMessageTitle',
defaultMessage: 'Convert to OLX?',
description: 'message to confirm that a user wants to use the advanced editor',
},
ConfirmSwitchButtonLabel: {
id: 'authoring.problemeditor.settings.switchtoadvancededitor.ConfirmSwitchButtonLabel',
defaultMessage: 'Switch to advanced editor',
description: 'message to confirm that a user wants to use the advanced editor',
},
explanationInputLabel: {
id: 'authoring.problemeditor.settings.showAnswer.explanation.inputLabel',
defaultMessage: 'Explanation',
description: 'answer explanation input label',
},
explanationSettingText: {
id: 'authoring.problemeditor.settings.showAnswer.explanation.text',
defaultMessage: 'Provide an explanation for the correct answer.',
description: 'Solution Explanation text',
},
});
export default messages;

View File

@@ -0,0 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RandomizationCard snapshot snapshot: renders general feedback setting card 1`] = `
<SettingsOption
className=""
extraSections={[]}
hasExpandableTextArea={false}
none={false}
summary={
{
"defaultMessage": "sUmmary",
}
}
title="General Feedback"
>
<div
className="halfSpacedMessage"
>
<span>
<FormattedMessage
defaultMessage="Enter the feedback to appear when a student submits a wrong answer. This will be overridden if you add answer-specific feedback."
description="description for general feedback input, clariying useage"
id="authoring.problemeditor.settings.generalFeedbackInputDescription"
/>
</span>
</div>
<Form.Group>
<Form.Control
floatingLabel="Enter General Feedback"
onChange={[MockFunction randomizationCardHooks.handleChange]}
value="sOmE_vAlUE"
/>
</Form.Group>
</SettingsOption>
`;

View File

@@ -0,0 +1,37 @@
import { useState, useEffect } from 'react';
import _ from 'lodash-es';
import messages from './messages';
import * as module from './hooks';
export const state = {
// eslint-disable-next-line react-hooks/rules-of-hooks
summary: (val) => useState(val),
};
export const generalFeedbackHooks = (generalFeedback, updateSettings) => {
const [summary, setSummary] = module.state.summary({
message: messages.noGeneralFeedbackSummary, values: {}, intl: true,
});
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (_.isEmpty(generalFeedback)) {
setSummary({ message: messages.noGeneralFeedbackSummary, values: {}, intl: true });
} else {
setSummary({
message: generalFeedback,
values: {},
intl: false,
});
}
}, [generalFeedback]);
const handleChange = (event) => {
updateSettings({ generalFeedback: event.target.value });
};
return {
summary,
handleChange,
};
};

View File

@@ -0,0 +1,47 @@
import { useEffect } from 'react';
import { MockUseState } from '../../../../../../../../testUtils';
import messages from './messages';
import * as hooks from './hooks';
jest.mock('react', () => {
const updateState = jest.fn();
return {
updateState,
useEffect: jest.fn(),
useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])),
};
});
jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m,
}));
const state = new MockUseState(hooks);
describe('Problem settings hooks', () => {
let output;
let updateSettings;
let generalFeedback;
beforeEach(() => {
updateSettings = jest.fn();
generalFeedback = 'sOmE_vAlUe';
state.mock();
});
afterEach(() => {
state.restore();
useEffect.mockClear();
});
describe('Show advanced settings', () => {
beforeEach(() => {
output = hooks.generalFeedbackHooks(generalFeedback, updateSettings);
});
test('test default state is false', () => {
expect(output.summary.message).toEqual(messages.noGeneralFeedbackSummary);
});
test('test showAdvancedCards sets state to true', () => {
const mockEvent = { target: { value: 'sOmE_otheR_ValUe' } };
output.handleChange(mockEvent);
expect(updateSettings).toHaveBeenCalledWith({ generalFeedback: mockEvent.target.value });
});
});
});

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
import { Form } from '@openedx/paragon';
import PropTypes from 'prop-types';
import SettingsOption from '../../SettingsOption';
import messages from './messages';
import { generalFeedbackHooks } from './hooks';
export const GeneralFeedbackCard = ({
generalFeedback,
updateSettings,
// inject
intl,
}) => {
const { summary, handleChange } = generalFeedbackHooks(generalFeedback, updateSettings);
return (
<SettingsOption
title={intl.formatMessage(messages.generalFeebackSettingTitle)}
summary={summary.intl ? intl.formatMessage(summary.message) : summary.message}
none={!generalFeedback}
>
<div className="halfSpacedMessage">
<span>
<FormattedMessage {...messages.generalFeedbackDescription} />
</span>
</div>
<Form.Group>
<Form.Control
value={generalFeedback}
onChange={handleChange}
floatingLabel={intl.formatMessage(messages.generalFeedbackInputLabel)}
/>
</Form.Group>
</SettingsOption>
);
};
GeneralFeedbackCard.propTypes = {
generalFeedback: PropTypes.string.isRequired,
updateSettings: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(GeneralFeedbackCard);

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { formatMessage } from '../../../../../../../../testUtils';
import { GeneralFeedbackCard } from './index';
import { generalFeedbackHooks } from './hooks';
jest.mock('./hooks', () => ({
generalFeedbackHooks: jest.fn(),
}));
describe('RandomizationCard', () => {
const props = {
generalFeedback: 'sOmE_vAlUE',
updateSettings: jest.fn().mockName('args.updateSettings'),
intl: { formatMessage },
};
const randomizationCardHooksProps = {
summary: { message: { defaultMessage: 'sUmmary' } },
handleChange: jest.fn().mockName('randomizationCardHooks.handleChange'),
};
generalFeedbackHooks.mockReturnValue(randomizationCardHooksProps);
describe('behavior', () => {
it(' calls generalFeedbackHooks with props when initialized', () => {
shallow(<GeneralFeedbackCard {...props} />);
expect(generalFeedbackHooks).toHaveBeenCalledWith(props.generalFeedback, props.updateSettings);
});
});
describe('snapshot', () => {
test('snapshot: renders general feedback setting card', () => {
expect(shallow(<GeneralFeedbackCard {...props} />).snapshot).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,26 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
generalFeebackSettingTitle: {
id: 'authoring.problemeditor.settings.generalFeebackSettingTitle',
defaultMessage: 'General Feedback',
description: 'label for general feedback setting',
},
generalFeedbackInputLabel: {
id: 'authoring.problemeditor.settings.generalFeedbackInputLabel',
defaultMessage: 'Enter General Feedback',
description: 'label for general feedback input describing rules',
},
generalFeedbackDescription: {
id: 'authoring.problemeditor.settings.generalFeedbackInputDescription',
defaultMessage: 'Enter the feedback to appear when a student submits a wrong answer. This will be overridden if you add answer-specific feedback.',
description: 'description for general feedback input, clariying useage',
},
noGeneralFeedbackSummary: {
id: 'authoring.problemeditor.settings.generalFeedback.noFeedbackSummary',
defaultMessage: 'None',
description: 'message which informs use there is no general feedback set.',
},
});
export default messages;

View File

@@ -0,0 +1,79 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
ActionRow, Form, Icon, IconButton, Row,
} from '@openedx/paragon';
import { DeleteOutline } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import messages from '../../messages';
export const GroupFeedbackRow = ({
value,
handleAnswersSelectedChange,
handleFeedbackChange,
handleDelete,
answers,
// injected
intl,
}) => (
<div className="mb-4">
<ActionRow className="mb-2">
<Form.Control
value={value.feedback}
onChange={handleFeedbackChange}
/>
<div className="d-flex flex-row flex-nowrap">
<IconButton
src={DeleteOutline}
iconAs={Icon}
alt={intl.formatMessage(messages.settingsDeleteIconAltText)}
onClick={handleDelete}
variant="primary"
/>
</div>
</ActionRow>
<Form.CheckboxSet
onChange={handleAnswersSelectedChange}
value={value.answers}
>
<Row className="mx-0">
{answers.map((letter) => (
<Form.Checkbox
className="mr-4 mt-1"
value={letter.id}
checked={value.answers.indexOf(letter.id)}
isValid={value.answers.indexOf(letter.id) >= 0}
>
<div className="x-small">
{letter.id}
</div>
</Form.Checkbox>
))}
</Row>
</Form.CheckboxSet>
</div>
);
GroupFeedbackRow.propTypes = {
answers: PropTypes.arrayOf(PropTypes.shape({
correct: PropTypes.bool,
id: PropTypes.string,
selectedFeedback: PropTypes.string,
title: PropTypes.string,
unselectedFeedback: PropTypes.string,
})).isRequired,
handleAnswersSelectedChange: PropTypes.func.isRequired,
handleFeedbackChange: PropTypes.func.isRequired,
handleDelete: PropTypes.func.isRequired,
value: PropTypes.shape({
id: PropTypes.number.isRequired,
answers: PropTypes.arrayOf(PropTypes.string),
feedback: PropTypes.string,
}).isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(GroupFeedbackRow);

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { formatMessage } from '../../../../../../../../testUtils';
import { GroupFeedbackRow } from './GroupFeedbackRow';
jest.mock('@openedx/paragon', () => ({
...jest.requireActual('@openedx/paragon'),
Row: 'Row',
IconButton: 'IconButton',
Icon: 'Icon',
Form: {
CheckboxSet: 'Form.CheckboxSet',
Checkbox: 'Form.CheckboxSet',
Control: 'Form.Control',
},
ActionRow: 'ActionRow',
}));
jest.mock('@openedx/paragon/icons', () => ({
...jest.requireActual('@openedx/paragon/icons'),
DeleteOutline: 'DeleteOutline',
}));
describe('GroupFeedbackRow', () => {
const props = {
value: { answers: ['A', 'C'], feedback: 'sOmE FeEDBACK' },
answers: ['A', 'B', 'C', 'D'],
handleAnswersSelectedChange: jest.fn().mockName('handleAnswersSelectedChange'),
handleFeedbackChange: jest.fn().mockName('handleFeedbackChange'),
handleDelete: jest.fn().mockName('handleDelete'),
intl: { formatMessage },
};
describe('snapshot', () => {
test('snapshot: renders hints row', () => {
expect(shallow(<GroupFeedbackRow {...props} />).snapshot).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,77 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GroupFeedbackRow snapshot snapshot: renders hints row 1`] = `
<div
className="mb-4"
>
<ActionRow
className="mb-2"
>
<Form.Control
onChange={[MockFunction handleFeedbackChange]}
value="sOmE FeEDBACK"
/>
<div
className="d-flex flex-row flex-nowrap"
>
<IconButton
alt="Delete answer"
iconAs="Icon"
onClick={[MockFunction handleDelete]}
src="DeleteOutline"
variant="primary"
/>
</div>
</ActionRow>
<Form.CheckboxSet
onChange={[MockFunction handleAnswersSelectedChange]}
value={
[
"A",
"C",
]
}
>
<Row
className="mx-0"
>
<Form.CheckboxSet
checked={-1}
className="mr-4 mt-1"
isValid={false}
>
<div
className="x-small"
/>
</Form.CheckboxSet>
<Form.CheckboxSet
checked={-1}
className="mr-4 mt-1"
isValid={false}
>
<div
className="x-small"
/>
</Form.CheckboxSet>
<Form.CheckboxSet
checked={-1}
className="mr-4 mt-1"
isValid={false}
>
<div
className="x-small"
/>
</Form.CheckboxSet>
<Form.CheckboxSet
checked={-1}
className="mr-4 mt-1"
isValid={false}
>
<div
className="x-small"
/>
</Form.CheckboxSet>
</Row>
</Form.CheckboxSet>
</div>
`;

View File

@@ -0,0 +1,171 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HintsCard snapshot snapshot: renders groupFeedbacks setting card multiple groupFeedbacks 1`] = `
<SettingsOption
className=""
extraSections={[]}
hasExpandableTextArea={true}
none={false}
summary=""
title="Group Feedback"
>
<div
className="pb-3"
>
<FormattedMessage
defaultMessage="Group feedback will appear when a student selects a specific set of answers."
description="label for group feedback input"
id="authoring.problemeditor.settings.GroupFeedbackInputLabel"
/>
</div>
<injectIntl(ShimmedIntlComponent)
answers={
[
"A",
"B",
"C",
]
}
id={1}
key="1"
props="propsValue"
value={
{
"answers": [
"A",
"C",
],
"feedback": "sOmE FeEDBACK",
"id": 1,
"value": "groupFeedback1",
}
}
/>
<injectIntl(ShimmedIntlComponent)
answers={
[
"A",
"B",
"C",
]
}
id={2}
key="2"
props="propsValue"
value={
{
"answers": [
"A",
],
"feedback": "sOmE FeEDBACK oTher FeEdback",
"id": 2,
"value": "",
}
}
/>
<Button
className="m-0 p-0 font-weight-bold"
onClick={[MockFunction groupFeedbacksCardHooks.handleAdd]}
size="sm"
text={null}
variant="add"
>
<FormattedMessage
defaultMessage="Add group feedback"
description="addGroupFeedbackButtonText"
id="authoring.problemeditor.settings.addGroupFeedbackButtonText"
/>
</Button>
</SettingsOption>
`;
exports[`HintsCard snapshot snapshot: renders groupFeedbacks setting card no groupFeedbacks 1`] = `
<SettingsOption
className=""
extraSections={[]}
hasExpandableTextArea={true}
none={true}
summary="None"
title="Group Feedback"
>
<div
className="pb-3"
>
<FormattedMessage
defaultMessage="Group feedback will appear when a student selects a specific set of answers."
description="label for group feedback input"
id="authoring.problemeditor.settings.GroupFeedbackInputLabel"
/>
</div>
<Button
className="m-0 p-0 font-weight-bold"
onClick={[MockFunction groupFeedbacksCardHooks.handleAdd]}
size="sm"
text={null}
variant="add"
>
<FormattedMessage
defaultMessage="Add group feedback"
description="addGroupFeedbackButtonText"
id="authoring.problemeditor.settings.addGroupFeedbackButtonText"
/>
</Button>
</SettingsOption>
`;
exports[`HintsCard snapshot snapshot: renders groupFeedbacks setting card one groupFeedback 1`] = `
<SettingsOption
className=""
extraSections={[]}
hasExpandableTextArea={true}
none={false}
summary="groupFeedback1"
title="Group Feedback"
>
<div
className="pb-3"
>
<FormattedMessage
defaultMessage="Group feedback will appear when a student selects a specific set of answers."
description="label for group feedback input"
id="authoring.problemeditor.settings.GroupFeedbackInputLabel"
/>
</div>
<injectIntl(ShimmedIntlComponent)
answers={
[
"A",
"B",
"C",
]
}
id={1}
key="1"
props="propsValue"
value={
{
"answers": [
"A",
"C",
],
"feedback": "sOmE FeEDBACK",
"id": 1,
"value": "groupFeedback1",
}
}
/>
<Button
className="m-0 p-0 font-weight-bold"
onClick={[MockFunction groupFeedbacksCardHooks.handleAdd]}
size="sm"
text={null}
variant="add"
>
<FormattedMessage
defaultMessage="Add group feedback"
description="addGroupFeedbackButtonText"
id="authoring.problemeditor.settings.addGroupFeedbackButtonText"
/>
</Button>
</SettingsOption>
`;

View File

@@ -0,0 +1,97 @@
import { useState, useEffect } from 'react';
import _ from 'lodash-es';
import messages from './messages';
import * as module from './hooks';
export const state = {
// eslint-disable-next-line react-hooks/rules-of-hooks
summary: (val) => useState(val),
};
export const groupFeedbackCardHooks = (groupFeedbacks, updateSettings, answerslist) => {
const [summary, setSummary] = module.state.summary({ message: messages.noGroupFeedbackSummary, values: {} });
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (groupFeedbacks.length === 0) {
setSummary({ message: messages.noGroupFeedbackSummary, values: {} });
} else {
const feedbacksInList = groupFeedbacks.map(({ answers, feedback }) => {
const answerIDs = answerslist.map((a) => a.id);
const answersString = answers.filter((value) => answerIDs.includes(value));
return `${answersString} ${feedback}\n`;
});
setSummary({
message: messages.groupFeedbackSummary,
values: { groupFeedback: feedbacksInList },
});
}
}, [groupFeedbacks, answerslist]);
const handleAdd = () => {
let newId = 0;
if (!_.isEmpty(groupFeedbacks)) {
newId = Math.max(...groupFeedbacks.map(feedback => feedback.id)) + 1;
}
const groupFeedback = { id: newId, answers: [], feedback: '' };
const modifiedGroupFeedbacks = [...groupFeedbacks, groupFeedback];
updateSettings({ groupFeedbackList: modifiedGroupFeedbacks });
};
return {
summary,
handleAdd,
};
};
export const groupFeedbackRowHooks = ({ id, groupFeedbacks, updateSettings }) => {
// Hooks for the answers associated with a groupfeedback
const addSelectedAnswer = ({ value }) => {
const oldGroupFeedback = groupFeedbacks.find(x => x.id === id);
const newAnswers = [...oldGroupFeedback.answers, value];
const newFeedback = { ...oldGroupFeedback, answers: newAnswers };
const remainingFeedbacks = groupFeedbacks.filter((item) => (item.id !== id));
const updatedFeedbackList = [newFeedback, ...remainingFeedbacks].sort((a, b) => a.id - b.id);
updateSettings({ groupFeedbackList: updatedFeedbackList });
};
const removedSelectedAnswer = ({ value }) => {
const oldGroupFeedback = groupFeedbacks.find(x => x.id === id);
const newAnswers = oldGroupFeedback.answers.filter(item => item !== value);
const newFeedback = { ...oldGroupFeedback, answers: newAnswers };
const remainingFeedbacks = groupFeedbacks.filter((item) => (item.id !== id));
const updatedFeedbackList = [newFeedback, ...remainingFeedbacks].sort((a, b) => a.id - b.id);
updateSettings({ groupFeedbackList: updatedFeedbackList });
};
const handleAnswersSelectedChange = (event) => {
const { checked, value } = event.target;
if (checked) {
addSelectedAnswer({ value });
} else {
removedSelectedAnswer({ value });
}
};
// Delete Button
const handleDelete = () => {
const modifiedGroupFeedbacks = groupFeedbacks.filter((item) => (item.id !== id));
updateSettings({ groupFeedbackList: modifiedGroupFeedbacks });
};
// Hooks for the feedback associated with a groupfeedback
const handleFeedbackChange = (event) => {
const { value } = event.target;
const modifiedGroupFeedback = groupFeedbacks.map(groupFeedback => {
if (groupFeedback.id === id) {
return { ...groupFeedback, feedback: value };
}
return groupFeedback;
});
updateSettings({ groupFeedbackList: modifiedGroupFeedback });
};
return {
handleAnswersSelectedChange, handleFeedbackChange, handleDelete,
};
};

View File

@@ -0,0 +1,96 @@
import { useEffect } from 'react';
import { MockUseState } from '../../../../../../../../testUtils';
import messages from './messages';
import * as hooks from './hooks';
jest.mock('react', () => {
const updateState = jest.fn();
return {
updateState,
useEffect: jest.fn(),
useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])),
};
});
jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m,
}));
const state = new MockUseState(hooks);
describe('groupFeedbackCardHooks', () => {
let output;
let updateSettings;
let groupFeedbacks;
beforeEach(() => {
updateSettings = jest.fn();
groupFeedbacks = [];
state.mock();
});
afterEach(() => {
state.restore();
useEffect.mockClear();
});
describe('Show advanced settings', () => {
beforeEach(() => {
output = hooks.groupFeedbackCardHooks(groupFeedbacks, updateSettings);
});
test('test default state is false', () => {
expect(output.summary.message).toEqual(messages.noGroupFeedbackSummary);
});
test('test Event adds a new feedback ', () => {
output.handleAdd();
expect(updateSettings).toHaveBeenCalledWith({ groupFeedbackList: [{ id: 0, answers: [], feedback: '' }] });
});
});
});
describe('groupFeedbackRowHooks', () => {
const mockId = 'iD';
const mockAnswer = 'moCkAnsweR';
const mockFeedback = 'mOckFEEdback';
let groupFeedbacks;
let output;
let updateSettings;
beforeEach(() => {
updateSettings = jest.fn();
groupFeedbacks = [{ id: mockId, answers: [mockAnswer], feedback: mockFeedback }];
state.mock();
});
afterEach(() => {
state.restore();
useEffect.mockClear();
});
describe('Show advanced settings', () => {
beforeEach(() => {
output = hooks.groupFeedbackRowHooks({ id: mockId, groupFeedbacks, updateSettings });
});
test('test associate an answer with the feedback object', () => {
const mockNewAnswer = 'nEw VAluE';
output.handleAnswersSelectedChange({ target: { checked: true, value: mockNewAnswer } });
expect(updateSettings).toHaveBeenCalledWith(
{ groupFeedbackList: [{ id: mockId, answers: [mockAnswer, mockNewAnswer], feedback: mockFeedback }] },
);
});
test('test unassociate an answer with the feedback object', () => {
output.handleAnswersSelectedChange({ target: { checked: false, value: mockAnswer } });
expect(updateSettings).toHaveBeenCalledWith(
{ groupFeedbackList: [{ id: mockId, answers: [], feedback: mockFeedback }] },
);
});
test('test update feedback text with a groupfeedback', () => {
const mockNewFeedback = 'nEw fEedBack';
output.handleFeedbackChange({ target: { checked: false, value: mockNewFeedback } });
expect(updateSettings).toHaveBeenCalledWith(
{ groupFeedbackList: [{ id: mockId, answers: [mockAnswer], feedback: mockNewFeedback }] },
);
});
test('Delete a Row from the list of feedbacks', () => {
output.handleDelete();
expect(updateSettings).toHaveBeenCalledWith(
{ groupFeedbackList: [] },
);
});
});
});

View File

@@ -0,0 +1,66 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
import SettingsOption from '../../SettingsOption';
import messages from './messages';
import { groupFeedbackCardHooks, groupFeedbackRowHooks } from './hooks';
import GroupFeedbackRow from './GroupFeedbackRow';
import Button from '../../../../../../../sharedComponents/Button';
export const GroupFeedbackCard = ({
groupFeedbacks,
updateSettings,
answers,
// inject
intl,
}) => {
const { summary, handleAdd } = groupFeedbackCardHooks(groupFeedbacks, updateSettings, answers);
return (
<SettingsOption
title={intl.formatMessage(messages.groupFeedbackSettingTitle)}
summary={intl.formatMessage(summary.message, { ...summary.values })}
none={!groupFeedbacks.length}
hasExpandableTextArea
>
<div className="pb-3">
<FormattedMessage {...messages.groupFeedbackInputLabel} />
</div>
{groupFeedbacks.map((groupFeedback) => (
<GroupFeedbackRow
key={groupFeedback.id}
id={groupFeedback.id}
value={groupFeedback}
answers={answers}
{...groupFeedbackRowHooks({ id: groupFeedback.id, groupFeedbacks, updateSettings })}
/>
))}
<Button
className="m-0 p-0 font-weight-bold"
variant="add"
onClick={handleAdd}
size="sm"
>
<FormattedMessage {...messages.addGroupFeedbackButtonText} />
</Button>
</SettingsOption>
);
};
GroupFeedbackCard.propTypes = {
intl: intlShape.isRequired,
groupFeedbacks: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.number.isRequired,
feedback: PropTypes.string.isRequired,
answers: PropTypes.arrayOf(PropTypes.string).isRequired,
})).isRequired,
answers: PropTypes.arrayOf(PropTypes.shape({
correct: PropTypes.bool,
id: PropTypes.string,
selectedFeedback: PropTypes.string,
title: PropTypes.string,
unselectedFeedback: PropTypes.string,
})).isRequired,
updateSettings: PropTypes.func.isRequired,
};
export default injectIntl(GroupFeedbackCard);

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { formatMessage } from '../../../../../../../../testUtils';
import { GroupFeedbackCard } from './index';
import { groupFeedbackRowHooks, groupFeedbackCardHooks } from './hooks';
import messages from './messages';
jest.mock('./hooks', () => ({
groupFeedbackCardHooks: jest.fn(),
groupFeedbackRowHooks: jest.fn(),
}));
describe('HintsCard', () => {
const answers = ['A', 'B', 'C'];
const groupFeedback1 = {
id: 1, value: 'groupFeedback1', answers: ['A', 'C'], feedback: 'sOmE FeEDBACK',
};
const groupFeedback2 = {
id: 2, value: '', answers: ['A'], feedback: 'sOmE FeEDBACK oTher FeEdback',
};
const groupFeedbacks0 = [];
const groupFeedbacks1 = [groupFeedback1];
const groupFeedbacks2 = [groupFeedback1, groupFeedback2];
const props = {
intl: { formatMessage },
groupFeedbacks: groupFeedbacks0,
updateSettings: jest.fn().mockName('args.updateSettings'),
answers,
};
const groupFeedbacksRowHooksProps = { props: 'propsValue' };
groupFeedbackRowHooks.mockReturnValue(groupFeedbacksRowHooksProps);
describe('behavior', () => {
it(' calls groupFeedbacksCardHooks when initialized', () => {
const groupFeedbacksCardHooksProps = {
summary: { message: messages.noGroupFeedbackSummary },
handleAdd: jest.fn().mockName('groupFeedbacksCardHooks.handleAdd'),
};
groupFeedbackCardHooks.mockReturnValue(groupFeedbacksCardHooksProps);
shallow(<GroupFeedbackCard {...props} />);
expect(groupFeedbackCardHooks).toHaveBeenCalledWith(groupFeedbacks0, props.updateSettings, answers);
});
});
describe('snapshot', () => {
test('snapshot: renders groupFeedbacks setting card no groupFeedbacks', () => {
const groupFeedbacksCardHooksProps = {
summary: { message: messages.noGroupFeedbackSummary, values: {} },
handleAdd: jest.fn().mockName('groupFeedbacksCardHooks.handleAdd'),
};
groupFeedbackCardHooks.mockReturnValue(groupFeedbacksCardHooksProps);
expect(shallow(<GroupFeedbackCard {...props} />).snapshot).toMatchSnapshot();
});
test('snapshot: renders groupFeedbacks setting card one groupFeedback', () => {
const groupFeedbacksCardHooksProps = {
summary: {
message: messages.groupFeedbackSummary,
values: { groupFeedback: groupFeedback1.value, count: 1 },
},
handleAdd: jest.fn().mockName('groupFeedbacksCardHooks.handleAdd'),
};
groupFeedbackCardHooks.mockReturnValue(groupFeedbacksCardHooksProps);
expect(shallow(<GroupFeedbackCard {...props} groupFeedbacks={groupFeedbacks1} />).snapshot).toMatchSnapshot();
});
test('snapshot: renders groupFeedbacks setting card multiple groupFeedbacks', () => {
const groupFeedbacksCardHooksProps = {
summary: {
message: messages.groupFeedbackSummary,
values: { groupFeedback: groupFeedback2.value, count: 2 },
},
handleAdd: jest.fn().mockName('groupFeedbacksCardHooks.handleAdd'),
};
groupFeedbackCardHooks.mockReturnValue(groupFeedbacksCardHooksProps);
expect(shallow(<GroupFeedbackCard {...props} groupFeedbacks={groupFeedbacks2} />).snapshot).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,32 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
noGroupFeedbackSummary: {
id: 'authoring.problemeditor.settings.GroupFeedbackSummary.nonMessage',
defaultMessage: 'None',
description: 'message to confirm that a user wants to use the advanced editor',
},
groupFeedbackSummary: {
id: 'authoring.problemeditor.settings.GroupFeedbackSummary.message',
defaultMessage: '{groupFeedback}',
description: 'summary of current feedbacks provided for multiple problems',
},
addGroupFeedbackButtonText: {
id: 'authoring.problemeditor.settings.addGroupFeedbackButtonText',
defaultMessage: 'Add group feedback',
description: 'addGroupFeedbackButtonText',
},
groupFeedbackInputLabel: {
id: 'authoring.problemeditor.settings.GroupFeedbackInputLabel',
defaultMessage: 'Group feedback will appear when a student selects a specific set of answers.',
description: 'label for group feedback input',
},
groupFeedbackSettingTitle: {
id: 'authoring.problemeditor.settings.GroupFeedbackSettingTitle',
defaultMessage: 'Group Feedback',
description: 'label for group feedback setting',
},
});
export default messages;

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Container,
Icon,
IconButton,
} from '@openedx/paragon';
import { DeleteOutline } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import messages from '../messages';
import ExpandableTextArea from '../../../../../../sharedComponents/ExpandableTextArea';
export const HintRow = ({
value,
handleChange,
handleDelete,
id,
// injected
intl,
}) => (
<ActionRow className="mb-4">
<Container fluid className="p-0">
<ExpandableTextArea
value={value}
setContent={handleChange}
placeholder={intl.formatMessage(messages.hintInputLabel)}
id={`hint-${id}`}
/>
</Container>
<div className="d-flex flex-row flex-nowrap">
<IconButton
src={DeleteOutline}
iconAs={Icon}
alt={intl.formatMessage(messages.settingsDeleteIconAltText)}
onClick={handleDelete}
variant="primary"
/>
</div>
</ActionRow>
);
HintRow.propTypes = {
value: PropTypes.string.isRequired,
handleChange: PropTypes.func.isRequired,
handleDelete: PropTypes.func.isRequired,
id: PropTypes.string.isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(HintRow);

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { formatMessage } from '../../../../../../../testUtils';
import { HintRow } from './HintRow';
describe('HintRow', () => {
const props = {
value: 'hint_1',
handleChange: jest.fn(),
handleDelete: jest.fn(),
id: '0',
intl: { formatMessage },
};
describe('snapshot', () => {
test('snapshot: renders hints row', () => {
expect(shallow(<HintRow {...props} />).snapshot).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,59 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
import SettingsOption from '../SettingsOption';
import { ProblemTypeKeys } from '../../../../../../data/constants/problem';
import messages from '../messages';
import { hintsCardHooks, hintsRowHooks } from '../hooks';
import HintRow from './HintRow';
import Button from '../../../../../../sharedComponents/Button';
export const HintsCard = ({
hints,
problemType,
updateSettings,
// inject
intl,
}) => {
const { summary, handleAdd } = hintsCardHooks(hints, updateSettings);
if (problemType === ProblemTypeKeys.ADVANCED) { return null; }
return (
<SettingsOption
title={intl.formatMessage(messages.hintSettingTitle)}
summary={intl.formatMessage(summary.message, { ...summary.values })}
none={!hints.length}
hasExpandableTextArea
>
{hints.map((hint) => (
<HintRow
key={hint.id}
id={hint.id}
value={hint.value}
{...hintsRowHooks(hint.id, hints, updateSettings)}
/>
))}
<Button
className="m-0 p-0 font-weight-bold"
variant="add"
onClick={handleAdd}
size="sm"
>
<FormattedMessage {...messages.addHintButtonText} />
</Button>
</SettingsOption>
);
};
HintsCard.propTypes = {
intl: intlShape.isRequired,
hints: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
})).isRequired,
problemType: PropTypes.string.isRequired,
updateSettings: PropTypes.func.isRequired,
};
export default injectIntl(HintsCard);

View File

@@ -0,0 +1,79 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import { formatMessage } from '../../../../../../../testUtils';
import { HintsCard } from './HintsCard';
import { hintsCardHooks, hintsRowHooks } from '../hooks';
import messages from '../messages';
jest.mock('../hooks', () => ({
hintsCardHooks: jest.fn(),
hintsRowHooks: jest.fn(),
}));
describe('HintsCard', () => {
const hint1 = { id: 1, value: 'hint1' };
const hint2 = { id: 2, value: '' };
const hints0 = [];
const hints1 = [hint1];
const hints2 = [hint1, hint2];
const props = {
intl: { formatMessage },
hints: hints0,
updateSettings: jest.fn().mockName('args.updateSettings'),
};
const hintsRowHooksProps = {
handleChange: jest.fn().mockName('hintsRowHooks.handleChange'),
handleDelete: jest.fn().mockName('hintsRowHooks.handleDelete'),
};
hintsRowHooks.mockReturnValue(hintsRowHooksProps);
describe('behavior', () => {
it(' calls hintsCardHooks when initialized', () => {
const hintsCardHooksProps = {
summary: { message: messages.noHintSummary, values: {} },
handleAdd: jest.fn().mockName('hintsCardHooks.handleAdd'),
};
hintsCardHooks.mockReturnValue(hintsCardHooksProps);
shallow(<HintsCard {...props} />);
expect(hintsCardHooks).toHaveBeenCalledWith(hints0, props.updateSettings);
});
});
describe('snapshot', () => {
test('snapshot: renders hints setting card no hints', () => {
const hintsCardHooksProps = {
summary: { message: messages.noHintSummary, values: {} },
handleAdd: jest.fn().mockName('hintsCardHooks.handleAdd'),
};
hintsCardHooks.mockReturnValue(hintsCardHooksProps);
expect(shallow(<HintsCard {...props} />).snapshot).toMatchSnapshot();
});
test('snapshot: renders hints setting card one hint', () => {
const hintsCardHooksProps = {
summary: {
message: messages.hintSummary,
values: { hint: hint1.value, count: 1 },
},
handleAdd: jest.fn().mockName('hintsCardHooks.handleAdd'),
};
hintsCardHooks.mockReturnValue(hintsCardHooksProps);
expect(shallow(<HintsCard {...props} hints={hints1} />).snapshot).toMatchSnapshot();
});
test('snapshot: renders hints setting card multiple hints', () => {
const hintsCardHooksProps = {
summary: {
message: messages.hintSummary,
values: { hint: hint2.value, count: 2 },
},
handleAdd: jest.fn().mockName('hintsCardHooks.handleAdd'),
};
hintsCardHooks.mockReturnValue(hintsCardHooksProps);
expect(shallow(<HintsCard {...props} hints={hints2} />).snapshot).toMatchSnapshot();
});
});
});

Some files were not shown because too many files have changed in this diff Show More