feat: 'frontend-lib-content-components' into this repo
This commit is contained in:
68
src/editors/Editor.jsx
Normal file
68
src/editors/Editor.jsx
Normal 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;
|
||||
54
src/editors/Editor.test.jsx
Normal file
54
src/editors/Editor.test.jsx
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
58
src/editors/EditorPage.jsx
Normal file
58
src/editors/EditorPage.jsx
Normal 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;
|
||||
32
src/editors/EditorPage.test.jsx
Normal file
32
src/editors/EditorPage.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
13
src/editors/Placeholder.jsx
Normal file
13
src/editors/Placeholder.jsx
Normal 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;
|
||||
36
src/editors/Placeholder.test.jsx
Normal file
36
src/editors/Placeholder.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
36
src/editors/VideoSelector.jsx
Normal file
36
src/editors/VideoSelector.jsx
Normal 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;
|
||||
40
src/editors/VideoSelector.test.jsx
Normal file
40
src/editors/VideoSelector.test.jsx
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
47
src/editors/VideoSelectorPage.jsx
Normal file
47
src/editors/VideoSelectorPage.jsx
Normal 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;
|
||||
31
src/editors/VideoSelectorPage.test.jsx
Normal file
31
src/editors/VideoSelectorPage.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
36
src/editors/__snapshots__/Editor.test.jsx.snap
Normal file
36
src/editors/__snapshots__/Editor.test.jsx.snap
Normal 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>
|
||||
`;
|
||||
59
src/editors/__snapshots__/EditorPage.test.jsx.snap
Normal file
59
src/editors/__snapshots__/EditorPage.test.jsx.snap
Normal 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>
|
||||
`;
|
||||
3
src/editors/__snapshots__/VideoSelector.test.jsx.snap
Normal file
3
src/editors/__snapshots__/VideoSelector.test.jsx.snap
Normal file
@@ -0,0 +1,3 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Video Selector render rendering correctly with expected Input 1`] = `<VideoGallery />`;
|
||||
53
src/editors/__snapshots__/VideoSelectorPage.test.jsx.snap
Normal file
53
src/editors/__snapshots__/VideoSelectorPage.test.jsx.snap
Normal 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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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} />,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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..."`;
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
76
src/editors/containers/EditorContainer/hooks.js
Normal file
76
src/editors/containers/EditorContainer/hooks.js
Normal 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 })
|
||||
));
|
||||
149
src/editors/containers/EditorContainer/hooks.test.jsx
Normal file
149
src/editors/containers/EditorContainer/hooks.test.jsx
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
102
src/editors/containers/EditorContainer/index.jsx
Normal file
102
src/editors/containers/EditorContainer/index.jsx
Normal 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);
|
||||
6
src/editors/containers/EditorContainer/index.scss
Normal file
6
src/editors/containers/EditorContainer/index.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
// fix double scrollbars
|
||||
.editor-container {
|
||||
.pgn__modal-body {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
67
src/editors/containers/EditorContainer/index.test.jsx
Normal file
67
src/editors/containers/EditorContainer/index.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
22
src/editors/containers/EditorContainer/messages.js
Normal file
22
src/editors/containers/EditorContainer/messages.js
Normal 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;
|
||||
102
src/editors/containers/GameEditor/index.jsx
Normal file
102
src/editors/containers/GameEditor/index.jsx
Normal 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));
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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)));
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as FeedbackBox } from './FeedbackBox';
|
||||
export { default as FeedbackControl } from './FeedbackControl';
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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',
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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: [] },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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
Reference in New Issue
Block a user