Merge branch 'main' of https://github.com/openedx/frontend-lib-content-components into mashal-m/react-upgrade-to-v17
This commit is contained in:
@@ -7,9 +7,10 @@ const config = createConfig('eslint', {
|
||||
'import/no-named-as-default-member': 'off',
|
||||
'import/no-self-import': 'off',
|
||||
'spaced-comment': ['error', 'always', { block: { exceptions: ['*'] } }],
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'react-hooks/rules-of-hooks': 2,
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'no-promise-executor-return': 'off',
|
||||
'no-param-reassign': ['error', { props: false }],
|
||||
radix: 'off',
|
||||
},
|
||||
});
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -44,8 +44,8 @@
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "^1.1.1",
|
||||
"@edx/frontend-build": "12.8.27",
|
||||
"@edx/frontend-platform": "^4.6.0",
|
||||
"@edx/paragon": "^20.44.0",
|
||||
"@edx/frontend-platform": "4.2.0",
|
||||
"@edx/paragon": "^20.45.0",
|
||||
"@edx/reactifex": "^2.1.1",
|
||||
"@testing-library/dom": "^8.13.0",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
@@ -2758,9 +2758,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/paragon": {
|
||||
"version": "20.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.44.0.tgz",
|
||||
"integrity": "sha512-C1uC3RaRmlFANtHebFdZzVDM08vgFJRnHE3u97ix07e0ACSQDbVNoZ2H7JgBy8nqHz2JWGHPnvtpvPf5DAZsZQ==",
|
||||
"version": "20.45.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.45.0.tgz",
|
||||
"integrity": "sha512-9lHcnSJ36sQ+bsYFhydf/Pvp3Bo5N3go8R3ORPTNtvYnwiKSfjlv11QpURC/xHobXsT2eYHiwl2gNmq1yP09BA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "^1.1.1",
|
||||
"@edx/frontend-build": "12.8.27",
|
||||
"@edx/frontend-platform": "^4.6.0",
|
||||
"@edx/paragon": "^20.44.0",
|
||||
"@edx/frontend-platform": "4.2.0",
|
||||
"@edx/paragon": "^20.45.0",
|
||||
"@edx/reactifex": "^2.1.1",
|
||||
"@testing-library/dom": "^8.13.0",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
|
||||
@@ -9,14 +9,7 @@ exports[`EditorContainer component render snapshot: initialized. enable save and
|
||||
close={[MockFunction closeCancelConfirmModal]}
|
||||
confirmAction={
|
||||
<Button
|
||||
onClick={
|
||||
Object {
|
||||
"handleCancel": Object {
|
||||
"onClose": [MockFunction props.onClose],
|
||||
"returnFunction": [MockFunction props.returnFunction],
|
||||
},
|
||||
}
|
||||
}
|
||||
onClick={[Function]}
|
||||
variant="primary"
|
||||
>
|
||||
<FormattedMessage
|
||||
@@ -92,14 +85,7 @@ exports[`EditorContainer component render snapshot: not initialized. disable sav
|
||||
close={[MockFunction closeCancelConfirmModal]}
|
||||
confirmAction={
|
||||
<Button
|
||||
onClick={
|
||||
Object {
|
||||
"handleCancel": Object {
|
||||
"onClose": [MockFunction props.onClose],
|
||||
"returnFunction": [MockFunction props.returnFunction],
|
||||
},
|
||||
}
|
||||
}
|
||||
onClick={[Function]}
|
||||
variant="primary"
|
||||
>
|
||||
<FormattedMessage
|
||||
|
||||
@@ -8,11 +8,13 @@ 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,
|
||||
@@ -22,6 +24,7 @@ export const hooks = {
|
||||
},
|
||||
|
||||
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 {
|
||||
|
||||
@@ -17,7 +17,9 @@ export const TitleHeader = ({
|
||||
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 {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const {
|
||||
} = appHooks;
|
||||
|
||||
export const state = StrictDict({
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
isCancelConfirmModalOpen: (val) => useState(val),
|
||||
});
|
||||
|
||||
@@ -25,7 +26,9 @@ export const handleSaveClicked = ({
|
||||
validateEntry,
|
||||
returnFunction,
|
||||
}) => {
|
||||
const destination = useSelector(selectors.app.returnUrl);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const destination = returnFunction ? '' : useSelector(selectors.app.returnUrl);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const analytics = useSelector(selectors.app.analytics);
|
||||
|
||||
return () => saveBlock({
|
||||
@@ -53,14 +56,18 @@ export const handleCancel = ({ onClose, returnFunction }) => {
|
||||
}
|
||||
return navigateCallback({
|
||||
returnFunction,
|
||||
destination: useSelector(selectors.app.returnUrl),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
destination: returnFunction ? '' : useSelector(selectors.app.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 })
|
||||
));
|
||||
|
||||
@@ -36,7 +36,12 @@ export const EditorContainer = ({
|
||||
confirmAction={(
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCancel}
|
||||
onClick={() => {
|
||||
handleCancel();
|
||||
if (returnFunction) {
|
||||
closeCancelConfirmModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormattedMessage {...messages.okButtonLabel} />
|
||||
</Button>
|
||||
|
||||
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 '@edx/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));
|
||||
@@ -18,7 +18,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
getLocale: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./AnswerOption', () => function () {
|
||||
jest.mock('./AnswerOption', () => function mockAnswerOption() {
|
||||
return <div>MockAnswerOption</div>;
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ exports[`AnswersContainer render snapshot: numeric problems: answer range/answer
|
||||
<div
|
||||
className="answers-container border border-light-700 rounded py-4 pl-4 pr-3"
|
||||
>
|
||||
<Component
|
||||
<mockAnswerOption
|
||||
answer={
|
||||
Object {
|
||||
"correct": true,
|
||||
@@ -108,7 +108,7 @@ exports[`AnswersContainer render snapshot: numeric problems: answer range/answer
|
||||
<div
|
||||
className="answers-container border border-light-700 rounded py-4 pl-4 pr-3"
|
||||
>
|
||||
<Component
|
||||
<mockAnswerOption
|
||||
answer={
|
||||
Object {
|
||||
"correct": true,
|
||||
@@ -122,7 +122,7 @@ exports[`AnswersContainer render snapshot: numeric problems: answer range/answer
|
||||
hasSingleAnswer={false}
|
||||
key="A"
|
||||
/>
|
||||
<Component
|
||||
<mockAnswerOption
|
||||
answer={
|
||||
Object {
|
||||
"correct": true,
|
||||
@@ -200,7 +200,7 @@ exports[`AnswersContainer render snapshot: renders correctly with answers 1`] =
|
||||
<div
|
||||
className="answers-container border border-light-700 rounded py-4 pl-4 pr-3"
|
||||
>
|
||||
<Component
|
||||
<mockAnswerOption
|
||||
answer={
|
||||
Object {
|
||||
"correct": true,
|
||||
@@ -211,7 +211,7 @@ exports[`AnswersContainer render snapshot: renders correctly with answers 1`] =
|
||||
hasSingleAnswer={false}
|
||||
key="a"
|
||||
/>
|
||||
<Component
|
||||
<mockAnswerOption
|
||||
answer={
|
||||
Object {
|
||||
"correct": true,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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),
|
||||
});
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ exports[`SolutionWidget render snapshot: renders correct default 1`] = `
|
||||
/>
|
||||
</div>
|
||||
<[object Object]
|
||||
editorContentHtml="This is my question"
|
||||
editorType="solution"
|
||||
id="solution"
|
||||
minHeight={150}
|
||||
placeholder="Enter your explanation"
|
||||
setEditorRef={[MockFunction prepareEditorRef.setEditorRef]}
|
||||
textValue="This is my question"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -28,7 +28,7 @@ export const ExplanationWidget = ({
|
||||
id="solution"
|
||||
editorType="solution"
|
||||
editorRef={editorRef}
|
||||
textValue={settings?.solutionExplanation}
|
||||
editorContentHtml={settings?.solutionExplanation}
|
||||
setEditorRef={setEditorRef}
|
||||
minHeight={150}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
|
||||
@@ -14,12 +14,12 @@ exports[`QuestionWidget render snapshot: renders correct default 1`] = `
|
||||
/>
|
||||
</div>
|
||||
<[object Object]
|
||||
editorContentHtml="This is my question"
|
||||
editorType="question"
|
||||
id="question"
|
||||
minHeight={150}
|
||||
placeholder="Enter your question"
|
||||
setEditorRef={[MockFunction prepareEditorRef.setEditorRef]}
|
||||
textValue="This is my question"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -25,7 +25,7 @@ export const QuestionWidget = ({
|
||||
id="question"
|
||||
editorType="question"
|
||||
editorRef={editorRef}
|
||||
textValue={question}
|
||||
editorContentHtml={question}
|
||||
setEditorRef={setEditorRef}
|
||||
minHeight={150}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
|
||||
@@ -12,10 +12,15 @@ import {
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -44,6 +49,7 @@ export const showFullCard = (hasExpandableTextArea) => {
|
||||
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) {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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),
|
||||
};
|
||||
|
||||
@@ -12,6 +13,7 @@ export const generalFeedbackHooks = (generalFeedback, updateSettings) => {
|
||||
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 });
|
||||
|
||||
@@ -4,12 +4,14 @@ 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: {} });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RandomizationTypes, RandomizationTypesKeys } from '../../../../../../..
|
||||
import * as module from './hooks';
|
||||
|
||||
export const state = {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
summary: (val) => useState(val),
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line react/prop-types
|
||||
jest.mock('../../SettingsOption', () => function ({ children, summary }) {
|
||||
jest.mock('../../SettingsOption', () => function mockSettingsOption({ children, summary }) {
|
||||
return <div className="SettingsOption" data-testid="Settings-Option">{summary}{children}</div>;
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setAssetToStaticUrl } from '../../../../sharedComponents/TinyMceWidget/
|
||||
import { ProblemTypeKeys } from '../../../../data/constants/problem';
|
||||
|
||||
export const state = StrictDict({
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
isSaveWarningModalOpen: (val) => useState(val),
|
||||
});
|
||||
|
||||
@@ -123,7 +124,7 @@ export const checkForSettingDiscrepancy = ({ problem, ref, openSaveWarningModal
|
||||
const problemSettings = reactSettingsParser.getSettings();
|
||||
const rawOlxSettings = reactSettingsParser.parseRawOlxSettings();
|
||||
let isMismatched = false;
|
||||
// console.log(rawOlxSettings);
|
||||
|
||||
Object.entries(rawOlxSettings).forEach(([key, value]) => {
|
||||
if (value !== problemSettings[key]) {
|
||||
isMismatched = true;
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as module from './hooks';
|
||||
import { getDataFromOlx } from '../../../../data/redux/thunkActions/problem';
|
||||
|
||||
export const state = StrictDict({
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
selected: (val) => useState(val),
|
||||
});
|
||||
|
||||
|
||||
@@ -28,18 +28,6 @@ export const nonQuestionKeys = [
|
||||
'textline',
|
||||
];
|
||||
|
||||
export const richTextFormats = [
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'div',
|
||||
'p',
|
||||
'pre',
|
||||
];
|
||||
|
||||
export const responseKeys = [
|
||||
'multiplechoiceresponse',
|
||||
'numericalresponse',
|
||||
@@ -480,12 +468,13 @@ export class OLXParser {
|
||||
}
|
||||
questionArray.push(tag);
|
||||
} else if (responseKeys.includes(tagName)) {
|
||||
/* <label> and <description> tags often are both valid olx as siblings or children of response type tags.
|
||||
They, however, do belong in the question, so we append them to the question.
|
||||
*/
|
||||
/* Tags that are not used for other parts of the question such as <solution> or <choicegroup>
|
||||
should be included in the question. These include but are not limited to tags like <label>,
|
||||
<description> and <table> as they often are both valid olx as siblings or children of response
|
||||
type tags. */
|
||||
tag[tagName].forEach(subTag => {
|
||||
const subTagName = Object.keys(subTag)[0];
|
||||
if (subTagName === 'label' || subTagName === 'description' || richTextFormats.includes(subTagName)) {
|
||||
if (!nonQuestionKeys.includes(subTagName)) {
|
||||
questionArray.push(subTag);
|
||||
}
|
||||
});
|
||||
@@ -540,7 +529,15 @@ export class OLXParser {
|
||||
const solutionArray = [];
|
||||
if (divBody && divBody.div) {
|
||||
divBody.div.forEach(tag => {
|
||||
if (_.get(Object.values(tag)[0][0], '#text', null) !== 'Explanation') {
|
||||
const tagText = _.get(Object.values(tag)[0][0], '#text', '');
|
||||
if (tagText.toString().trim() !== 'Explanation') {
|
||||
solutionArray.push(tag);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
solutionBody.solution.forEach(tag => {
|
||||
const tagText = _.get(Object.values(tag)[0][0], '#text', '');
|
||||
if (tagText.toString().trim() !== 'Explanation') {
|
||||
solutionArray.push(tag);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
htmlEntityTestOLX,
|
||||
numberParseTestOLX,
|
||||
solutionExplanationTest,
|
||||
solutionExplanationWithoutDivTest,
|
||||
tablesInRichTextTest,
|
||||
parseOutExplanationTests,
|
||||
} from './mockData/olxTestData';
|
||||
import { ProblemTypeKeys } from '../../../data/constants/problem';
|
||||
|
||||
@@ -294,6 +297,14 @@ describe('OLXParser', () => {
|
||||
expect(question.trim()).toBe(labelDescriptionQuestionOLX.question);
|
||||
});
|
||||
});
|
||||
describe('given olx with table tags', () => {
|
||||
const olxparser = new OLXParser(tablesInRichTextTest.rawOLX);
|
||||
const problemType = olxparser.getProblemType();
|
||||
const question = olxparser.parseQuestions(problemType);
|
||||
it('should append the table to the question', () => {
|
||||
expect(question.trim()).toBe(tablesInRichTextTest.question);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('getSolutionExplanation()', () => {
|
||||
describe('for checkbox questions', () => {
|
||||
@@ -311,5 +322,17 @@ describe('OLXParser', () => {
|
||||
const explanation = olxparser.getSolutionExplanation(problemType);
|
||||
expect(explanation).toBe(solutionExplanationTest.solutionExplanation);
|
||||
});
|
||||
it('should parse solution fields without div', () => {
|
||||
const olxparser = new OLXParser(solutionExplanationWithoutDivTest.rawOLX);
|
||||
const problemType = olxparser.getProblemType();
|
||||
const explanation = olxparser.getSolutionExplanation(problemType);
|
||||
expect(explanation).toBe(solutionExplanationWithoutDivTest.solutionExplanation);
|
||||
});
|
||||
it('should parse out <p>Explanation</p>', () => {
|
||||
const olxparser = new OLXParser(parseOutExplanationTests.rawOLX);
|
||||
const problemType = olxparser.getProblemType();
|
||||
const explanation = olxparser.getSolutionExplanation(problemType);
|
||||
expect(explanation).toBe(parseOutExplanationTests.solutionExplanation);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -539,7 +539,9 @@ export const textInputWithFeedbackAndHintsOLX = {
|
||||
},
|
||||
},
|
||||
},
|
||||
question: '<p>You can use this template as a guide to the simple editor markdown and OLX markup to use for text input with hints and feedback problems. Edit this component to replace this template with your own assessment.</p><label>Add the question text, or prompt, here. This text is required.</label><em>You can add an optional tip or note related to the prompt like this. </em>',
|
||||
question: `<p>You can use this template as a guide to the simple editor markdown and OLX markup to use for text input with hints and feedback problems. Edit this component to replace this template with your own assessment.</p>
|
||||
<label>Add the question text, or prompt, here. This text is required.</label>
|
||||
<em>You can add an optional tip or note related to the prompt like this. </em>`,
|
||||
buildOLX: `<problem>
|
||||
<stringresponse answer="the correct answer" type="ci">
|
||||
<p>You can use this template as a guide to the simple editor markdown and OLX markup to use for text input with hints and feedback problems. Edit this component to replace this template with your own assessment.</p>
|
||||
@@ -769,7 +771,9 @@ export const labelDescriptionQuestionOLX = {
|
||||
</problem>`,
|
||||
|
||||
question: `<p style="text-align: center;"><img height="274" width="" src="/static/boiling_eggs_water_system.png" alt="boiling eggs: water system"></img></p>
|
||||
<label>Taking the system as just the <b>water</b>, as indicated by the red dashed line, what would be the correct expression for the first law of thermodynamics applied to this system?</label><em>Watch out, boiling water is hot</em>`,
|
||||
|
||||
<label>Taking the system as just the <b>water</b>, as indicated by the red dashed line, what would be the correct expression for the first law of thermodynamics applied to this system?</label>
|
||||
<em>Watch out, boiling water is hot</em>`,
|
||||
};
|
||||
|
||||
export const htmlEntityTestOLX = {
|
||||
@@ -804,7 +808,8 @@ export const htmlEntityTestOLX = {
|
||||
},
|
||||
],
|
||||
},
|
||||
question: `<p>What is the content of the register x2 after executing the following three lines of instructions?</p><p><span style="font-family: 'courier new', courier;"><strong>Address          assembly instructions <br></br>0x0              addi x1, x0, 1<br></br>0x4              slli x2, x1, 4<br></br>0x8              sub x1, x2, x1</strong></span></p>`,
|
||||
question: `<p>What is the content of the register x2 after executing the following three lines of instructions?</p>
|
||||
<p><span style="font-family: 'courier new', courier;"><strong>Address          assembly instructions <br></br>0x0              addi x1, x0, 1<br></br>0x4              slli x2, x1, 4<br></br>0x8              sub x1, x2, x1</strong></span></p>`,
|
||||
solutionExplanation: `<p><span style="font-family: 'courier new', courier;"><strong>Address          assembly instructions    comment<br></br>0x0              addi x1, x0, 1           x1 = 0x1<br></br>0x4              slli x2, x1, 4           x2 = x1 << 4 = 0x10<br></br>0x8              sub x1, x2, x1           x1 = x2 - x1 = 0x10 - 0x01 = 0xf</strong></span></p>`,
|
||||
};
|
||||
|
||||
@@ -880,3 +885,230 @@ export const solutionExplanationTest = {
|
||||
solutionExplanation: `\n
|
||||
This loop will iterate <code class="lang-matlab">99</code> times, but the length of <code class="lang-matlab">q</code> will not be <code class="lang-matlab">99</code> due to indexing with the value <code class="lang-matlab">2*i -1</code>. On the last iteration, <code class="lang-matlab">i = 99</code>, so <code class="lang-matlab">2*i - 1 = 2*78 - 1 = 197</code>. This will be the last position filled in <code class="lang-matlab">q</code>, so the answer is <code class="lang-matlab">197</code>.\n `,
|
||||
};
|
||||
|
||||
export const solutionExplanationWithoutDivTest = {
|
||||
rawOLX: `<problem display_name="For loop" markdown="null" max_attempts="3" showanswer="answered" weight="0.0">
|
||||
<multiplechoiceresponse>
|
||||
<p>Considering a list z=[8,12,2,9,7] and the following for loop:</p>
|
||||
<b>
|
||||
<p>for i in z: </p>
|
||||
<p> y=i+1 </p>
|
||||
<p> print(y)</p>
|
||||
</b>
|
||||
<label>What would be the result of running this code?</label>
|
||||
<description>Select the correct answer </description>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">8 </choice>
|
||||
<choice correct="false">[9,13,3,10,8] </choice>
|
||||
<choice correct="true">9<br>
|
||||
13</br>
|
||||
3
|
||||
<br>10</br>
|
||||
8 </choice>
|
||||
</choicegroup>
|
||||
<solution>
|
||||
<p/>
|
||||
<img src="https://courses.edx.org/asset-v1:MITx+CTL.SC0x+1T2023+type@asset+block@Screenshot_2022-12-19_205625.png"/>
|
||||
<p>How would you adjust your code to get the other results? We encourage you to try different for loops and share them in the discussion forum.</p>
|
||||
</solution>
|
||||
</multiplechoiceresponse>
|
||||
</problem>`,
|
||||
solutionExplanation: `
|
||||
<p></p>
|
||||
<img src="https://courses.edx.org/asset-v1:MITx+CTL.SC0x+1T2023+type@asset+block@Screenshot_2022-12-19_205625.png"></img>
|
||||
<p>How would you adjust your code to get the other results? We encourage you to try different for loops and share them in the discussion forum.</p>
|
||||
`,
|
||||
};
|
||||
|
||||
export const tablesInRichTextTest = {
|
||||
rawOLX: `<problem>
|
||||
<choiceresponse>
|
||||
<p>
|
||||
The table shows the number of protein-coding genes, chromosomes, and bases in a range of eukaryotic species.
|
||||
</p>
|
||||
<table class="chart" summary="A list of eukaryotic organisms and their genome contents." label="Four columns. The first column lists the species, the second column lists the number of protein-coding genes, the third column lists the number of chromosomes, and the fourth column lists the genome size in bases." width="100%">
|
||||
<caption>Eukaryotic Genomes Comparison</caption>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="col" style="text-align: center; border: 1px solid black;">
|
||||
<b>Species</b>
|
||||
</th>
|
||||
<th scope="col" style="text-align: center; border: 1px solid black;">
|
||||
<b>Protein-coding genes</b>
|
||||
</th>
|
||||
<th scope="col" style="text-align: center; border: 1px solid black;">
|
||||
<b>Chromosomes</b>
|
||||
</th>
|
||||
<th scope="col" style="text-align: center; border: 1px solid black;">
|
||||
<b>Bases</b>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; border: 1px solid black;">Yeast (<i>S. cerevisiae</i>)</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~5,800</td>
|
||||
<td style="text-align: center; border: 1px solid black;">16</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~12 Mb</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; border: 1px solid black;">Arabidopsis (<i>A. thaliana</i>)</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~27,000</td>
|
||||
<td style="text-align: center; border: 1px solid black;">5</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~115 Mb</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; border: 1px solid black;">Rice (<i>O. sativa</i>)</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~41,000</td>
|
||||
<td style="text-align: center; border: 1px solid black;">12</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~390 Mb</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; border: 1px solid black;">Worm (<i>C. elegans</i>)</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~19,000</td>
|
||||
<td style="text-align: center; border: 1px solid black;">6</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~100 Mb</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; border: 1px solid black;">Fly (<i>D. melanogaster</i>)</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~14,000</td>
|
||||
<td style="text-align: center; border: 1px solid black;">4</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~165 Mb</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; border: 1px solid black;">Mouse (<i>M. musculus</i>)</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~23,000</td>
|
||||
<td style="text-align: center; border: 1px solid black;">20</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~3 Gb</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; border: 1px solid black;">Human (<i>H. sapiens</i>)</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~21,000</td>
|
||||
<td style="text-align: center; border: 1px solid black;">23</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~3 Gb</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
In which of the following observations does the C-value paradox apply? Select all that apply.
|
||||
</p>
|
||||
<checkboxgroup>
|
||||
<choice correct="false">
|
||||
<div>
|
||||
Rice has a larger genome and more genes than <i>Arabidopsis</i>.
|
||||
</div>
|
||||
</choice>
|
||||
<choice correct="true">
|
||||
<div>
|
||||
Humans have a larger genome but fewer genes than <i>Arabidopsis</i>.
|
||||
</div>
|
||||
</choice>
|
||||
<choice correct="true">
|
||||
<div>
|
||||
Worms have a smaller genome but more genes than flies.
|
||||
</div>
|
||||
</choice>
|
||||
</checkboxgroup>
|
||||
<solution>
|
||||
<div class="detailed-solution">
|
||||
<p>
|
||||
Explanation
|
||||
</p>
|
||||
<p>
|
||||
Explanation
|
||||
</p>
|
||||
<p dir="ltr" id="docs-internal-guid-8b7482ab-7fff-27bd-79c5-a433e43a95ea">
|
||||
The C-value paradox states that there is no relation between size of genome and number of genes. In the comparison between rice and <i>Arabidopsis</i>, the size of the rice genome is both larger and contains a greater number of genes than <i>Arabidopsis</i>, so the C-value paradox does not apply here. In the remaining options, the larger genomes have fewer genes.
|
||||
</p>
|
||||
</div>
|
||||
</solution>
|
||||
</choiceresponse>
|
||||
</problem>`,
|
||||
question: `<p>
|
||||
The table shows the number of protein-coding genes, chromosomes, and bases in a range of eukaryotic species.
|
||||
</p>
|
||||
<table class="chart" summary="A list of eukaryotic organisms and their genome contents." label="Four columns. The first column lists the species, the second column lists the number of protein-coding genes, the third column lists the number of chromosomes, and the fourth column lists the genome size in bases." width="100%">
|
||||
<caption>Eukaryotic Genomes Comparison</caption>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="col" style="text-align: center; border: 1px solid black;">
|
||||
<b>Species</b>
|
||||
</th>
|
||||
<th scope="col" style="text-align: center; border: 1px solid black;">
|
||||
<b>Protein-coding genes</b>
|
||||
</th>
|
||||
<th scope="col" style="text-align: center; border: 1px solid black;">
|
||||
<b>Chromosomes</b>
|
||||
</th>
|
||||
<th scope="col" style="text-align: center; border: 1px solid black;">
|
||||
<b>Bases</b>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; border: 1px solid black;">Yeast (<i>S. cerevisiae</i>)</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~5,800</td>
|
||||
<td style="text-align: center; border: 1px solid black;">16</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~12 Mb</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; border: 1px solid black;">Arabidopsis (<i>A. thaliana</i>)</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~27,000</td>
|
||||
<td style="text-align: center; border: 1px solid black;">5</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~115 Mb</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; border: 1px solid black;">Rice (<i>O. sativa</i>)</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~41,000</td>
|
||||
<td style="text-align: center; border: 1px solid black;">12</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~390 Mb</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; border: 1px solid black;">Worm (<i>C. elegans</i>)</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~19,000</td>
|
||||
<td style="text-align: center; border: 1px solid black;">6</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~100 Mb</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; border: 1px solid black;">Fly (<i>D. melanogaster</i>)</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~14,000</td>
|
||||
<td style="text-align: center; border: 1px solid black;">4</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~165 Mb</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; border: 1px solid black;">Mouse (<i>M. musculus</i>)</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~23,000</td>
|
||||
<td style="text-align: center; border: 1px solid black;">20</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~3 Gb</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; border: 1px solid black;">Human (<i>H. sapiens</i>)</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~21,000</td>
|
||||
<td style="text-align: center; border: 1px solid black;">23</td>
|
||||
<td style="text-align: center; border: 1px solid black;">~3 Gb</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
In which of the following observations does the C-value paradox apply? Select all that apply.
|
||||
</p>`,
|
||||
};
|
||||
|
||||
export const parseOutExplanationTests = {
|
||||
rawOLX: `<problem>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup>
|
||||
</choicegroup>
|
||||
<solution>
|
||||
<p>Explanation</p>
|
||||
<p>
|
||||
Explanation
|
||||
</p>
|
||||
<p>solution meat</p>
|
||||
</solution>
|
||||
</multiplechoiceresponse>
|
||||
<demandhint></demandhint>
|
||||
</problem>`,
|
||||
solutionExplanation: `
|
||||
|
||||
|
||||
<p>solution meat</p>
|
||||
`
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
|
||||
/>
|
||||
</Toast>
|
||||
<[object Object]
|
||||
editorContentHtml="eDiTablE Text"
|
||||
editorRef={
|
||||
Object {
|
||||
"current": Object {
|
||||
@@ -48,7 +49,6 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
|
||||
initializeEditor={[MockFunction args.intializeEditor]}
|
||||
minHeight={500}
|
||||
setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]}
|
||||
textValue="eDiTablE Text"
|
||||
/>
|
||||
</div>
|
||||
</EditorContainer>
|
||||
@@ -194,6 +194,7 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
|
||||
/>
|
||||
</Toast>
|
||||
<[object Object]
|
||||
editorContentHtml="eDiTablE Text"
|
||||
editorRef={
|
||||
Object {
|
||||
"current": Object {
|
||||
@@ -206,7 +207,6 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
|
||||
initializeEditor={[MockFunction args.intializeEditor]}
|
||||
minHeight={500}
|
||||
setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]}
|
||||
textValue="eDiTablE Text"
|
||||
/>
|
||||
</div>
|
||||
</EditorContainer>
|
||||
|
||||
@@ -48,7 +48,7 @@ export const TextEditor = ({
|
||||
<TinyMceWidget
|
||||
editorType="text"
|
||||
editorRef={editorRef}
|
||||
textValue={blockValue ? blockValue.data.data : ''}
|
||||
editorContentHtml={blockValue ? blockValue.data.data : ''}
|
||||
setEditorRef={setEditorRef}
|
||||
minHeight={500}
|
||||
height="100%"
|
||||
|
||||
@@ -10,7 +10,9 @@ import * as module from './SelectVideoModal';
|
||||
|
||||
export const hooks = {
|
||||
videoList: ({ fetchVideos }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [videos, setVideos] = React.useState(null);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
React.useEffect(() => {
|
||||
fetchVideos({ onSuccess: setVideos });
|
||||
}, []);
|
||||
|
||||
@@ -13,12 +13,15 @@ export const {
|
||||
|
||||
export const hooks = {
|
||||
initialize: (dispatch, selectedVideoId, selectedVideoUrl) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
React.useEffect(() => {
|
||||
dispatch(thunkActions.video.loadVideoData(selectedVideoId, selectedVideoUrl));
|
||||
}, []);
|
||||
},
|
||||
returnToGallery: () => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const learningContextId = useSelector(selectors.app.learningContextId);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const blockId = useSelector(selectors.app.blockId);
|
||||
return () => (navigateTo(`/course/${learningContextId}/editor/course-videos/${blockId}`));
|
||||
},
|
||||
|
||||
@@ -9,8 +9,10 @@ const durationMatcher = /^(\d{0,2}):?(\d{0,2})?:?(\d{0,2})?$/i;
|
||||
export const durationWidget = ({ duration, updateField }) => {
|
||||
const setDuration = (val) => updateField({ duration: val });
|
||||
const initialState = module.durationString(duration);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [unsavedDuration, setUnsavedDuration] = useState(initialState);
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
setUnsavedDuration(module.durationString(duration));
|
||||
}, [duration]);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { thunkActions } from '../../../../../../data/redux';
|
||||
import * as module from './hooks';
|
||||
|
||||
export const state = {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
showSizeError: (args) => React.useState(args),
|
||||
};
|
||||
|
||||
@@ -28,7 +29,9 @@ export const checkValidFileSize = ({
|
||||
};
|
||||
|
||||
export const fileInput = ({ fileSizeError }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const dispatch = useDispatch();
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const ref = React.useRef();
|
||||
const click = () => ref.current.click();
|
||||
const addFile = (e) => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const analyticsEvents = {
|
||||
socialSharingSettingChanged: 'edx.social.video_sharing_setting.changed',
|
||||
};
|
||||
|
||||
export default analyticsEvents;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { selectors } from '../../../../../../data/redux';
|
||||
import analyticsEvents from './constants';
|
||||
|
||||
export const useTrackSocialSharingChange = ({ updateField }) => {
|
||||
const analytics = useSelector(selectors.app.analytics);
|
||||
const allowVideoSharing = useSelector(selectors.video.allowVideoSharing);
|
||||
return (event) => {
|
||||
sendTrackEvent(
|
||||
analyticsEvents.socialSharingSettingChanged,
|
||||
{
|
||||
...analytics,
|
||||
value: event.target.checked,
|
||||
},
|
||||
);
|
||||
updateField({
|
||||
allowVideoSharing: {
|
||||
...allowVideoSharing,
|
||||
value: event.target.checked,
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export default useTrackSocialSharingChange;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import analyticsEvents from './constants';
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('../../../../../../data/redux', () => ({
|
||||
selectors: {
|
||||
app: {
|
||||
analytics: jest.fn((state) => ({ analytics: state })),
|
||||
},
|
||||
video: {
|
||||
allowVideoSharing: jest.fn((state) => ({ allowVideoSharing: state })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics', () => ({
|
||||
sendTrackEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('SocialShareWidget hooks', () => {
|
||||
describe('useTrackSocialSharingChange when', () => {
|
||||
let onClick;
|
||||
let updateField;
|
||||
describe.each([true, false])('box is toggled', (checked) => {
|
||||
beforeAll(() => {
|
||||
jest.resetAllMocks();
|
||||
updateField = jest.fn();
|
||||
onClick = hooks.useTrackSocialSharingChange({ updateField });
|
||||
expect(typeof onClick).toBe('function');
|
||||
onClick({ target: { checked } });
|
||||
});
|
||||
it('field is updated', () => {
|
||||
expect(updateField).toBeCalledWith({ allowVideoSharing: { value: checked } });
|
||||
});
|
||||
it('event tracking is called', () => {
|
||||
expect(sendTrackEvent).toBeCalledWith(
|
||||
analyticsEvents.socialSharingSettingChanged,
|
||||
{ value: checked },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { selectors, actions } from '../../../../../../data/redux';
|
||||
import CollapsibleFormWidget from '../CollapsibleFormWidget';
|
||||
import messages from './messages';
|
||||
import * as hooks from './hooks';
|
||||
|
||||
/**
|
||||
* Collapsible Form widget controlling video thumbnail
|
||||
@@ -32,6 +33,8 @@ export const SocialShareWidget = ({
|
||||
const isSetByCourse = allowVideoSharing.level === 'course';
|
||||
const videoSharingEnabled = isLibrary ? videoSharingEnabledForAll : videoSharingEnabledForCourse;
|
||||
const learnMoreLink = videoSharingLearnMoreLink || 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/social_sharing.html';
|
||||
const onSocialSharingCheckboxChange = hooks.useTrackSocialSharingChange({ updateField });
|
||||
|
||||
const getSubtitle = () => {
|
||||
if (allowVideoSharing.value) {
|
||||
return intl.formatMessage(messages.enabledSubtitle);
|
||||
@@ -52,12 +55,7 @@ export const SocialShareWidget = ({
|
||||
className="mt-3"
|
||||
checked={allowVideoSharing.value}
|
||||
disabled={isSetByCourse}
|
||||
onChange={(e) => updateField({
|
||||
allowVideoSharing: {
|
||||
...allowVideoSharing,
|
||||
value: e.target.checked,
|
||||
},
|
||||
})}
|
||||
onChange={onSocialSharingCheckboxChange}
|
||||
>
|
||||
<div className="small text-gray-700">
|
||||
{intl.formatMessage(messages.socialSharingCheckboxLabel)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as constants from './constants';
|
||||
import * as module from './hooks';
|
||||
|
||||
export const state = {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
showSizeError: (args) => React.useState(args),
|
||||
};
|
||||
|
||||
@@ -85,7 +86,9 @@ export const checkValidSize = ({ file, onSizeFail }) => {
|
||||
};
|
||||
|
||||
export const fileInput = ({ setThumbnailSrc, imgRef, fileSizeError }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const dispatch = useDispatch();
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const ref = React.useRef();
|
||||
const click = () => ref.current.click();
|
||||
const addFile = (e) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import messages from './messages';
|
||||
|
||||
export const hooks = {
|
||||
state: {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
inDeleteConfirmation: (args) => React.useState(args),
|
||||
},
|
||||
setUpDeleteConfirmation: () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ import * as module from './index';
|
||||
|
||||
export const hooks = {
|
||||
updateErrors: ({ isUploadError, isDeleteError }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [error, setError] = React.useContext(ErrorContext).transcripts;
|
||||
if (isUploadError) {
|
||||
setError({ ...error, uploadError: messages.uploadTranscriptError.defaultMessage });
|
||||
|
||||
@@ -4,6 +4,7 @@ import { parseYoutubeId } from '../../../../../../data/services/cms/api';
|
||||
import * as requests from '../../../../../../data/redux/thunkActions/requests';
|
||||
|
||||
export const state = {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
showVideoIdChangeAlert: (args) => React.useState(args),
|
||||
};
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ export const updatedObject = (obj, index, val) => ({ ...obj, [index]: val });
|
||||
* @param {string} key - form key
|
||||
* @return {func} - callback taking a value and updating the video redux field
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
export const updateFormField = ({ dispatch, key }) => useCallback(
|
||||
(val) => dispatch(actions.video.updateField({ [key]: val })),
|
||||
[],
|
||||
@@ -93,14 +94,17 @@ export const updateFormField = ({ dispatch, key }) => useCallback(
|
||||
* setAll - sets form field in hook AND redux
|
||||
*/
|
||||
export const valueHooks = ({ dispatch, key }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const formValue = useSelector(selectors.video[key]);
|
||||
const [local, setLocal] = module.state[key](formValue);
|
||||
const setFormValue = module.updateFormField({ dispatch, key });
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
setLocal(formValue);
|
||||
}, [formValue]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const setAll = useCallback(
|
||||
(val) => {
|
||||
setLocal(val);
|
||||
|
||||
@@ -7,12 +7,14 @@ import * as module from './hooks';
|
||||
export const ErrorContext = createContext();
|
||||
|
||||
export const state = StrictDict({
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
durationErrors: (val) => useState(val),
|
||||
handoutErrors: (val) => useState(val),
|
||||
licenseErrors: (val) => useState(val),
|
||||
thumbnailErrors: (val) => useState(val),
|
||||
transcriptsErrors: (val) => useState(val),
|
||||
videoSourceErrors: (val) => useState(val),
|
||||
/* eslint-enable react-hooks/rules-of-hooks */
|
||||
});
|
||||
|
||||
export const errorsHook = () => {
|
||||
|
||||
@@ -19,12 +19,19 @@ export const {
|
||||
} = appHooks;
|
||||
|
||||
export const state = {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
highlighted: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
searchString: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
showSelectVideoError: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
showSizeError: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
sortBy: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
filertBy: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
hideSelectedVideos: (val) => React.useState(val),
|
||||
};
|
||||
|
||||
@@ -46,7 +53,7 @@ export const searchAndSortProps = () => {
|
||||
onFilterClick: (key) => () => setFilterBy(key),
|
||||
filterKeys,
|
||||
filterMessages,
|
||||
showSwitch: true,
|
||||
showSwitch: false,
|
||||
hideSelectedVideos,
|
||||
switchMessage: messages.hideSelectedCourseVideosSwitchLabel,
|
||||
onSwitchClick: () => setHideSelectedVideos(!hideSelectedVideos),
|
||||
@@ -100,7 +107,9 @@ export const videoListProps = ({ searchSortProps, videos }) => {
|
||||
setShowSizeError,
|
||||
] = module.state.showSizeError(false);
|
||||
const filteredList = module.filterList({ ...searchSortProps, videos });
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const learningContextId = useSelector(selectors.app.learningContextId);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const blockId = useSelector(selectors.app.blockId);
|
||||
return {
|
||||
galleryError: {
|
||||
@@ -146,14 +155,18 @@ export const fileInputProps = () => {
|
||||
};
|
||||
|
||||
export const handleVideoUpload = () => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const learningContextId = useSelector(selectors.app.learningContextId);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const blockId = useSelector(selectors.app.blockId);
|
||||
return () => navigateTo(`/course/${learningContextId}/editor/video_upload/${blockId}`);
|
||||
};
|
||||
|
||||
export const handleCancel = () => (
|
||||
navigateCallback({
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
destination: useSelector(selectors.app.returnUrl),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
analytics: useSelector(selectors.app.analytics),
|
||||
analyticsEvent: analyticsEvt.videoGalleryCancelClick,
|
||||
})
|
||||
|
||||
@@ -1,166 +1,227 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`VideoUploadEditor renders without errors 1`] = `
|
||||
exports[`VideoUploader snapshots renders as expected with default behavior 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="marked-area"
|
||||
className="d-flex flex-column justify-content-center align-items-center p-4 w-100 min-vh-100"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onDragEnter={[Function]}
|
||||
onDragLeave={[Function]}
|
||||
onDragOver={[Function]}
|
||||
onDrop={[Function]}
|
||||
onFocus={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
role="presentation"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
class="d-flex justify-content-end close-button-container"
|
||||
>
|
||||
<iconbutton
|
||||
iconas="Icon"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="d-flex flex-column justify-content-center align-items-center p-4 w-100 min-vh-100"
|
||||
role="presentation"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="d-flex flex-column justify-content-center align-items-center gap-2 text-center min-vh-100 w-100
|
||||
className="d-flex flex-column justify-content-center align-items-center gap-2 text-center min-vh-100 w-100
|
||||
dropzone-middle "
|
||||
>
|
||||
<div
|
||||
class="d-flex justify-content-center align-items-center bg-light rounded-circle file-upload"
|
||||
>
|
||||
<icon
|
||||
class="text-muted"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="d-flex align-items-center justify-content-center gap-1 flex-wrap flex-column pt-5"
|
||||
>
|
||||
<span
|
||||
style="font-size: 20px;"
|
||||
>
|
||||
FormattedMessage
|
||||
</span>
|
||||
<span
|
||||
style="font-size: 12px;"
|
||||
>
|
||||
FormattedMessage
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="d-flex align-items-center mt-3"
|
||||
>
|
||||
<span
|
||||
class="mx-2 text-dark"
|
||||
>
|
||||
OR
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
accept=""
|
||||
data-testid="fileInput"
|
||||
style="display: none;"
|
||||
tabindex="-1"
|
||||
type="file"
|
||||
>
|
||||
<div
|
||||
className="d-flex justify-content-center align-items-center bg-light rounded-circle file-upload"
|
||||
>
|
||||
<Icon
|
||||
className="text-muted"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="d-flex video-id-container"
|
||||
className="d-flex align-items-center justify-content-center gap-1 flex-wrap flex-column pt-5"
|
||||
>
|
||||
<div
|
||||
class="d-flex video-id-prompt"
|
||||
<span
|
||||
style={
|
||||
Object {
|
||||
"fontSize": "20px",
|
||||
}
|
||||
}
|
||||
>
|
||||
<input
|
||||
placeholder="Paste your video ID or URL"
|
||||
type="text"
|
||||
value=""
|
||||
<FormattedMessage
|
||||
defaultMessage="Drag and drop video here or click to upload"
|
||||
description="Display message for Drag and Drop zone"
|
||||
id="VideoUploadEditor.dropVideoFileHere"
|
||||
/>
|
||||
<button
|
||||
class="border-start-0"
|
||||
data-testid="inputSaveButton"
|
||||
type="button"
|
||||
>
|
||||
<icon
|
||||
class="rounded-circle text-dark"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
<span
|
||||
style={
|
||||
Object {
|
||||
"fontSize": "12px",
|
||||
}
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Upload MP4 or MOV files (5 GB max)"
|
||||
description="Info message for supported formats"
|
||||
id="VideoUploadEditor.uploadInfo"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="d-flex align-items-center mt-3"
|
||||
>
|
||||
<span
|
||||
className="mx-2 text-dark"
|
||||
>
|
||||
OR
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
accept=""
|
||||
data-testid="fileInput"
|
||||
multiple={false}
|
||||
onChange={[Function]}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"display": "none",
|
||||
}
|
||||
}
|
||||
tabIndex={-1}
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="d-flex video-id-container"
|
||||
>
|
||||
<div
|
||||
className="d-flex video-id-prompt"
|
||||
>
|
||||
<input
|
||||
onChange={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
placeholder="Paste your video ID or URL"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<button
|
||||
className="border-start-0"
|
||||
data-testid="inputSaveButton"
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<Icon
|
||||
className="rounded-circle text-dark"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`VideoUploader renders without errors 1`] = `
|
||||
exports[`VideoUploader snapshots renders as expected with error message 1`] = `
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
className="d-flex flex-column justify-content-center align-items-center p-4 w-100 min-vh-100"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onDragEnter={[Function]}
|
||||
onDragLeave={[Function]}
|
||||
onDragOver={[Function]}
|
||||
onDrop={[Function]}
|
||||
onFocus={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
role="presentation"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
class="d-flex flex-column justify-content-center align-items-center p-4 w-100 min-vh-100"
|
||||
role="presentation"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="d-flex flex-column justify-content-center align-items-center gap-2 text-center min-vh-100 w-100
|
||||
className="d-flex flex-column justify-content-center align-items-center gap-2 text-center min-vh-100 w-100
|
||||
dropzone-middle "
|
||||
>
|
||||
<div
|
||||
class="d-flex justify-content-center align-items-center bg-light rounded-circle file-upload"
|
||||
>
|
||||
<icon
|
||||
class="text-muted"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="d-flex align-items-center justify-content-center gap-1 flex-wrap flex-column pt-5"
|
||||
>
|
||||
<span
|
||||
style="font-size: 20px;"
|
||||
>
|
||||
FormattedMessage
|
||||
</span>
|
||||
<span
|
||||
style="font-size: 12px;"
|
||||
>
|
||||
FormattedMessage
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="d-flex align-items-center mt-3"
|
||||
>
|
||||
<span
|
||||
class="mx-2 text-dark"
|
||||
>
|
||||
OR
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
accept=""
|
||||
data-testid="fileInput"
|
||||
style="display: none;"
|
||||
tabindex="-1"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="d-flex video-id-container"
|
||||
>
|
||||
<div
|
||||
class="d-flex video-id-prompt"
|
||||
className="d-flex justify-content-center align-items-center bg-light rounded-circle file-upload"
|
||||
>
|
||||
<input
|
||||
placeholder="Paste your video ID or URL"
|
||||
type="text"
|
||||
value=""
|
||||
<Icon
|
||||
className="text-muted"
|
||||
/>
|
||||
<button
|
||||
class="border-start-0"
|
||||
data-testid="inputSaveButton"
|
||||
type="button"
|
||||
>
|
||||
<icon
|
||||
class="rounded-circle text-dark"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-center gap-1 flex-wrap flex-column pt-5"
|
||||
>
|
||||
<span
|
||||
style={
|
||||
Object {
|
||||
"fontSize": "20px",
|
||||
}
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Drag and drop video here or click to upload"
|
||||
description="Display message for Drag and Drop zone"
|
||||
id="VideoUploadEditor.dropVideoFileHere"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
style={
|
||||
Object {
|
||||
"fontSize": "12px",
|
||||
}
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Upload MP4 or MOV files (5 GB max)"
|
||||
description="Info message for supported formats"
|
||||
id="VideoUploadEditor.uploadInfo"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="d-flex align-items-center mt-3"
|
||||
>
|
||||
<span
|
||||
className="mx-2 text-dark"
|
||||
>
|
||||
OR
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
accept=""
|
||||
data-testid="fileInput"
|
||||
multiple={false}
|
||||
onChange={[Function]}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"display": "none",
|
||||
}
|
||||
}
|
||||
tabIndex={-1}
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="d-flex video-id-container"
|
||||
>
|
||||
<div
|
||||
className="d-flex video-id-prompt"
|
||||
>
|
||||
<input
|
||||
onChange={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
placeholder="Paste your video ID or URL"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<button
|
||||
className="border-start-0"
|
||||
data-testid="inputSaveButton"
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<Icon
|
||||
className="rounded-circle text-dark"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`VideoUploaderEdirtor snapshots renders as expected with default behavior 1`] = `
|
||||
<ContextConsumer>
|
||||
<Component />
|
||||
</ContextConsumer>
|
||||
`;
|
||||
|
||||
@@ -1,66 +1,81 @@
|
||||
import * as requests from '../../data/redux/thunkActions/requests';
|
||||
import React from 'react';
|
||||
import * as module from './hooks';
|
||||
import { selectors } from '../../data/redux';
|
||||
import store from '../../data/store';
|
||||
import * as appHooks from '../../hooks';
|
||||
|
||||
const extToMime = {
|
||||
mp4: 'video/mp4',
|
||||
mov: 'video/quicktime',
|
||||
};
|
||||
|
||||
export const {
|
||||
navigateTo,
|
||||
} = appHooks;
|
||||
|
||||
export const uploadVideo = async ({ dispatch, supportedFiles }) => {
|
||||
const data = { files: [] };
|
||||
supportedFiles.forEach((file) => {
|
||||
data.files.push({
|
||||
file_name: file.name,
|
||||
content_type: file.type,
|
||||
});
|
||||
});
|
||||
const onFileUploadedHook = module.onFileUploaded();
|
||||
dispatch(await requests.uploadVideo({
|
||||
data,
|
||||
onSuccess: async (response) => {
|
||||
const { files } = response.data;
|
||||
await Promise.all(Object.values(files).map(async (fileObj) => {
|
||||
const fileName = fileObj.file_name;
|
||||
const edxVideoId = fileObj.edx_video_id;
|
||||
const uploadUrl = fileObj.upload_url;
|
||||
const uploadFile = supportedFiles.find((file) => file.name === fileName);
|
||||
|
||||
if (!uploadFile) {
|
||||
console.error(`Could not find file object with name "${fileName}" in supportedFiles array.`);
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('uploaded-file', uploadFile);
|
||||
await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
.then(() => onFileUploadedHook(edxVideoId))
|
||||
.catch((error) => console.error('Error uploading file:', error));
|
||||
}));
|
||||
},
|
||||
}));
|
||||
export const state = {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
loading: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
errorMessage: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
textInputValue: (val) => React.useState(val),
|
||||
};
|
||||
|
||||
export const onFileUploaded = () => {
|
||||
const state = store.getState();
|
||||
const learningContextId = selectors.app.learningContextId(state);
|
||||
const blockId = selectors.app.blockId(state);
|
||||
return (edxVideoId) => navigateTo(`/course/${learningContextId}/editor/video/${blockId}?selectedVideoId=${edxVideoId}`);
|
||||
export const uploadEditor = () => {
|
||||
const [loading, setLoading] = module.state.loading(false);
|
||||
const [errorMessage, setErrorMessage] = module.state.errorMessage(null);
|
||||
return {
|
||||
loading,
|
||||
setLoading,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
};
|
||||
};
|
||||
|
||||
export const onUrlUploaded = () => {
|
||||
const state = store.getState();
|
||||
const learningContextId = selectors.app.learningContextId(state);
|
||||
const blockId = selectors.app.blockId(state);
|
||||
export const uploader = () => {
|
||||
const [textInputValue, settextInputValue] = module.state.textInputValue('');
|
||||
return {
|
||||
textInputValue,
|
||||
settextInputValue,
|
||||
};
|
||||
};
|
||||
|
||||
export const postUploadRedirect = (storeState) => {
|
||||
const learningContextId = selectors.app.learningContextId(storeState);
|
||||
const blockId = selectors.app.blockId(storeState);
|
||||
return (videoUrl) => navigateTo(`/course/${learningContextId}/editor/video/${blockId}?selectedVideoUrl=${videoUrl}`);
|
||||
};
|
||||
|
||||
export default {
|
||||
uploadVideo,
|
||||
export const onVideoUpload = () => {
|
||||
const storeState = store.getState();
|
||||
return module.postUploadRedirect(storeState);
|
||||
};
|
||||
|
||||
const getFileExtension = (filename) => filename.slice(Math.abs(filename.lastIndexOf('.') - 1) + 2);
|
||||
|
||||
export const fileValidator = (setLoading, setErrorMessage, uploadVideo) => (file) => {
|
||||
const supportedFormats = Object.keys(extToMime);
|
||||
const ext = getFileExtension(file.name);
|
||||
const type = extToMime[ext] || '';
|
||||
const newFile = new File([file], file.name, { type });
|
||||
|
||||
if (supportedFormats.includes(ext)) {
|
||||
uploadVideo({
|
||||
supportedFiles: [newFile],
|
||||
setLoadSpinner: setLoading,
|
||||
postUploadRedirect: onVideoUpload(),
|
||||
});
|
||||
} else {
|
||||
const errorMsg = 'Video must be an MP4 or MOV file';
|
||||
setErrorMessage(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
postUploadRedirect,
|
||||
uploadEditor,
|
||||
uploader,
|
||||
onVideoUpload,
|
||||
fileValidator,
|
||||
};
|
||||
|
||||
@@ -1,92 +1,50 @@
|
||||
import hooks from './hooks';
|
||||
import * as requests from '../../data/redux/thunkActions/requests';
|
||||
import * as hooks from './hooks';
|
||||
import { MockUseState } from '../../../testUtils';
|
||||
|
||||
jest.mock('../../data/redux/thunkActions/requests');
|
||||
|
||||
describe('uploadVideo', () => {
|
||||
const dispatch = jest.fn();
|
||||
const supportedFiles = [
|
||||
new File(['content1'], 'file1.mp4', { type: 'video/mp4' }),
|
||||
new File(['content2'], 'file2.mov', { type: 'video/quicktime' }),
|
||||
];
|
||||
const state = new MockUseState(hooks);
|
||||
const setLoading = jest.fn();
|
||||
const setErrorMessage = jest.fn();
|
||||
const uploadVideo = jest.fn();
|
||||
|
||||
describe('Video Upload Editor hooks', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should dispatch uploadVideo action with correct data and onSuccess callback', async () => {
|
||||
requests.uploadVideo.mockImplementation(() => 'requests.uploadVideo');
|
||||
const data = {
|
||||
files: [
|
||||
{ file_name: 'file1.mp4', content_type: 'video/mp4' },
|
||||
{ file_name: 'file2.mov', content_type: 'video/quicktime' },
|
||||
],
|
||||
};
|
||||
|
||||
await hooks.uploadVideo({ dispatch, supportedFiles });
|
||||
|
||||
expect(requests.uploadVideo).toHaveBeenCalledWith({ data, onSuccess: expect.any(Function) });
|
||||
expect(dispatch).toHaveBeenCalledWith('requests.uploadVideo');
|
||||
describe('state hooks', () => {
|
||||
state.testGetter(state.keys.loading);
|
||||
state.testGetter(state.keys.errorMessage);
|
||||
state.testGetter(state.keys.textInputValue);
|
||||
});
|
||||
describe('using state', () => {
|
||||
beforeEach(() => { state.mock(); });
|
||||
afterEach(() => { state.restore(); });
|
||||
|
||||
it('should call fetch with correct arguments for each file', async () => {
|
||||
const mockResponseData = { success: true };
|
||||
const mockFetchResponse = Promise.resolve({ data: mockResponseData });
|
||||
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
|
||||
const response = {
|
||||
files: [
|
||||
{ file_name: 'file1.mp4', upload_url: 'http://example.com/put_video1' },
|
||||
{ file_name: 'file2.mov', upload_url: 'http://example.com/put_video2' },
|
||||
],
|
||||
};
|
||||
const mockRequestResponse = { data: response };
|
||||
requests.uploadVideo.mockImplementation(async ({ onSuccess }) => {
|
||||
await onSuccess(mockRequestResponse);
|
||||
});
|
||||
|
||||
await hooks.uploadVideo({ dispatch, supportedFiles });
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
response.files.forEach(({ upload_url: uploadUrl }, index) => {
|
||||
expect(fetch.mock.calls[index][0]).toEqual(uploadUrl);
|
||||
});
|
||||
supportedFiles.forEach((file, index) => {
|
||||
expect(fetch.mock.calls[index][1].body.get('uploaded-file')).toBe(file);
|
||||
describe('Hooks for Video Upload', () => {
|
||||
beforeEach(() => {
|
||||
hooks.uploadEditor();
|
||||
hooks.uploader();
|
||||
});
|
||||
it('initialize state with correct values', () => {
|
||||
expect(state.stateVals.loading).toEqual(false);
|
||||
expect(state.stateVals.errorMessage).toEqual(null);
|
||||
expect(state.stateVals.textInputValue).toEqual('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should log an error if fetch failed to upload a file', async () => {
|
||||
const error = new Error('Uh-oh!');
|
||||
global.fetch = jest.fn().mockRejectedValue(error);
|
||||
const response = {
|
||||
files: [
|
||||
{ file_name: 'file1.mp4', upload_url: 'http://example.com/put_video1' },
|
||||
{ file_name: 'file2.mov', upload_url: 'http://example.com/put_video2' },
|
||||
],
|
||||
};
|
||||
const mockRequestResponse = { data: response };
|
||||
requests.uploadVideo.mockImplementation(async ({ onSuccess }) => {
|
||||
await onSuccess(mockRequestResponse);
|
||||
describe('File Validation', () => {
|
||||
it('Checks with valid MIME type', () => {
|
||||
const file = new File(['(⌐□_□)'], 'video.mp4', { type: 'video/mp4' });
|
||||
const validator = hooks.fileValidator(setLoading, setErrorMessage, uploadVideo);
|
||||
validator(file);
|
||||
expect(uploadVideo).toHaveBeenCalled();
|
||||
expect(setErrorMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await hooks.uploadVideo({ dispatch, supportedFiles });
|
||||
});
|
||||
|
||||
it('should log an error if file object is not found in supportedFiles array', () => {
|
||||
const response = {
|
||||
files: [
|
||||
{ file_name: 'file2.mov', upload_url: 'http://example.com/put_video2' },
|
||||
],
|
||||
};
|
||||
const mockRequestResponse = { data: response };
|
||||
const spyConsoleError = jest.spyOn(console, 'error');
|
||||
requests.uploadVideo.mockImplementation(({ onSuccess }) => {
|
||||
onSuccess(mockRequestResponse);
|
||||
it('Checks with invalid MIME type', () => {
|
||||
const file = new File(['(⌐□_□)'], 'video.gif', { type: 'video/mp4' });
|
||||
const validator = hooks.fileValidator(setLoading, setErrorMessage, uploadVideo);
|
||||
validator(file);
|
||||
expect(uploadVideo).not.toHaveBeenCalled();
|
||||
expect(setErrorMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
hooks.uploadVideo({ dispatch, supportedFiles: [supportedFiles[0]] });
|
||||
|
||||
expect(spyConsoleError).toHaveBeenCalledWith('Could not find file object with name "file2.mov" in supportedFiles array.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, IconButton } from '@edx/paragon';
|
||||
import './index.scss';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Icon, IconButton, Spinner } from '@edx/paragon';
|
||||
import { ArrowForward, Close, FileUpload } from '@edx/paragon/icons';
|
||||
import { connect } from 'react-redux';
|
||||
import { thunkActions } from '../../data/redux';
|
||||
import './index.scss';
|
||||
import * as hooks from './hooks';
|
||||
import messages from '../../messages';
|
||||
import messages from './messages';
|
||||
import * as editorHooks from '../EditorContainer/hooks';
|
||||
|
||||
export const VideoUploader = ({ onUpload, errorMessage }) => {
|
||||
const [, setUploadedFile] = useState();
|
||||
const [textInputValue, setTextInputValue] = useState('');
|
||||
const onUrlUpdatedHook = hooks.onUrlUploaded();
|
||||
const { textInputValue, setTextInputValue } = hooks.uploader();
|
||||
const onURLUpload = hooks.onVideoUpload();
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: 'video/*',
|
||||
@@ -21,7 +21,6 @@ export const VideoUploader = ({ onUpload, errorMessage }) => {
|
||||
onDrop: (acceptedFiles) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
const uploadfile = acceptedFiles[0];
|
||||
setUploadedFile(uploadfile);
|
||||
onUpload(uploadfile);
|
||||
}
|
||||
},
|
||||
@@ -32,7 +31,7 @@ export const VideoUploader = ({ onUpload, errorMessage }) => {
|
||||
};
|
||||
|
||||
const handleSaveButtonClick = () => {
|
||||
onUrlUpdatedHook(textInputValue);
|
||||
onURLUpload(textInputValue);
|
||||
};
|
||||
|
||||
if (errorMessage) {
|
||||
@@ -85,52 +84,53 @@ VideoUploader.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
const VideoUploadEditor = ({ intl, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [errorMessage, setErrorMessage] = useState(null);
|
||||
const handleCancel = () => {
|
||||
editorHooks.handleCancel({ onClose });
|
||||
};
|
||||
const VideoUploadEditor = (
|
||||
{
|
||||
intl,
|
||||
onClose,
|
||||
// Redux states
|
||||
uploadVideo,
|
||||
},
|
||||
) => {
|
||||
const {
|
||||
loading,
|
||||
setLoading,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
} = hooks.uploadEditor();
|
||||
const handleCancel = editorHooks.handleCancel({ onClose });
|
||||
|
||||
const handleDrop = (file) => {
|
||||
if (!file) {
|
||||
console.log('No file selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
const extToMime = {
|
||||
mp4: 'video/mp4',
|
||||
mov: 'video/quicktime',
|
||||
};
|
||||
const supportedFormats = Object.keys(extToMime);
|
||||
|
||||
function getFileExtension(filename) {
|
||||
return filename.slice(Math.abs(filename.lastIndexOf('.') - 1) + 2);
|
||||
}
|
||||
|
||||
const ext = getFileExtension(file.name);
|
||||
const type = extToMime[ext] || '';
|
||||
const newFile = new File([file], file.name, { type });
|
||||
|
||||
if (supportedFormats.includes(ext)) {
|
||||
hooks.uploadVideo({ dispatch, supportedFiles: [newFile] });
|
||||
} else {
|
||||
const errorMsg = 'Video must be an MP4 or MOV file';
|
||||
console.log(errorMsg);
|
||||
setErrorMessage(errorMsg);
|
||||
}
|
||||
const validator = hooks.fileValidator(setLoading, setErrorMessage, uploadVideo);
|
||||
validator(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="marked-area">
|
||||
<div className="d-flex justify-content-end close-button-container">
|
||||
<IconButton
|
||||
src={Close}
|
||||
iconAs={Icon}
|
||||
onClick={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
<VideoUploader onUpload={handleDrop} errorMessage={errorMessage} intl={intl} />
|
||||
<div>
|
||||
{(!loading) ? (
|
||||
<div className="marked-area">
|
||||
<div className="d-flex justify-content-end close-button-container">
|
||||
<IconButton
|
||||
src={Close}
|
||||
iconAs={Icon}
|
||||
onClick={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
<VideoUploader onUpload={handleDrop} errorMessage={errorMessage} intl={intl} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-6">
|
||||
<Spinner
|
||||
animation="border"
|
||||
className="m-3"
|
||||
screenreadertext={intl.formatMessage(messages.spinnerScreenReaderText)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -138,6 +138,13 @@ const VideoUploadEditor = ({ intl, onClose }) => {
|
||||
VideoUploadEditor.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
uploadVideo: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(VideoUploadEditor);
|
||||
export const mapStateToProps = () => ({});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
uploadVideo: thunkActions.video.uploadVideo,
|
||||
};
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(VideoUploadEditor));
|
||||
|
||||
@@ -1,142 +1,37 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { render, fireEvent, act } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { shallow } from 'enzyme';
|
||||
import VideoUploadEditor, { VideoUploader } from '.';
|
||||
import * as hooks from './hooks';
|
||||
import * as appHooks from '../../hooks';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
const mockOnUpload = jest.fn();
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
jest.mock('../../hooks', () => ({
|
||||
...jest.requireActual('../../hooks'),
|
||||
navigateTo: jest.fn((args) => ({ navigateTo: args })),
|
||||
}));
|
||||
import { formatMessage } from '../../../testUtils';
|
||||
|
||||
const defaultEditorProps = {
|
||||
intl: {},
|
||||
onClose: jest.fn().mockName('props.onClose'),
|
||||
intl: { formatMessage },
|
||||
uploadVideo: jest.fn(),
|
||||
};
|
||||
|
||||
const defaultUploaderProps = {
|
||||
onUpload: mockOnUpload,
|
||||
errorMessage: '',
|
||||
intl: {},
|
||||
onUpload: jest.fn(),
|
||||
errorMessage: null,
|
||||
intl: { formatMessage },
|
||||
};
|
||||
|
||||
const renderEditorComponent = (props = defaultEditorProps) => render(
|
||||
<IntlProvider locale="en">
|
||||
<VideoUploadEditor {...props} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
const renderUploaderComponent = (props = defaultUploaderProps) => render(
|
||||
<IntlProvider locale="en">
|
||||
<VideoUploader {...props} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
describe('VideoUploadEditor', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders without errors', () => {
|
||||
const { container } = renderEditorComponent();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('updates the input field value when user types', () => {
|
||||
const { getByPlaceholderText } = renderEditorComponent();
|
||||
const input = getByPlaceholderText('Paste your video ID or URL');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'test value' } });
|
||||
expect(input.value).toBe('test value');
|
||||
});
|
||||
|
||||
it('click on the save button', () => {
|
||||
const { getByPlaceholderText, getByTestId } = renderEditorComponent();
|
||||
const testValue = 'test vale';
|
||||
const input = getByPlaceholderText('Paste your video ID or URL');
|
||||
fireEvent.change(input, { target: { value: testValue } });
|
||||
const button = getByTestId('inputSaveButton');
|
||||
fireEvent.click(button);
|
||||
expect(appHooks.navigateTo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows error message with unsupported files', async () => {
|
||||
const { getByTestId, findByText } = renderEditorComponent();
|
||||
const fileInput = getByTestId('fileInput');
|
||||
|
||||
const unsupportedFile = new File(['(⌐□_□)'], 'unsupported.avi', { type: 'video/avi' });
|
||||
fireEvent.change(fileInput, { target: { files: [unsupportedFile] } });
|
||||
|
||||
const errorMsg = await findByText('Video must be an MP4 or MOV file');
|
||||
expect(errorMsg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls uploadVideo with supported files', async () => {
|
||||
const uploadVideoSpy = jest.spyOn(hooks, 'uploadVideo');
|
||||
const { container } = renderEditorComponent();
|
||||
const dropzone = container.querySelector('.dropzone-middle');
|
||||
|
||||
const supportedFile = new File(['(⌐□_□)'], 'supported.mp4', { type: 'video/mp4' });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.drop(dropzone, {
|
||||
dataTransfer: {
|
||||
files: [supportedFile],
|
||||
types: ['Files'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(uploadVideoSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
dispatch: mockDispatch,
|
||||
supportedFiles: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: supportedFile.name,
|
||||
type: supportedFile.type,
|
||||
size: supportedFile.size,
|
||||
}),
|
||||
]),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoUploader', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders without errors', () => {
|
||||
const { container } = renderUploaderComponent();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with an error message', () => {
|
||||
const errorMessage = 'Video must be an MP4 or MOV file';
|
||||
const { getByText } = renderUploaderComponent({ ...defaultUploaderProps, errorMessage });
|
||||
expect(getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls the onUpload function when a supported file is dropped', async () => {
|
||||
const { container } = renderUploaderComponent();
|
||||
const dropzone = container.querySelector('.dropzone-middle');
|
||||
const file = new File(['(⌐□_□)'], 'video.mp4', { type: 'video/mp4' });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.drop(dropzone, {
|
||||
dataTransfer: {
|
||||
files: [file],
|
||||
types: ['Files'],
|
||||
},
|
||||
});
|
||||
describe('snapshots', () => {
|
||||
test('renders as expected with default behavior', () => {
|
||||
expect(shallow(<VideoUploader {...defaultUploaderProps} />)).toMatchSnapshot();
|
||||
});
|
||||
test('renders as expected with error message', () => {
|
||||
const defaultUploaderPropsWithError = { ...defaultUploaderProps, errorMessages: 'Some Error' };
|
||||
expect(shallow(<VideoUploader {...defaultUploaderPropsWithError} />)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoUploaderEdirtor', () => {
|
||||
describe('snapshots', () => {
|
||||
test('renders as expected with default behavior', () => {
|
||||
expect(shallow(<VideoUploadEditor {...defaultEditorProps} />)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
expect(mockOnUpload).toHaveBeenCalledWith(file);
|
||||
});
|
||||
});
|
||||
|
||||
21
src/editors/containers/VideoUploadEditor/messages.js
Normal file
21
src/editors/containers/VideoUploadEditor/messages.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
spinnerScreenReaderText: {
|
||||
id: 'authoring.videoUpload.spinnerScreenReaderText',
|
||||
defaultMessage: 'loading',
|
||||
description: 'Loading message for spinner screenreader text.',
|
||||
},
|
||||
dropVideoFileHere: {
|
||||
defaultMessage: 'Drag and drop video here or click to upload',
|
||||
id: 'VideoUploadEditor.dropVideoFileHere',
|
||||
description: 'Display message for Drag and Drop zone',
|
||||
},
|
||||
info: {
|
||||
id: 'VideoUploadEditor.uploadInfo',
|
||||
defaultMessage: 'Upload MP4 or MOV files (5 GB max)',
|
||||
description: 'Info message for supported formats',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -7,4 +7,5 @@ export const blockTypes = StrictDict({
|
||||
problem: 'problem',
|
||||
// ADDED_EDITORS GO BELOW
|
||||
video_upload: 'video_upload',
|
||||
game: 'game',
|
||||
});
|
||||
|
||||
@@ -198,7 +198,6 @@ const getStyles = () => (
|
||||
}
|
||||
.mce-content-body img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.mce-content-body pre {
|
||||
margin: 1em 0;
|
||||
|
||||
@@ -32,6 +32,7 @@ const app = createSlice({
|
||||
blockId: payload.blockId,
|
||||
learningContextId: payload.learningContextId,
|
||||
blockType: payload.blockType,
|
||||
blockValue: null,
|
||||
}),
|
||||
setUnitUrl: (state, { payload }) => ({ ...state, unitUrl: payload }),
|
||||
setBlockValue: (state, { payload }) => ({
|
||||
|
||||
2
src/editors/data/redux/game/index.js
Normal file
2
src/editors/data/redux/game/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { actions, reducer } from './reducers';
|
||||
export { default as selectors } from './selectors';
|
||||
31
src/editors/data/redux/game/reducers.js
Normal file
31
src/editors/data/redux/game/reducers.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { StrictDict } from '../../../utils';
|
||||
|
||||
const initialState = {
|
||||
settings: {},
|
||||
// TODO fill in with mock state
|
||||
exampleValue: 'this is an example value from the redux state',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const game = createSlice({
|
||||
name: 'game',
|
||||
initialState,
|
||||
reducers: {
|
||||
updateField: (state, { payload }) => ({
|
||||
...state,
|
||||
...payload,
|
||||
}),
|
||||
// TODO fill in reducers
|
||||
},
|
||||
});
|
||||
|
||||
const actions = StrictDict(game.actions);
|
||||
|
||||
const { reducer } = game;
|
||||
|
||||
export {
|
||||
actions,
|
||||
initialState,
|
||||
reducer,
|
||||
};
|
||||
15
src/editors/data/redux/game/selectors.js
Normal file
15
src/editors/data/redux/game/selectors.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import * as module from './selectors';
|
||||
|
||||
export const gameState = (state) => state.game;
|
||||
const mkSimpleSelector = (cb) => createSelector([module.gameState], cb);
|
||||
export const simpleSelectors = {
|
||||
exampleValue: mkSimpleSelector(gameData => gameData.exampleValue),
|
||||
settings: mkSimpleSelector(gameData => gameData.settings),
|
||||
completeState: mkSimpleSelector(gameData => gameData),
|
||||
// TODO fill in with selectors as needed
|
||||
};
|
||||
|
||||
export default {
|
||||
...simpleSelectors,
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import * as app from './app';
|
||||
import * as requests from './requests';
|
||||
import * as video from './video';
|
||||
import * as problem from './problem';
|
||||
import * as game from './game';
|
||||
|
||||
/* eslint-disable import/no-cycle */
|
||||
export { default as thunkActions } from './thunkActions';
|
||||
@@ -15,6 +16,7 @@ const modules = {
|
||||
requests,
|
||||
video,
|
||||
problem,
|
||||
game,
|
||||
};
|
||||
|
||||
const moduleProps = (propName) => Object.keys(modules).reduce(
|
||||
|
||||
@@ -64,13 +64,13 @@ export const initialize = (data) => (dispatch) => {
|
||||
/**
|
||||
* @param {func} onSuccess
|
||||
*/
|
||||
export const saveBlock = ({ content, returnToUnit }) => (dispatch) => {
|
||||
export const saveBlock = (content, returnToUnit) => (dispatch) => {
|
||||
dispatch(actions.app.setBlockContent(content));
|
||||
dispatch(requests.saveBlock({
|
||||
content,
|
||||
onSuccess: (response) => {
|
||||
dispatch(actions.app.setSaveResponse(response));
|
||||
returnToUnit(response.data)();
|
||||
returnToUnit(response.data);
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -138,8 +138,8 @@ describe('app thunkActions', () => {
|
||||
let returnToUnit;
|
||||
let calls;
|
||||
beforeEach(() => {
|
||||
returnToUnit = jest.fn((response) => () => response);
|
||||
thunkActions.saveBlock({ content: testValue, returnToUnit })(dispatch);
|
||||
returnToUnit = jest.fn();
|
||||
thunkActions.saveBlock(testValue, returnToUnit)(dispatch);
|
||||
calls = dispatch.mock.calls;
|
||||
});
|
||||
it('dispatches actions.app.setBlockContent with content, before dispatching saveBlock', () => {
|
||||
|
||||
@@ -290,7 +290,6 @@ export const fetchVideoFeatures = ({ ...rest }) => (dispatch, getState) => {
|
||||
requestKey: RequestKeys.fetchVideoFeatures,
|
||||
promise: api.fetchVideoFeatures({
|
||||
studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
|
||||
learningContextId: selectors.app.learningContextId(getState()),
|
||||
}),
|
||||
...rest,
|
||||
}));
|
||||
|
||||
@@ -487,7 +487,6 @@ describe('requests thunkActions module', () => {
|
||||
requestKey: RequestKeys.fetchVideoFeatures,
|
||||
promise: api.fetchVideoFeatures({
|
||||
studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
|
||||
learningContextId: selectors.app.learningContextId(testState),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -370,6 +370,45 @@ export const replaceTranscript = ({ newFile, newFilename, language }) => (dispat
|
||||
}));
|
||||
};
|
||||
|
||||
export const uploadVideo = ({ supportedFiles, setLoadSpinner, postUploadRedirect }) => (dispatch) => {
|
||||
const data = { files: [] };
|
||||
setLoadSpinner(true);
|
||||
supportedFiles.forEach((file) => {
|
||||
data.files.push({
|
||||
file_name: file.name,
|
||||
content_type: file.type,
|
||||
});
|
||||
});
|
||||
dispatch(requests.uploadVideo({
|
||||
data,
|
||||
onSuccess: async (response) => {
|
||||
const { files } = response.data;
|
||||
await Promise.all(Object.values(files).map(async (fileObj) => {
|
||||
const fileName = fileObj.file_name;
|
||||
const edxVideoId = fileObj.edx_video_id;
|
||||
const uploadUrl = fileObj.upload_url;
|
||||
const uploadFile = supportedFiles.find((file) => file.name === fileName);
|
||||
if (!uploadFile) {
|
||||
console.error(`Could not find file object with name "${fileName}" in supportedFiles array.`);
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('uploaded-file', uploadFile);
|
||||
await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
.then(() => postUploadRedirect(edxVideoId))
|
||||
.catch((error) => console.error('Error uploading file:', error));
|
||||
}));
|
||||
setLoadSpinner(false);
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
export default {
|
||||
loadVideoData,
|
||||
determineVideoSources,
|
||||
@@ -382,4 +421,5 @@ export default {
|
||||
updateTranscriptLanguage,
|
||||
replaceTranscript,
|
||||
uploadHandout,
|
||||
uploadVideo,
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ jest.mock('./requests', () => ({
|
||||
checkTranscriptsForImport: (args) => ({ checkTranscriptsForImport: args }),
|
||||
importTranscript: (args) => ({ importTranscript: args }),
|
||||
fetchVideoFeatures: (args) => ({ fetchVideoFeatures: args }),
|
||||
uploadVideo: (args) => ({ uploadVideo: args }),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils', () => ({
|
||||
@@ -669,3 +670,79 @@ describe('video thunkActions', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadVideo', () => {
|
||||
let dispatch;
|
||||
let setLoadSpinner;
|
||||
let postUploadRedirect;
|
||||
let dispatchedAction;
|
||||
const supportedFiles = [
|
||||
new File(['content1'], 'file1.mp4', { type: 'video/mp4' }),
|
||||
new File(['content2'], 'file2.mov', { type: 'video/quicktime' }),
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch = jest.fn((action) => ({ dispatch: action }));
|
||||
setLoadSpinner = jest.fn();
|
||||
postUploadRedirect = jest.fn();
|
||||
jest.resetAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('dispatch uploadVideo action with right data', async () => {
|
||||
const data = {
|
||||
files: [
|
||||
{ file_name: 'file1.mp4', content_type: 'video/mp4' },
|
||||
{ file_name: 'file2.mov', content_type: 'video/quicktime' },
|
||||
],
|
||||
};
|
||||
|
||||
thunkActions.uploadVideo({ supportedFiles, setLoadSpinner, postUploadRedirect })(dispatch);
|
||||
[[dispatchedAction]] = dispatch.mock.calls;
|
||||
expect(dispatchedAction.uploadVideo).not.toEqual(undefined);
|
||||
expect(setLoadSpinner).toHaveBeenCalled();
|
||||
expect(dispatchedAction.uploadVideo.data).toEqual(data);
|
||||
});
|
||||
|
||||
it('should call fetch with correct arguments for each file', async () => {
|
||||
const mockResponseData = { success: true };
|
||||
const mockFetchResponse = Promise.resolve({ data: mockResponseData });
|
||||
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
|
||||
const response = {
|
||||
files: [
|
||||
{ file_name: 'file1.mp4', upload_url: 'http://example.com/put_video1' },
|
||||
{ file_name: 'file2.mov', upload_url: 'http://example.com/put_video2' },
|
||||
],
|
||||
};
|
||||
const mockRequestResponse = { data: response };
|
||||
thunkActions.uploadVideo({ supportedFiles, setLoadSpinner, postUploadRedirect })(dispatch);
|
||||
[[dispatchedAction]] = dispatch.mock.calls;
|
||||
|
||||
dispatchedAction.uploadVideo.onSuccess(mockRequestResponse);
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
response.files.forEach(({ upload_url: uploadUrl }, index) => {
|
||||
expect(fetch.mock.calls[index][0]).toEqual(uploadUrl);
|
||||
});
|
||||
supportedFiles.forEach((file, index) => {
|
||||
expect(fetch.mock.calls[index][1].body.get('uploaded-file')).toBe(file);
|
||||
});
|
||||
});
|
||||
|
||||
it('should log an error if file object is not found in supportedFiles array', () => {
|
||||
const mockResponseData = { success: true };
|
||||
const mockFetchResponse = Promise.resolve({ data: mockResponseData });
|
||||
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
|
||||
const response = {
|
||||
files: [
|
||||
{ file_name: 'file2.gif', upload_url: 'http://example.com/put_video2' },
|
||||
],
|
||||
};
|
||||
const mockRequestResponse = { data: response };
|
||||
const spyConsoleError = jest.spyOn(console, 'error');
|
||||
|
||||
thunkActions.uploadVideo({ supportedFiles: [supportedFiles[0]], setLoadSpinner, postUploadRedirect })(dispatch);
|
||||
dispatchedAction.uploadVideo.onSuccess(mockRequestResponse);
|
||||
expect(spyConsoleError).toHaveBeenCalledWith('Could not find file object with name "file2.gif" in supportedFiles array.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,7 +143,7 @@ export const apiMethods = {
|
||||
response = {
|
||||
data: content.olx,
|
||||
category: blockType,
|
||||
couseKey: learningContextId,
|
||||
courseKey: learningContextId,
|
||||
has_changes: true,
|
||||
id: blockId,
|
||||
metadata: { display_name: title, ...content.settings },
|
||||
@@ -204,9 +204,8 @@ export const apiMethods = {
|
||||
),
|
||||
fetchVideoFeatures: ({
|
||||
studioEndpointUrl,
|
||||
learningContextId,
|
||||
}) => get(
|
||||
urls.videoFeatures({ studioEndpointUrl, learningContextId }),
|
||||
urls.videoFeatures({ studioEndpointUrl }),
|
||||
),
|
||||
uploadVideo: ({
|
||||
data,
|
||||
|
||||
@@ -575,7 +575,7 @@ describe('cms api', () => {
|
||||
});
|
||||
describe('fetchVideoFeatures', () => {
|
||||
it('should call get with url.videoFeatures', () => {
|
||||
const args = { studioEndpointUrl, learningContextId };
|
||||
const args = { studioEndpointUrl };
|
||||
apiMethods.fetchVideoFeatures({ ...args });
|
||||
expect(get).toHaveBeenCalledWith(urls.videoFeatures({ ...args }));
|
||||
});
|
||||
|
||||
@@ -36,21 +36,7 @@ export const fetchBlockById = ({ blockId, studioEndpointUrl }) => {
|
||||
} else if (blockId === 'problem-block-id') {
|
||||
data = {
|
||||
data: `<problem>
|
||||
<multiplechoiceresponse>
|
||||
<p>What is the content of the register x2 after executing the following three lines of instructions?</p>
|
||||
<p><span style="font-family: 'courier new', courier;"><strong>Address          assembly instructions <br />0x0              addi x1, x0, 1<br />0x4              slli x2, x1, 4<br />0x8              sub x1, x2, x1</strong></span></p>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">answerA</choice>
|
||||
<choice correct="true">answerB</choice>
|
||||
</choicegroup>
|
||||
<solution>
|
||||
<div class="detailed-solution">
|
||||
<p>Explanation</p>
|
||||
<p><span style="font-family: 'courier new', courier;"><strong>Address          assembly instructions    comment<br />0x0              addi x1, x0, 1           x1 = 0x1<br />0x4              slli x2, x1, 4           x2 = x1 << 4 = 0x10<br />0x8              sub x1, x2, x1           x1 = x2 - x1 = 0x10 - 0x01 = 0xf</strong></span></p>
|
||||
</div>
|
||||
</solution>
|
||||
</multiplechoiceresponse>
|
||||
</problem>`,
|
||||
</problem>`,
|
||||
display_name: 'Dropdown',
|
||||
metadata: {
|
||||
markdown: `You can use this template as a guide to the simple editor markdown and OLX markup to use for dropdown problems. Edit this component to replace this template with your own assessment.
|
||||
@@ -68,6 +54,11 @@ export const fetchBlockById = ({ blockId, studioEndpointUrl }) => {
|
||||
weight: 29,
|
||||
},
|
||||
};
|
||||
} else if (blockId === 'game-block-id') {
|
||||
data = {
|
||||
display_name: 'Game Block',
|
||||
// TODO: insert mock data from backend here
|
||||
};
|
||||
}
|
||||
return mockPromise({ data: { ...data } });
|
||||
};
|
||||
@@ -152,7 +143,7 @@ export const fetchAdvanceSettings = ({ studioEndpointUrl, learningContextId }) =
|
||||
data: { allow_unsupported_xblocks: { value: true } },
|
||||
});
|
||||
// eslint-disable-next-line
|
||||
export const fetchVideoFeatures = ({ studioEndpointUrl, learningContextId }) => mockPromise({
|
||||
export const fetchVideoFeatures = ({ studioEndpointUrl }) => mockPromise({
|
||||
data: {
|
||||
allowThumbnailUpload: true,
|
||||
videoSharingEnabledForCourse: true,
|
||||
|
||||
@@ -7,26 +7,39 @@ export const unit = ({ studioEndpointUrl, unitUrl }) => (
|
||||
);
|
||||
|
||||
export const returnUrl = ({ studioEndpointUrl, unitUrl, learningContextId }) => {
|
||||
if (learningContextId && learningContextId.includes('library-v1')) {
|
||||
if (learningContextId && learningContextId.startsWith('library-v1')) {
|
||||
// when the learning context is a v1 library, return to the library page
|
||||
return libraryV1({ studioEndpointUrl, learningContextId });
|
||||
}
|
||||
if (learningContextId && learningContextId.startsWith('lib')) {
|
||||
// when it's a v2 library, there will be no return url (instead a closed popup)
|
||||
throw new Error('Return url not available (or needed) for V2 libraries');
|
||||
}
|
||||
// when the learning context is a course, return to the unit page
|
||||
return unitUrl ? unit({ studioEndpointUrl, unitUrl }) : '';
|
||||
if (unitUrl) {
|
||||
return unit({ studioEndpointUrl, unitUrl });
|
||||
}
|
||||
throw new Error('No unit url for return url');
|
||||
};
|
||||
|
||||
export const block = ({ studioEndpointUrl, blockId }) => (
|
||||
blockId.includes('block-v1')
|
||||
blockId.startsWith('block-v1')
|
||||
? `${studioEndpointUrl}/xblock/${blockId}`
|
||||
: `${studioEndpointUrl}/api/xblock/v2/xblocks/${blockId}`
|
||||
: `${studioEndpointUrl}/api/xblock/v2/xblocks/${blockId}/fields/`
|
||||
);
|
||||
|
||||
export const blockAncestor = ({ studioEndpointUrl, blockId }) => (
|
||||
`${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`
|
||||
);
|
||||
export const blockAncestor = ({ studioEndpointUrl, blockId }) => {
|
||||
if (blockId.startsWith('block-v1')) {
|
||||
return `${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`;
|
||||
}
|
||||
// this url only need to get info to build the return url, which isn't used by V2 blocks
|
||||
throw new Error('Block ancestor not available (and not needed) for V2 blocks');
|
||||
};
|
||||
|
||||
export const blockStudioView = ({ studioEndpointUrl, blockId }) => (
|
||||
`${block({ studioEndpointUrl, blockId })}/studio_view`
|
||||
blockId.startsWith('block-v1')
|
||||
? `${block({ studioEndpointUrl, blockId })}/studio_view`
|
||||
: `${studioEndpointUrl}/api/xblock/v2/xblocks/${blockId}/view/studio_view/`
|
||||
);
|
||||
|
||||
export const courseAssets = ({ studioEndpointUrl, learningContextId }) => (
|
||||
@@ -69,8 +82,8 @@ export const courseAdvanceSettings = ({ studioEndpointUrl, learningContextId })
|
||||
`${studioEndpointUrl}/api/contentstore/v0/advanced_settings/${learningContextId}`
|
||||
);
|
||||
|
||||
export const videoFeatures = ({ studioEndpointUrl, learningContextId }) => (
|
||||
`${studioEndpointUrl}/video_features/${learningContextId}`
|
||||
export const videoFeatures = ({ studioEndpointUrl }) => (
|
||||
`${studioEndpointUrl}/video_features/`
|
||||
);
|
||||
|
||||
export const courseVideos = ({ studioEndpointUrl, learningContextId }) => (
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('cms url methods', () => {
|
||||
const learningContextId = 'lEarnIngCOntextId123';
|
||||
const courseId = 'course-v1:courseId123';
|
||||
const libraryV1Id = 'library-v1:libaryId123';
|
||||
const libraryV2Id = 'lib:libaryId123';
|
||||
const language = 'la';
|
||||
const handout = '/aSSet@hANdoUt';
|
||||
const videoId = '123-SOmeVidEOid-213';
|
||||
@@ -41,17 +42,21 @@ describe('cms url methods', () => {
|
||||
],
|
||||
},
|
||||
};
|
||||
it('returns the library page when given the library', () => {
|
||||
it('returns the library page when given the v1 library', () => {
|
||||
expect(returnUrl({ studioEndpointUrl, unitUrl, learningContextId: libraryV1Id }))
|
||||
.toEqual(`${studioEndpointUrl}/library/${libraryV1Id}`);
|
||||
});
|
||||
it('throws error when given the v2 library', () => {
|
||||
expect(() => { returnUrl({ studioEndpointUrl, unitUrl, learningContextId: libraryV2Id }); })
|
||||
.toThrow('Return url not available (or needed) for V2 libraries');
|
||||
});
|
||||
it('returns url with studioEndpointUrl and unitUrl', () => {
|
||||
expect(returnUrl({ studioEndpointUrl, unitUrl, learningContextId: courseId }))
|
||||
.toEqual(`${studioEndpointUrl}/container/${unitUrl.data.ancestors[0].id}`);
|
||||
});
|
||||
it('returns empty string if no unit url', () => {
|
||||
expect(returnUrl({ studioEndpointUrl, unitUrl: null, learningContextId: courseId }))
|
||||
.toEqual('');
|
||||
it('throws error if no unit url', () => {
|
||||
expect(() => { returnUrl({ studioEndpointUrl, unitUrl: null, learningContextId: courseId }); })
|
||||
.toThrow('No unit url for return url');
|
||||
});
|
||||
it('returns the library page when given the library', () => {
|
||||
expect(libraryV1({ studioEndpointUrl, learningContextId: libraryV1Id }))
|
||||
@@ -69,7 +74,7 @@ describe('cms url methods', () => {
|
||||
});
|
||||
it('returns v2 url with studioEndpointUrl and v2BlockId', () => {
|
||||
expect(block({ studioEndpointUrl, blockId: v2BlockId }))
|
||||
.toEqual(`${studioEndpointUrl}/api/xblock/v2/xblocks/${v2BlockId}`);
|
||||
.toEqual(`${studioEndpointUrl}/api/xblock/v2/xblocks/${v2BlockId}/fields/`);
|
||||
});
|
||||
});
|
||||
describe('blockAncestor', () => {
|
||||
@@ -77,12 +82,20 @@ describe('cms url methods', () => {
|
||||
expect(blockAncestor({ studioEndpointUrl, blockId }))
|
||||
.toEqual(`${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`);
|
||||
});
|
||||
it('throws error with studioEndpointUrl, v2 blockId and ancestor query', () => {
|
||||
expect(() => { blockAncestor({ studioEndpointUrl, blockId: v2BlockId }); })
|
||||
.toThrow('Block ancestor not available (and not needed) for V2 blocks');
|
||||
});
|
||||
});
|
||||
describe('blockStudioView', () => {
|
||||
it('returns url with studioEndpointUrl, blockId and studio_view query', () => {
|
||||
it('returns v1 url with studioEndpointUrl, blockId and studio_view query', () => {
|
||||
expect(blockStudioView({ studioEndpointUrl, blockId }))
|
||||
.toEqual(`${block({ studioEndpointUrl, blockId })}/studio_view`);
|
||||
});
|
||||
it('returns v2 url with studioEndpointUrl, v2 blockId and studio_view query', () => {
|
||||
expect(blockStudioView({ studioEndpointUrl, blockId: v2BlockId }))
|
||||
.toEqual(`${studioEndpointUrl}/api/xblock/v2/xblocks/${v2BlockId}/view/studio_view/`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('courseAssets', () => {
|
||||
@@ -141,8 +154,8 @@ describe('cms url methods', () => {
|
||||
});
|
||||
describe('videoFeatures', () => {
|
||||
it('returns url with studioEndpointUrl and learningContextId', () => {
|
||||
expect(videoFeatures({ studioEndpointUrl, learningContextId }))
|
||||
.toEqual(`${studioEndpointUrl}/video_features/${learningContextId}`);
|
||||
expect(videoFeatures({ studioEndpointUrl }))
|
||||
.toEqual(`${studioEndpointUrl}/video_features/`);
|
||||
});
|
||||
});
|
||||
describe('courseVideos', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { actions, thunkActions } from './data/redux';
|
||||
import * as module from './hooks';
|
||||
import { RequestKeys } from './data/constants/requests';
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
export const initializeApp = ({ dispatch, data }) => useEffect(
|
||||
() => dispatch(thunkActions.app.initialize(data)),
|
||||
[data],
|
||||
@@ -54,15 +55,15 @@ export const saveBlock = ({
|
||||
attemptSave = true;
|
||||
}
|
||||
if (attemptSave) {
|
||||
dispatch(thunkActions.app.saveBlock({
|
||||
returnToUnit: module.navigateCallback({
|
||||
dispatch(thunkActions.app.saveBlock(
|
||||
content,
|
||||
module.navigateCallback({
|
||||
destination,
|
||||
analyticsEvent: analyticsEvt.editorSaveClick,
|
||||
analytics,
|
||||
returnFunction,
|
||||
}),
|
||||
content,
|
||||
}));
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -146,14 +146,14 @@ describe('hooks', () => {
|
||||
analytics,
|
||||
dispatch,
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledWith(thunkActions.app.saveBlock({
|
||||
returnToUnit: navigateCallback({
|
||||
expect(dispatch).toHaveBeenCalledWith(thunkActions.app.saveBlock(
|
||||
content,
|
||||
navigateCallback({
|
||||
destination,
|
||||
analyticsEvent: analyticsEvt.editorSaveClick,
|
||||
analytics,
|
||||
}),
|
||||
content,
|
||||
}));
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import './index.scss';
|
||||
const CODEMIRROR_LANGUAGES = { HTML: 'html', XML: 'xml' };
|
||||
|
||||
export const state = {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
showBtnEscapeHTML: (val) => React.useState(val),
|
||||
};
|
||||
|
||||
@@ -60,6 +61,7 @@ export const createCodeMirrorDomNode = ({
|
||||
upstreamRef,
|
||||
lang,
|
||||
}) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
const languageExtension = lang === CODEMIRROR_LANGUAGES.HTML ? html() : xml();
|
||||
const cleanText = cleanHTML({ initialText });
|
||||
|
||||
@@ -9,10 +9,12 @@ import messages from './messages';
|
||||
|
||||
export const hooks = {
|
||||
state: {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
isDismissed: (val) => React.useState(val),
|
||||
},
|
||||
dismissalHooks: ({ dismissError, isError }) => {
|
||||
const [isDismissed, setIsDismissed] = hooks.state.isDismissed(false);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
React.useEffect(
|
||||
() => {
|
||||
setIsDismissed(isDismissed && !isError);
|
||||
|
||||
@@ -11,7 +11,7 @@ jest.mock('@edx/frontend-platform/logging', () => ({
|
||||
}));
|
||||
|
||||
// stubbing this to avoid needing to inject a stubbed intl into an internal component
|
||||
jest.mock('./ErrorPage', () => function () {
|
||||
jest.mock('./ErrorPage', () => function mockErrorPage() {
|
||||
return <p>Error Page</p>;
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ exports[`ExpandableTextArea snapshots renders as expected with default behavior
|
||||
className="expandable-mce error"
|
||||
>
|
||||
<TinyMceWidget
|
||||
editorContentHtml="text"
|
||||
editorRef={
|
||||
Object {
|
||||
"current": null,
|
||||
@@ -14,7 +15,6 @@ exports[`ExpandableTextArea snapshots renders as expected with default behavior
|
||||
editorType="expandable"
|
||||
placeholder={null}
|
||||
setEditorRef={[Function]}
|
||||
textValue="text"
|
||||
updateContent={[MockFunction]}
|
||||
/>
|
||||
</div>
|
||||
@@ -27,6 +27,7 @@ exports[`ExpandableTextArea snapshots renders error message 1`] = `
|
||||
className="expandable-mce error"
|
||||
>
|
||||
<TinyMceWidget
|
||||
editorContentHtml="text"
|
||||
editorRef={
|
||||
Object {
|
||||
"current": null,
|
||||
@@ -35,7 +36,6 @@ exports[`ExpandableTextArea snapshots renders error message 1`] = `
|
||||
editorType="expandable"
|
||||
placeholder={null}
|
||||
setEditorRef={[Function]}
|
||||
textValue="text"
|
||||
updateContent={[MockFunction]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ export const ExpandableTextArea = ({
|
||||
<>
|
||||
<div className="expandable-mce error">
|
||||
<TinyMceWidget
|
||||
textValue={value}
|
||||
editorContentHtml={value}
|
||||
editorRef={editorRef}
|
||||
editorType="expandable"
|
||||
setEditorRef={setEditorRef}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const fileInput = ({ onAddFile }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const ref = React.useRef();
|
||||
const click = () => ref.current.click();
|
||||
const addFile = (e) => {
|
||||
|
||||
@@ -1,30 +1,57 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import * as paragon from '@edx/paragon';
|
||||
import * as icons from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
fireEvent, render, screen, waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { formatMessage } from '../../../../testUtils';
|
||||
import { DimensionControls } from './DimensionControls';
|
||||
import hooks from './hooks';
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
onInputChange: (handler) => ({ 'hooks.onInputChange': handler }),
|
||||
}));
|
||||
const WrappedDimensionControls = () => {
|
||||
const dimensions = hooks.dimensions('altText');
|
||||
|
||||
useEffect(() => {
|
||||
dimensions.onImgLoad({ })({ target: { naturalWidth: 1517, naturalHeight: 803 } });
|
||||
}, []);
|
||||
|
||||
return <DimensionControls {...dimensions} intl={{ formatMessage }} />;
|
||||
};
|
||||
|
||||
const UnlockedDimensionControls = () => {
|
||||
const dimensions = hooks.dimensions('altText');
|
||||
|
||||
useEffect(() => {
|
||||
dimensions.onImgLoad({ })({ target: { naturalWidth: 1517, naturalHeight: 803 } });
|
||||
dimensions.unlock();
|
||||
}, []);
|
||||
|
||||
return <DimensionControls {...dimensions} intl={{ formatMessage }} />;
|
||||
};
|
||||
|
||||
describe('DimensionControls', () => {
|
||||
const props = {
|
||||
lockDims: { width: 12, height: 15 },
|
||||
locked: { 'props.locked': 'lockedValue' },
|
||||
isLocked: true,
|
||||
value: { width: 20, height: 40 },
|
||||
// inject
|
||||
intl: { formatMessage },
|
||||
};
|
||||
beforeEach(() => {
|
||||
props.setWidth = jest.fn().mockName('props.setWidth');
|
||||
props.setHeight = jest.fn().mockName('props.setHeight');
|
||||
props.lock = jest.fn().mockName('props.lock');
|
||||
props.unlock = jest.fn().mockName('props.unlock');
|
||||
props.updateDimensions = jest.fn().mockName('props.updateDimensions');
|
||||
});
|
||||
describe('render', () => {
|
||||
const props = {
|
||||
lockAspectRatio: { width: 4, height: 5 },
|
||||
locked: { 'props.locked': 'lockedValue' },
|
||||
isLocked: true,
|
||||
value: { width: 20, height: 40 },
|
||||
// inject
|
||||
intl: { formatMessage },
|
||||
};
|
||||
beforeEach(() => {
|
||||
jest.spyOn(hooks, 'onInputChange').mockImplementation((handler) => ({ 'hooks.onInputChange': handler }));
|
||||
props.setWidth = jest.fn().mockName('props.setWidth');
|
||||
props.setHeight = jest.fn().mockName('props.setHeight');
|
||||
props.lock = jest.fn().mockName('props.lock');
|
||||
props.unlock = jest.fn().mockName('props.unlock');
|
||||
props.updateDimensions = jest.fn().mockName('props.updateDimensions');
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.spyOn(hooks, 'onInputChange').mockRestore();
|
||||
});
|
||||
test('snapshot', () => {
|
||||
expect(shallow(<DimensionControls {...props} />)).toMatchSnapshot();
|
||||
});
|
||||
@@ -38,4 +65,76 @@ describe('DimensionControls', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('component tests for dimensions', () => {
|
||||
beforeEach(() => {
|
||||
paragon.Form.Group = jest.fn().mockImplementation(({ children }) => (
|
||||
<div>{children}</div>
|
||||
));
|
||||
paragon.Form.Label = jest.fn().mockImplementation(({ children }) => (
|
||||
<div>{children}</div>
|
||||
));
|
||||
// eslint-disable-next-line no-import-assign
|
||||
paragon.Icon = jest.fn().mockImplementation(({ children }) => (
|
||||
<div>{children}</div>
|
||||
));
|
||||
// eslint-disable-next-line no-import-assign
|
||||
paragon.IconButton = jest.fn().mockImplementation(({ children }) => (
|
||||
<div>{children}</div>
|
||||
));
|
||||
paragon.Form.Control = jest.fn().mockImplementation(({ value, onChange, onBlur }) => (
|
||||
<input className="formControl" onChange={onChange} onBlur={onBlur} value={value} />
|
||||
));
|
||||
// eslint-disable-next-line no-import-assign
|
||||
icons.Locked = jest.fn().mockImplementation(() => {});
|
||||
// eslint-disable-next-line no-import-assign
|
||||
icons.Unlocked = jest.fn().mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
paragon.Form.Group.mockRestore();
|
||||
paragon.Form.Label.mockRestore();
|
||||
paragon.Form.Control.mockRestore();
|
||||
paragon.Icon.mockRestore();
|
||||
paragon.IconButton.mockRestore();
|
||||
icons.Locked.mockRestore();
|
||||
icons.Unlocked.mockRestore();
|
||||
});
|
||||
|
||||
it('renders with initial dimensions', () => {
|
||||
const { container } = render(<WrappedDimensionControls />);
|
||||
const widthInput = container.querySelector('.formControl');
|
||||
expect(widthInput.value).toBe('1517');
|
||||
});
|
||||
|
||||
it('resizes dimensions proportionally', async () => {
|
||||
const { container } = render(<WrappedDimensionControls />);
|
||||
const widthInput = container.querySelector('.formControl');
|
||||
expect(widthInput.value).toBe('1517');
|
||||
fireEvent.change(widthInput, { target: { value: 758 } });
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('.formControl')[0].value).toBe('758');
|
||||
});
|
||||
fireEvent.blur(widthInput);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('.formControl')[0].value).toBe('758');
|
||||
expect(container.querySelectorAll('.formControl')[1].value).toBe('401');
|
||||
});
|
||||
screen.debug();
|
||||
});
|
||||
|
||||
it('resizes only changed dimension when unlocked', async () => {
|
||||
const { container } = render(<UnlockedDimensionControls />);
|
||||
const widthInput = container.querySelector('.formControl');
|
||||
expect(widthInput.value).toBe('1517');
|
||||
fireEvent.change(widthInput, { target: { value: 758 } });
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('.formControl')[0].value).toBe('758');
|
||||
});
|
||||
fireEvent.blur(widthInput);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('.formControl')[0].value).toBe('758');
|
||||
expect(container.querySelectorAll('.formControl')[1].value).toBe('803');
|
||||
});
|
||||
screen.debug();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ exports[`ImageSettingsModal render snapshot 1`] = `
|
||||
"altText": Object {
|
||||
"error": Object {
|
||||
"dismiss": [MockFunction],
|
||||
"show": "sHoW",
|
||||
"show": true,
|
||||
},
|
||||
"isDecorative": false,
|
||||
"value": "alternative Taxes",
|
||||
@@ -45,7 +45,7 @@ exports[`ImageSettingsModal render snapshot 1`] = `
|
||||
<ErrorAlert
|
||||
dismissError={[MockFunction]}
|
||||
hideHeading={true}
|
||||
isError="sHoW"
|
||||
isError={true}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Enter alt text or specify that the image is decorative only."
|
||||
@@ -162,7 +162,7 @@ exports[`ImageSettingsModal render snapshot 1`] = `
|
||||
error={
|
||||
Object {
|
||||
"dismiss": [MockFunction],
|
||||
"show": "sHoW",
|
||||
"show": true,
|
||||
}
|
||||
}
|
||||
isDecorative={false}
|
||||
|
||||
@@ -5,15 +5,22 @@ import * as module from './hooks';
|
||||
|
||||
// Simple wrappers for useState to allow easy mocking for tests.
|
||||
export const state = {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
altText: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
dimensions: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
showAltTextDismissibleError: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
showAltTextSubmissionError: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
isDecorative: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
isLocked: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
local: (val) => React.useState(val),
|
||||
lockDims: (val) => React.useState(val),
|
||||
lockInitialized: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
lockAspectRatio: (val) => React.useState(val),
|
||||
};
|
||||
|
||||
export const dimKeys = StrictDict({
|
||||
@@ -23,12 +30,21 @@ export const dimKeys = StrictDict({
|
||||
|
||||
/**
|
||||
* findGcd(numerator, denominator)
|
||||
* Find the greatest common denominator of a ratio or fraction.
|
||||
* Find the greatest common denominator of a ratio or fraction, which may be 1.
|
||||
* @param {number} numerator - ratio numerator
|
||||
* @param {number} denominator - ratio denominator
|
||||
* @return {number} - ratio greatest common denominator
|
||||
*/
|
||||
export const findGcd = (a, b) => (b ? findGcd(b, a % b) : a);
|
||||
export const findGcd = (a, b) => {
|
||||
const gcd = b ? findGcd(b, a % b) : a;
|
||||
|
||||
if (gcd === 1 || [a, b].some(v => !Number.isInteger(v / gcd))) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return gcd;
|
||||
};
|
||||
|
||||
const checkEqual = (d1, d2) => (d1.height === d2.height && d1.width === d2.width);
|
||||
|
||||
/**
|
||||
@@ -43,34 +59,38 @@ export const getValidDimensions = ({
|
||||
dimensions,
|
||||
local,
|
||||
isLocked,
|
||||
lockDims,
|
||||
lockAspectRatio,
|
||||
}) => {
|
||||
// if lock is not active, just return new dimensions.
|
||||
// If lock is active, but dimensions have not changed, also just return new dimensions.
|
||||
if (!isLocked || checkEqual(local, dimensions)) {
|
||||
return local;
|
||||
}
|
||||
const out = {};
|
||||
let iter;
|
||||
const isMin = dimensions.height === lockDims.height;
|
||||
|
||||
const out = {};
|
||||
|
||||
// changed key is value of local height if that has changed, otherwise width.
|
||||
const keys = (local.height !== dimensions.height)
|
||||
? { changed: dimKeys.height, other: dimKeys.width }
|
||||
: { changed: dimKeys.width, other: dimKeys.height };
|
||||
|
||||
const direction = local[keys.changed] > dimensions[keys.changed] ? 1 : -1;
|
||||
|
||||
// don't move down if already at minimum size
|
||||
if (direction < 0 && isMin) { return dimensions; }
|
||||
// find closest valid iteration of the changed field
|
||||
iter = Math.max(Math.round(local[keys.changed] / lockDims[keys.changed]), 1);
|
||||
// if closest valid iteration is current iteration, move one iteration in the change direction
|
||||
if (iter === (dimensions[keys.changed] / lockDims[keys.changed])) { iter += direction; }
|
||||
|
||||
out[keys.changed] = Math.round(iter * lockDims[keys.changed]);
|
||||
out[keys.other] = Math.round(out[keys.changed] * (lockDims[keys.other] / lockDims[keys.changed]));
|
||||
out[keys.changed] = local[keys.changed];
|
||||
out[keys.other] = Math.round((local[keys.changed] * lockAspectRatio[keys.other]) / lockAspectRatio[keys.changed]);
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* reduceDimensions(width, height)
|
||||
* reduces both values by dividing by their greates common denominator (which can simply be 1).
|
||||
* @return {Array} [width, height]
|
||||
*/
|
||||
export const reduceDimensions = (width, height) => {
|
||||
const gcd = module.findGcd(width, height);
|
||||
|
||||
return [width / gcd, height / gcd];
|
||||
};
|
||||
|
||||
/**
|
||||
* dimensionLockHooks({ dimensions })
|
||||
* Returns a set of hooks pertaining to the dimension locks.
|
||||
@@ -79,28 +99,26 @@ export const getValidDimensions = ({
|
||||
* @return {obj} - dimension lock hooks
|
||||
* {func} initializeLock - enable the lock mechanism
|
||||
* {bool} isLocked - are dimensions locked?
|
||||
* {obj} lockDims - image dimensions ({ height, width })
|
||||
* {obj} lockAspectRatio - image dimensions ({ height, width })
|
||||
* {func} lock - lock the dimensions
|
||||
* {func} unlock - unlock the dimensions
|
||||
*/
|
||||
export const dimensionLockHooks = () => {
|
||||
const [lockDims, setLockDims] = module.state.lockDims(null);
|
||||
const [lockAspectRatio, setLockAspectRatio] = module.state.lockAspectRatio(null);
|
||||
const [isLocked, setIsLocked] = module.state.isLocked(true);
|
||||
|
||||
const initializeLock = ({ width, height }) => {
|
||||
// find minimum viable increment
|
||||
let gcd = module.findGcd(width, height);
|
||||
if ([width, height].some(v => !Number.isInteger(v / gcd))) {
|
||||
gcd = 1;
|
||||
}
|
||||
setLockDims({ width: width / gcd, height: height / gcd });
|
||||
// width and height are treated as a fraction and reduced.
|
||||
const [w, h] = reduceDimensions(width, height);
|
||||
|
||||
setLockAspectRatio({ width: w, height: h });
|
||||
};
|
||||
|
||||
return {
|
||||
initializeLock,
|
||||
isLocked,
|
||||
lock: () => setIsLocked(true),
|
||||
lockDims,
|
||||
lockAspectRatio,
|
||||
unlock: () => setIsLocked(false),
|
||||
};
|
||||
};
|
||||
@@ -135,6 +153,7 @@ export const dimensionLockHooks = () => {
|
||||
export const dimensionHooks = (altTextHook) => {
|
||||
const [dimensions, setDimensions] = module.state.dimensions(null);
|
||||
const [local, setLocal] = module.state.local(null);
|
||||
|
||||
const setAll = ({ height, width, altText }) => {
|
||||
if (altText === '' || altText) {
|
||||
if (altText === '') {
|
||||
@@ -145,11 +164,30 @@ export const dimensionHooks = (altTextHook) => {
|
||||
setDimensions({ height, width });
|
||||
setLocal({ height, width });
|
||||
};
|
||||
|
||||
const setHeight = (height) => {
|
||||
if (height.match(/[0-9]+[%]{1}/)) {
|
||||
const heightPercent = height.match(/[0-9]+[%]{1}/)[0];
|
||||
setLocal({ ...local, height: heightPercent });
|
||||
} else if (height.match(/[0-9]/)) {
|
||||
setLocal({ ...local, height: parseInt(height, 10) });
|
||||
}
|
||||
};
|
||||
|
||||
const setWidth = (width) => {
|
||||
if (width.match(/[0-9]+[%]{1}/)) {
|
||||
const widthPercent = width.match(/[0-9]+[%]{1}/)[0];
|
||||
setLocal({ ...local, width: widthPercent });
|
||||
} else if (width.match(/[0-9]/)) {
|
||||
setLocal({ ...local, width: parseInt(width, 10) });
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
initializeLock,
|
||||
isLocked,
|
||||
lock,
|
||||
lockDims,
|
||||
lockAspectRatio,
|
||||
unlock,
|
||||
} = module.dimensionLockHooks({ dimensions });
|
||||
|
||||
@@ -157,33 +195,19 @@ export const dimensionHooks = (altTextHook) => {
|
||||
onImgLoad: (selection) => ({ target: img }) => {
|
||||
const imageDims = { height: img.naturalHeight, width: img.naturalWidth };
|
||||
setAll(selection.height ? selection : imageDims);
|
||||
initializeLock(imageDims);
|
||||
initializeLock(selection.height ? selection : imageDims);
|
||||
},
|
||||
isLocked,
|
||||
lock,
|
||||
unlock,
|
||||
value: local,
|
||||
setHeight: (height) => {
|
||||
if (height.match(/[0-9]+[%]{1}/)) {
|
||||
const heightPercent = height.match(/[0-9]+[%]{1}/)[0];
|
||||
setLocal({ ...local, height: heightPercent });
|
||||
} else if (height.match(/[0-9]/)) {
|
||||
setLocal({ ...local, height: parseInt(height, 10) });
|
||||
}
|
||||
},
|
||||
setWidth: (width) => {
|
||||
if (width.match(/[0-9]+[%]{1}/)) {
|
||||
const widthPercent = width.match(/[0-9]+[%]{1}/)[0];
|
||||
setLocal({ ...local, width: widthPercent });
|
||||
} else if (width.match(/[0-9]/)) {
|
||||
setLocal({ ...local, width: parseInt(width, 10) });
|
||||
}
|
||||
},
|
||||
setHeight,
|
||||
setWidth,
|
||||
updateDimensions: () => setAll(module.getValidDimensions({
|
||||
dimensions,
|
||||
local,
|
||||
isLocked,
|
||||
lockDims,
|
||||
lockAspectRatio,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ const testVal = 'MY test VALUE';
|
||||
|
||||
describe('state values', () => {
|
||||
const testStateMethod = (key) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
expect(hooks.state[key](testVal)).toEqual(React.useState(testVal));
|
||||
};
|
||||
test('provides altText state value', () => testStateMethod(state.keys.altText));
|
||||
@@ -39,8 +40,7 @@ describe('state values', () => {
|
||||
test('provides isDecorative state value', () => testStateMethod(state.keys.isDecorative));
|
||||
test('provides isLocked state value', () => testStateMethod(state.keys.isLocked));
|
||||
test('provides local state value', () => testStateMethod(state.keys.local));
|
||||
test('provides lockDims state value', () => testStateMethod(state.keys.lockDims));
|
||||
test('provides lockInitialized state value', () => testStateMethod(state.keys.lockInitialized));
|
||||
test('provides lockAspectRatio state value', () => testStateMethod(state.keys.lockAspectRatio));
|
||||
});
|
||||
|
||||
describe('ImageSettingsModal hooks', () => {
|
||||
@@ -54,7 +54,7 @@ describe('ImageSettingsModal hooks', () => {
|
||||
dimensions: simpleDims,
|
||||
local: reducedDims,
|
||||
isLocked: false,
|
||||
lockDims: simpleDims,
|
||||
lockAspectRatio: simpleDims,
|
||||
})).toEqual(reducedDims);
|
||||
});
|
||||
it('returns local dimensions if the same as stored', () => {
|
||||
@@ -62,64 +62,48 @@ describe('ImageSettingsModal hooks', () => {
|
||||
dimensions: simpleDims,
|
||||
local: simpleDims,
|
||||
isLocked: true,
|
||||
lockDims: reducedDims,
|
||||
lockAspectRatio: reducedDims,
|
||||
})).toEqual(simpleDims);
|
||||
});
|
||||
describe('decreasing change when at minimum valid increment', () => {
|
||||
it('returns current dimensions', () => {
|
||||
const dimensions = { ...reducedDims };
|
||||
const lockDims = { ...dimensions };
|
||||
let local = { ...dimensions, width: dimensions.width - 1 };
|
||||
expect(
|
||||
hooks.getValidDimensions({
|
||||
dimensions,
|
||||
isLocked: true,
|
||||
local,
|
||||
lockDims,
|
||||
}),
|
||||
).toEqual(dimensions);
|
||||
local = { ...dimensions, height: dimensions.height - 1 };
|
||||
expect(
|
||||
hooks.getValidDimensions({
|
||||
dimensions,
|
||||
isLocked: true,
|
||||
local,
|
||||
lockDims,
|
||||
}),
|
||||
).toEqual(dimensions);
|
||||
});
|
||||
});
|
||||
describe('valid change', () => {
|
||||
it(
|
||||
'returns the nearest valid pair of dimensions in the change direction',
|
||||
describe('valid change when aspect ratio is locked', () => {
|
||||
describe(
|
||||
'keeps changed dimension and keeps the other dimension proportional but rounded',
|
||||
() => {
|
||||
const [w, h] = [7, 13];
|
||||
const values = [
|
||||
// bumps up if direction is up but nearest is current
|
||||
[[w + 1, h], [w * 2, h * 2]],
|
||||
[[w + 1, h], [w * 2, h * 2]],
|
||||
// bumps up if just below next
|
||||
[[w, 2 * h - 1], [w * 2, h * 2]],
|
||||
[[w, 2 * h - 1], [w * 2, h * 2]],
|
||||
// rounds down to next if that is closest
|
||||
[[w, 2 * h + 1], [w * 2, h * 2]],
|
||||
[[w, 2 * h + 1], [w * 2, h * 2]],
|
||||
// ensure is not locked to second iteration, by getting close to 3rd
|
||||
[[w, 3 * h - 1], [w * 3, h * 3]],
|
||||
[[w, 3 * h - 1], [w * 3, h * 3]],
|
||||
];
|
||||
values.forEach(([local, expected]) => {
|
||||
|
||||
const testDimensions = (newDimensions, expected) => {
|
||||
const dimensions = { width: w, height: h };
|
||||
expect(hooks.getValidDimensions({
|
||||
dimensions,
|
||||
local: { width: local[0], height: local[1] },
|
||||
lockDims: { ...dimensions },
|
||||
local: { width: newDimensions[0], height: newDimensions[1] },
|
||||
lockAspectRatio: { ...dimensions },
|
||||
isLocked: true,
|
||||
})).toEqual({ width: expected[0], height: expected[1] });
|
||||
};
|
||||
|
||||
it('if width is increased, increases and rounds height to stay proportional', () => {
|
||||
testDimensions([8, h], [8, 15]);
|
||||
});
|
||||
it('if height is increased, increases and rounds width to stay proportional', () => {
|
||||
testDimensions([w, 25], [13, 25]);
|
||||
});
|
||||
it('if width is decreased, decreases and rounds height to stay proportional', () => {
|
||||
testDimensions([6, h], [6, 11]);
|
||||
});
|
||||
it('if height is decreased, decreases and rounds width to stay proportional', () => {
|
||||
testDimensions([7, 10], [5, 10]);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
it('calculates new dimensions proportionally and correctly when lock is active', () => {
|
||||
expect(hooks.getValidDimensions({
|
||||
dimensions: { width: 1517, height: 803 },
|
||||
local: { width: 758, height: 803 },
|
||||
isLocked: true,
|
||||
lockAspectRatio: { width: 1517, height: 803 },
|
||||
})).toEqual({ width: 758, height: 401 });
|
||||
});
|
||||
});
|
||||
describe('dimensionLockHooks', () => {
|
||||
beforeEach(() => {
|
||||
@@ -129,21 +113,21 @@ describe('ImageSettingsModal hooks', () => {
|
||||
afterEach(() => {
|
||||
state.restore();
|
||||
});
|
||||
test('lockDims defaults to null', () => {
|
||||
expect(hook.lockDims).toEqual(null);
|
||||
test('lockAspectRatio defaults to null', () => {
|
||||
expect(hook.lockAspectRatio).toEqual(null);
|
||||
});
|
||||
test('isLocked defaults to true', () => {
|
||||
expect(hook.isLocked).toEqual(true);
|
||||
});
|
||||
describe('initializeLock', () => {
|
||||
it('calls setLockDims with the passed dimensions divided by their gcd', () => {
|
||||
it('calls setLockAspectRatio with the passed dimensions divided by their gcd', () => {
|
||||
hook.initializeLock(multiDims);
|
||||
expect(state.setState.lockDims).toHaveBeenCalledWith(reducedDims);
|
||||
expect(state.setState.lockAspectRatio).toHaveBeenCalledWith(reducedDims);
|
||||
});
|
||||
it('returns the values themselves if they have no gcd', () => {
|
||||
jest.spyOn(hooks, hookKeys.findGcd).mockReturnValueOnce(2);
|
||||
jest.spyOn(hooks, hookKeys.findGcd).mockReturnValueOnce(1);
|
||||
hook.initializeLock(simpleDims);
|
||||
expect(state.setState.lockDims).toHaveBeenCalledWith(simpleDims);
|
||||
expect(state.setState.lockAspectRatio).toHaveBeenCalledWith(simpleDims);
|
||||
});
|
||||
});
|
||||
test('lock sets isLocked to true', () => {
|
||||
@@ -224,14 +208,14 @@ describe('ImageSettingsModal hooks', () => {
|
||||
const getValidDimensions = (args) => ({ ...testDims(args), junk: 'data' });
|
||||
state.mockVal(state.keys.isLocked, true);
|
||||
state.mockVal(state.keys.dimensions, simpleDims);
|
||||
state.mockVal(state.keys.lockDims, reducedDims);
|
||||
state.mockVal(state.keys.lockAspectRatio, reducedDims);
|
||||
state.mockVal(state.keys.local, multiDims);
|
||||
jest.spyOn(hooks, hookKeys.getValidDimensions).mockImplementationOnce(getValidDimensions);
|
||||
hook = hooks.dimensionHooks();
|
||||
hook.updateDimensions();
|
||||
const expected = testDims({
|
||||
dimensions: simpleDims,
|
||||
lockDims: reducedDims,
|
||||
lockAspectRatio: reducedDims,
|
||||
local: multiDims,
|
||||
isLocked: true,
|
||||
});
|
||||
@@ -244,8 +228,8 @@ describe('ImageSettingsModal hooks', () => {
|
||||
describe('altTextHooks', () => {
|
||||
const value = 'myVAL';
|
||||
const isDecorative = true;
|
||||
const showAltTextDismissibleError = 'dismiSSiBLE';
|
||||
const showAltTextSubmissionError = 'subMISsion';
|
||||
const showAltTextDismissibleError = true;
|
||||
const showAltTextSubmissionError = true;
|
||||
beforeEach(() => {
|
||||
state.mock();
|
||||
hook = hooks.altTextHooks();
|
||||
@@ -388,4 +372,16 @@ describe('ImageSettingsModal hooks', () => {
|
||||
expect(props.saveToEditor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('findGcd', () => {
|
||||
it('should return correct gcd', () => {
|
||||
expect(hooks.findGcd(9, 12)).toBe(3);
|
||||
expect(hooks.findGcd(3, 4)).toBe(1);
|
||||
});
|
||||
});
|
||||
describe('reduceDimensions', () => {
|
||||
it('should return correct gcd', () => {
|
||||
expect(hooks.reduceDimensions(9, 12)).toEqual([3, 4]);
|
||||
expect(hooks.reduceDimensions(7, 8)).toEqual([7, 8]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ jest.mock('./DimensionControls', () => 'DimensionControls');
|
||||
jest.mock('./hooks', () => ({
|
||||
altText: () => ({
|
||||
error: {
|
||||
show: 'sHoW',
|
||||
show: true,
|
||||
dismiss: jest.fn(),
|
||||
},
|
||||
isDecorative: false,
|
||||
|
||||
@@ -7,10 +7,15 @@ import { sortFunctions, sortKeys, sortMessages } from './utils';
|
||||
import messages from './messages';
|
||||
|
||||
export const state = {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
highlighted: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
showSelectImageError: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
searchString: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
sortBy: (val) => React.useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
showSizeError: (val) => React.useState(val),
|
||||
};
|
||||
|
||||
@@ -29,7 +34,7 @@ export const searchAndSortHooks = () => {
|
||||
};
|
||||
|
||||
export const filteredList = ({ searchString, imageList }) => (
|
||||
imageList.filter(({ displayName }) => displayName.toLowerCase().includes(searchString.toLowerCase()))
|
||||
imageList.filter(({ displayName }) => displayName?.toLowerCase().includes(searchString?.toLowerCase()))
|
||||
);
|
||||
|
||||
export const displayList = ({ sortBy, searchString, images }) => (
|
||||
@@ -98,7 +103,9 @@ export const checkValidFileSize = ({
|
||||
};
|
||||
|
||||
export const fileInputHooks = ({ setSelection, clearSelection, imgList }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const dispatch = useDispatch();
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const ref = React.useRef();
|
||||
const click = () => ref.current.click();
|
||||
const addFile = (e) => {
|
||||
|
||||
@@ -25,7 +25,7 @@ export const SelectImageModal = ({
|
||||
galleryProps,
|
||||
searchSortProps,
|
||||
selectBtnProps,
|
||||
} = hooks.imgHooks({ setSelection, clearSelection, images });
|
||||
} = hooks.imgHooks({ setSelection, clearSelection, images: images.current });
|
||||
|
||||
const modalMessages = {
|
||||
confirmMsg: messages.nextButtonLabel,
|
||||
|
||||
@@ -6,6 +6,30 @@ import SelectionModal from '../../SelectionModal';
|
||||
import hooks from './hooks';
|
||||
import { SelectImageModal } from '.';
|
||||
|
||||
const mockImage = {
|
||||
displayName: 'DALL·E 2023-03-10.png',
|
||||
contentType: 'image/png',
|
||||
dateAdded: 1682009100000,
|
||||
url: '/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png',
|
||||
externalUrl: 'http://localhost:18000/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png',
|
||||
portableUrl: '/static/DALL_E_2023-03-10.png',
|
||||
thumbnail: '/asset-v1:TestX+Test01+Test0101+type@thumbnail+block@DALL_E_2023-03-10.jpg',
|
||||
locked: false,
|
||||
staticFullUrl: '/assets/courseware/v1/af2bf9ac70804e54c534107160a8e51e/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png',
|
||||
id: 'asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png',
|
||||
width: 100,
|
||||
height: 150,
|
||||
};
|
||||
|
||||
const mockImagesRef = { current: [mockImage] };
|
||||
|
||||
jest.mock('../../BaseModal', () => 'BaseModal');
|
||||
jest.mock('../../FileInput', () => 'FileInput');
|
||||
jest.mock('../../SelectionModal/Gallery', () => 'Gallery');
|
||||
jest.mock('../../SelectionModal/SearchSort', () => 'SearchSort');
|
||||
jest.mock('../../ErrorAlerts/FetchErrorAlert', () => 'FetchErrorAlert');
|
||||
jest.mock('../../ErrorAlerts/UploadErrorAlert', () => 'UploadErrorAlert');
|
||||
jest.mock('../..//ErrorAlerts/ErrorAlert', () => 'ErrorAlert');
|
||||
jest.mock('../../SelectionModal', () => 'SelectionModal');
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
@@ -56,6 +80,7 @@ describe('SelectImageModal', () => {
|
||||
close: jest.fn().mockName('props.close'),
|
||||
setSelection: jest.fn().mockName('props.setSelection'),
|
||||
clearSelection: jest.fn().mockName('props.clearSelection'),
|
||||
images: mockImagesRef,
|
||||
intl: { formatMessage },
|
||||
};
|
||||
let el;
|
||||
|
||||
@@ -4,6 +4,55 @@ exports[`ImageUploadModal component snapshot: no selection (Select Image Modal)
|
||||
<SelectImageModal
|
||||
clearSelection={[MockFunction props.clearSelection]}
|
||||
close={[MockFunction props.close]}
|
||||
images={
|
||||
Object {
|
||||
"current": Array [
|
||||
Object {
|
||||
"contentType": "image/png",
|
||||
"dateAdded": 1682009100000,
|
||||
"displayName": "DALL·E 2023-03-10.png",
|
||||
"externalUrl": "http://localhost:18000/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png",
|
||||
"height": 150,
|
||||
"id": "asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png",
|
||||
"locked": false,
|
||||
"portableUrl": "/static/DALL_E_2023-03-10.png",
|
||||
"staticFullUrl": "/assets/courseware/v1/af2bf9ac70804e54c534107160a8e51e/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png",
|
||||
"thumbnail": "/asset-v1:TestX+Test01+Test0101+type@thumbnail+block@DALL_E_2023-03-10.jpg",
|
||||
"url": "/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png",
|
||||
"width": 100,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
isOpen={false}
|
||||
setSelection={[MockFunction props.setSelection]}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`ImageUploadModal component snapshot: selection has no externalUrl (Select Image Modal) 1`] = `
|
||||
<SelectImageModal
|
||||
clearSelection={[MockFunction props.clearSelection]}
|
||||
close={[MockFunction props.close]}
|
||||
images={
|
||||
Object {
|
||||
"current": Array [
|
||||
Object {
|
||||
"contentType": "image/png",
|
||||
"dateAdded": 1682009100000,
|
||||
"displayName": "DALL·E 2023-03-10.png",
|
||||
"externalUrl": "http://localhost:18000/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png",
|
||||
"height": 150,
|
||||
"id": "asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png",
|
||||
"locked": false,
|
||||
"portableUrl": "/static/DALL_E_2023-03-10.png",
|
||||
"staticFullUrl": "/assets/courseware/v1/af2bf9ac70804e54c534107160a8e51e/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png",
|
||||
"thumbnail": "/asset-v1:TestX+Test01+Test0101+type@thumbnail+block@DALL_E_2023-03-10.jpg",
|
||||
"url": "/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png",
|
||||
"width": 100,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
isOpen={false}
|
||||
setSelection={[MockFunction props.setSelection]}
|
||||
/>
|
||||
@@ -11,10 +60,31 @@ exports[`ImageUploadModal component snapshot: no selection (Select Image Modal)
|
||||
|
||||
exports[`ImageUploadModal component snapshot: with selection content (ImageSettingsUpload) 1`] = `
|
||||
<ImageSettingsModal
|
||||
images={
|
||||
Object {
|
||||
"current": Array [
|
||||
Object {
|
||||
"contentType": "image/png",
|
||||
"dateAdded": 1682009100000,
|
||||
"displayName": "DALL·E 2023-03-10.png",
|
||||
"externalUrl": "http://localhost:18000/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png",
|
||||
"height": 150,
|
||||
"id": "asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png",
|
||||
"locked": false,
|
||||
"portableUrl": "/static/DALL_E_2023-03-10.png",
|
||||
"staticFullUrl": "/assets/courseware/v1/af2bf9ac70804e54c534107160a8e51e/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png",
|
||||
"thumbnail": "/asset-v1:TestX+Test01+Test0101+type@thumbnail+block@DALL_E_2023-03-10.jpg",
|
||||
"url": "/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png",
|
||||
"width": 100,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
isOpen={false}
|
||||
returnToSelection={[MockFunction props.clearSelection]}
|
||||
selection={
|
||||
Object {
|
||||
"externalUrl": "sOmEuRl.cOm",
|
||||
"some": "images",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import tinyMCEKeys from '../../data/constants/tinyMCE';
|
||||
import ImageSettingsModal from './ImageSettingsModal';
|
||||
import SelectImageModal from './SelectImageModal';
|
||||
import * as module from '.';
|
||||
import { updateImageDimensions } from '../TinyMceWidget/hooks';
|
||||
|
||||
export const propsString = (props) => (
|
||||
Object.keys(props).map((key) => `${key}="${props[key]}"`).join(' ')
|
||||
@@ -17,8 +18,8 @@ export const imgProps = ({
|
||||
lmsEndpointUrl,
|
||||
editorType,
|
||||
}) => {
|
||||
let url = selection.externalUrl;
|
||||
if (url.startsWith(lmsEndpointUrl) && editorType !== 'expandable') {
|
||||
let url = selection?.externalUrl;
|
||||
if (url?.startsWith(lmsEndpointUrl) && editorType !== 'expandable') {
|
||||
const sourceEndIndex = lmsEndpointUrl.length;
|
||||
url = url.substring(sourceEndIndex);
|
||||
}
|
||||
@@ -30,28 +31,61 @@ export const imgProps = ({
|
||||
};
|
||||
};
|
||||
|
||||
export const saveToEditor = ({
|
||||
settings, selection, lmsEndpointUrl, editorType, editorRef,
|
||||
}) => {
|
||||
const newImgTag = module.hooks.imgTag({
|
||||
settings,
|
||||
selection,
|
||||
lmsEndpointUrl,
|
||||
editorType,
|
||||
});
|
||||
|
||||
editorRef.current.execCommand(
|
||||
tinyMCEKeys.commands.insertContent,
|
||||
false,
|
||||
newImgTag,
|
||||
);
|
||||
};
|
||||
|
||||
export const updateImagesRef = ({
|
||||
images, selection, height, width, newImage,
|
||||
}) => {
|
||||
const { result: mappedImages, foundMatch: imageAlreadyExists } = updateImageDimensions({
|
||||
images: images.current, url: selection.externalUrl, height, width,
|
||||
});
|
||||
|
||||
images.current = imageAlreadyExists ? mappedImages : [...images.current, newImage];
|
||||
};
|
||||
|
||||
export const updateReactState = ({
|
||||
settings, selection, setSelection, images,
|
||||
}) => {
|
||||
const { height, width } = settings.dimensions;
|
||||
const newImage = {
|
||||
externalUrl: selection.externalUrl,
|
||||
altText: settings.altText,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
updateImagesRef({
|
||||
images, selection, height, width, newImage,
|
||||
});
|
||||
|
||||
setSelection(newImage);
|
||||
};
|
||||
|
||||
export const hooks = {
|
||||
createSaveCallback: ({
|
||||
close,
|
||||
editorRef,
|
||||
editorType,
|
||||
setSelection,
|
||||
selection,
|
||||
lmsEndpointUrl,
|
||||
...args
|
||||
}) => (
|
||||
settings,
|
||||
) => {
|
||||
editorRef.current.execCommand(
|
||||
tinyMCEKeys.commands.insertContent,
|
||||
false,
|
||||
module.hooks.imgTag({
|
||||
settings,
|
||||
selection,
|
||||
lmsEndpointUrl,
|
||||
editorType,
|
||||
}),
|
||||
);
|
||||
setSelection(null);
|
||||
saveToEditor({ settings, ...args });
|
||||
updateReactState({ settings, ...args });
|
||||
|
||||
close();
|
||||
},
|
||||
onClose: ({ clearSelection, close }) => () => {
|
||||
@@ -72,6 +106,11 @@ export const hooks = {
|
||||
});
|
||||
return `<img ${propsString(props)} />`;
|
||||
},
|
||||
updateReactState,
|
||||
updateImagesRef,
|
||||
saveToEditor,
|
||||
imgProps,
|
||||
propsString,
|
||||
};
|
||||
|
||||
export const ImageUploadModal = ({
|
||||
@@ -86,15 +125,17 @@ export const ImageUploadModal = ({
|
||||
editorType,
|
||||
lmsEndpointUrl,
|
||||
}) => {
|
||||
if (selection) {
|
||||
if (selection && selection.externalUrl) {
|
||||
return (
|
||||
<ImageSettingsModal
|
||||
{...{
|
||||
isOpen,
|
||||
close: module.hooks.onClose({ clearSelection, close }),
|
||||
close: module.hooks.onClose({ editorRef, clearSelection, close }),
|
||||
selection,
|
||||
images,
|
||||
saveToEditor: module.hooks.createSaveCallback({
|
||||
close,
|
||||
images,
|
||||
editorRef,
|
||||
editorType,
|
||||
selection,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { keyStore } from '../../utils';
|
||||
import tinyMCEKeys from '../../data/constants/tinyMCE';
|
||||
|
||||
import * as module from '.';
|
||||
import * as tinyMceHooks from '../TinyMceWidget/hooks';
|
||||
|
||||
jest.mock('./ImageSettingsModal', () => 'ImageSettingsModal');
|
||||
jest.mock('./SelectImageModal', () => 'SelectImageModal');
|
||||
@@ -22,7 +23,28 @@ const settings = {
|
||||
},
|
||||
};
|
||||
|
||||
const mockImage = {
|
||||
displayName: 'DALL·E 2023-03-10.png',
|
||||
contentType: 'image/png',
|
||||
dateAdded: 1682009100000,
|
||||
url: '/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png',
|
||||
externalUrl: 'http://localhost:18000/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png',
|
||||
portableUrl: '/static/DALL_E_2023-03-10.png',
|
||||
thumbnail: '/asset-v1:TestX+Test01+Test0101+type@thumbnail+block@DALL_E_2023-03-10.jpg',
|
||||
locked: false,
|
||||
staticFullUrl: '/assets/courseware/v1/af2bf9ac70804e54c534107160a8e51e/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png',
|
||||
id: 'asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png',
|
||||
width: 100,
|
||||
height: 150,
|
||||
};
|
||||
|
||||
let mockImagesRef = { current: [mockImage] };
|
||||
|
||||
describe('ImageUploadModal', () => {
|
||||
beforeEach(() => {
|
||||
mockImagesRef = { current: [mockImage] };
|
||||
});
|
||||
|
||||
describe('hooks', () => {
|
||||
describe('imgTag', () => {
|
||||
const selection = { externalUrl: 'sOmEuRl.cOm' };
|
||||
@@ -52,36 +74,62 @@ describe('ImageUploadModal', () => {
|
||||
});
|
||||
});
|
||||
describe('createSaveCallback', () => {
|
||||
const updateImageDimensionsSpy = jest.spyOn(tinyMceHooks, 'updateImageDimensions');
|
||||
const close = jest.fn();
|
||||
const execCommandMock = jest.fn();
|
||||
const editorRef = { current: { some: 'dATa', execCommand: execCommandMock } };
|
||||
const setSelection = jest.fn();
|
||||
const selection = jest.fn();
|
||||
const selection = { externalUrl: 'sOmEuRl.cOm' };
|
||||
const lmsEndpointUrl = 'sOmE';
|
||||
const images = mockImagesRef;
|
||||
let output;
|
||||
const newImage = {
|
||||
altText: settings.altText,
|
||||
externalUrl: selection.externalUrl,
|
||||
width: settings.dimensions.width,
|
||||
height: settings.dimensions.height,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
output = module.hooks.createSaveCallback({
|
||||
close, editorRef, setSelection, selection, lmsEndpointUrl,
|
||||
close, settings, images, editorRef, setSelection, selection, lmsEndpointUrl,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('It creates a callback, that when called, inserts to the editor, sets the selection to be null, and calls close', () => {
|
||||
jest.spyOn(module.hooks, hookKeys.imgTag)
|
||||
.mockImplementationOnce((props) => ({ selection, settings: props.settings, lmsEndpointUrl }));
|
||||
expect(execCommandMock).not.toBeCalled();
|
||||
expect(setSelection).not.toBeCalled();
|
||||
expect(close).not.toBeCalled();
|
||||
output(settings);
|
||||
expect(execCommandMock).toBeCalledWith(
|
||||
tinyMCEKeys.commands.insertContent,
|
||||
false,
|
||||
{ selection, settings, lmsEndpointUrl },
|
||||
);
|
||||
expect(setSelection).toBeCalledWith(null);
|
||||
expect(close).toBeCalled();
|
||||
});
|
||||
test(
|
||||
`It creates a callback, that when called, inserts to the editor, sets the selection to the current element,
|
||||
adds new image to the images ref, and calls close`,
|
||||
() => {
|
||||
jest.spyOn(module.hooks, hookKeys.imgTag)
|
||||
.mockImplementationOnce((props) => ({ selection, settings: props.settings, lmsEndpointUrl }));
|
||||
|
||||
expect(execCommandMock).not.toBeCalled();
|
||||
expect(setSelection).not.toBeCalled();
|
||||
expect(close).not.toBeCalled();
|
||||
expect(images.current).toEqual([mockImage]);
|
||||
|
||||
output(settings);
|
||||
|
||||
expect(execCommandMock).toBeCalledWith(
|
||||
tinyMCEKeys.commands.insertContent,
|
||||
false,
|
||||
{ selection, settings, lmsEndpointUrl },
|
||||
);
|
||||
expect(setSelection).toBeCalledWith(newImage);
|
||||
expect(updateImageDimensionsSpy.mock.calls.length).toBe(1);
|
||||
expect(updateImageDimensionsSpy).toBeCalledWith({
|
||||
images: [mockImage],
|
||||
url: selection.externalUrl,
|
||||
width: settings.dimensions.width,
|
||||
height: settings.dimensions.height,
|
||||
});
|
||||
expect(updateImageDimensionsSpy.mock.results[0].value.foundMatch).toBe(false);
|
||||
expect(images.current).toEqual([mockImage, newImage]);
|
||||
expect(close).toBeCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('onClose', () => {
|
||||
it('takes and calls clearSelection and close callbacks', () => {
|
||||
@@ -104,9 +152,12 @@ describe('ImageUploadModal', () => {
|
||||
isOpen: false,
|
||||
close: jest.fn().mockName('props.close'),
|
||||
clearSelection: jest.fn().mockName('props.clearSelection'),
|
||||
selection: { some: 'images' },
|
||||
selection: { some: 'images', externalUrl: 'sOmEuRl.cOm' },
|
||||
setSelection: jest.fn().mockName('props.setSelection'),
|
||||
lmsEndpointUrl: 'sOmE',
|
||||
images: {
|
||||
current: [mockImage],
|
||||
},
|
||||
};
|
||||
module.hooks = {
|
||||
createSaveCallback: jest.fn().mockName('hooks.createSaveCallback'),
|
||||
@@ -119,6 +170,9 @@ describe('ImageUploadModal', () => {
|
||||
test('snapshot: with selection content (ImageSettingsUpload)', () => {
|
||||
expect(shallow(<ImageUploadModal {...props} />)).toMatchSnapshot();
|
||||
});
|
||||
test('snapshot: selection has no externalUrl (Select Image Modal)', () => {
|
||||
expect(shallow(<ImageUploadModal {...props} selection={null} />)).toMatchSnapshot();
|
||||
});
|
||||
test('snapshot: no selection (Select Image Modal)', () => {
|
||||
expect(shallow(<ImageUploadModal {...props} selection={null} />)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -79,22 +79,22 @@ const mockUploadErrorAlertFn = jest.fn();
|
||||
|
||||
jest.mock('../BaseModal', () => 'BaseModal');
|
||||
jest.mock('./SearchSort', () => 'SearchSort');
|
||||
jest.mock('./Gallery', () => function (componentProps) {
|
||||
jest.mock('./Gallery', () => function mockGallery(componentProps) {
|
||||
mockGalleryFn(componentProps);
|
||||
return (<div>Gallery</div>);
|
||||
});
|
||||
jest.mock('../FileInput', () => function (componentProps) {
|
||||
jest.mock('../FileInput', () => function mockFileInput(componentProps) {
|
||||
mockFileInputFn(componentProps);
|
||||
return (<div>FileInput</div>);
|
||||
});
|
||||
jest.mock('../ErrorAlerts/ErrorAlert', () => function () {
|
||||
jest.mock('../ErrorAlerts/ErrorAlert', () => function mockErrorAlert() {
|
||||
return <div>ErrorAlert</div>;
|
||||
});
|
||||
jest.mock('../ErrorAlerts/FetchErrorAlert', () => function (componentProps) {
|
||||
jest.mock('../ErrorAlerts/FetchErrorAlert', () => function mockFetchErrorAlert(componentProps) {
|
||||
mockFetchErrorAlertFn(componentProps);
|
||||
return (<div>FetchErrorAlert</div>);
|
||||
});
|
||||
jest.mock('../ErrorAlerts/UploadErrorAlert', () => function (componentProps) {
|
||||
jest.mock('../ErrorAlerts/UploadErrorAlert', () => function mockUploadErrorAlert(componentProps) {
|
||||
mockUploadErrorAlertFn(componentProps);
|
||||
return (<div>UploadErrorAlert</div>);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export const getSaveBtnProps = ({ editorRef, ref, close }) => ({
|
||||
});
|
||||
|
||||
export const prepareSourceCodeModal = ({ editorRef, close }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const ref = useRef();
|
||||
const saveBtnProps = module.getSaveBtnProps({ editorRef, ref, close });
|
||||
|
||||
|
||||
@@ -18,21 +18,25 @@ exports[`TinyMceWidget snapshots ImageUploadModal is not rendered 1`] = `
|
||||
editorConfig={
|
||||
Object {
|
||||
"clearSelection": [MockFunction hooks.selectedImage.clearSelection],
|
||||
"editorContentHtml": undefined,
|
||||
"editorRef": Object {
|
||||
"current": Object {
|
||||
"value": "something",
|
||||
},
|
||||
},
|
||||
"editorType": "text",
|
||||
"images": Array [
|
||||
Object {
|
||||
"staTICUrl": "/assets/sOmEaSsET",
|
||||
},
|
||||
],
|
||||
"images": Object {
|
||||
"current": Array [
|
||||
Object {
|
||||
"externalUrl": "/assets/sOmEaSsET",
|
||||
},
|
||||
],
|
||||
},
|
||||
"isLibrary": true,
|
||||
"lmsEndpointUrl": "sOmEvaLue.cOm",
|
||||
"openImgModal": [MockFunction modal.openModal],
|
||||
"openSourceCodeModal": [MockFunction modal.openModal],
|
||||
"selection": "hooks.selectedImage.selection",
|
||||
"setSelection": [MockFunction hooks.selectedImage.setSelection],
|
||||
"studioEndpointUrl": "sOmEoThERvaLue.cOm",
|
||||
}
|
||||
@@ -56,11 +60,13 @@ exports[`TinyMceWidget snapshots SourcecodeModal is not rendered 1`] = `
|
||||
}
|
||||
editorType="problem"
|
||||
images={
|
||||
Array [
|
||||
Object {
|
||||
"staTICUrl": "/assets/sOmEaSsET",
|
||||
},
|
||||
]
|
||||
Object {
|
||||
"current": Array [
|
||||
Object {
|
||||
"externalUrl": "/assets/sOmEaSsET",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
isOpen={false}
|
||||
lmsEndpointUrl="sOmEvaLue.cOm"
|
||||
@@ -72,21 +78,25 @@ exports[`TinyMceWidget snapshots SourcecodeModal is not rendered 1`] = `
|
||||
editorConfig={
|
||||
Object {
|
||||
"clearSelection": [MockFunction hooks.selectedImage.clearSelection],
|
||||
"editorContentHtml": undefined,
|
||||
"editorRef": Object {
|
||||
"current": Object {
|
||||
"value": "something",
|
||||
},
|
||||
},
|
||||
"editorType": "problem",
|
||||
"images": Array [
|
||||
Object {
|
||||
"staTICUrl": "/assets/sOmEaSsET",
|
||||
},
|
||||
],
|
||||
"images": Object {
|
||||
"current": Array [
|
||||
Object {
|
||||
"externalUrl": "/assets/sOmEaSsET",
|
||||
},
|
||||
],
|
||||
},
|
||||
"isLibrary": false,
|
||||
"lmsEndpointUrl": "sOmEvaLue.cOm",
|
||||
"openImgModal": [MockFunction modal.openModal],
|
||||
"openSourceCodeModal": [MockFunction modal.openModal],
|
||||
"selection": "hooks.selectedImage.selection",
|
||||
"setSelection": [MockFunction hooks.selectedImage.setSelection],
|
||||
"studioEndpointUrl": "sOmEoThERvaLue.cOm",
|
||||
}
|
||||
@@ -110,11 +120,13 @@ exports[`TinyMceWidget snapshots renders as expected with default behavior 1`] =
|
||||
}
|
||||
editorType="text"
|
||||
images={
|
||||
Array [
|
||||
Object {
|
||||
"staTICUrl": "/assets/sOmEaSsET",
|
||||
},
|
||||
]
|
||||
Object {
|
||||
"current": Array [
|
||||
Object {
|
||||
"externalUrl": "/assets/sOmEaSsET",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
isOpen={false}
|
||||
lmsEndpointUrl="sOmEvaLue.cOm"
|
||||
@@ -137,21 +149,25 @@ exports[`TinyMceWidget snapshots renders as expected with default behavior 1`] =
|
||||
editorConfig={
|
||||
Object {
|
||||
"clearSelection": [MockFunction hooks.selectedImage.clearSelection],
|
||||
"editorContentHtml": undefined,
|
||||
"editorRef": Object {
|
||||
"current": Object {
|
||||
"value": "something",
|
||||
},
|
||||
},
|
||||
"editorType": "text",
|
||||
"images": Array [
|
||||
Object {
|
||||
"staTICUrl": "/assets/sOmEaSsET",
|
||||
},
|
||||
],
|
||||
"images": Object {
|
||||
"current": Array [
|
||||
Object {
|
||||
"externalUrl": "/assets/sOmEaSsET",
|
||||
},
|
||||
],
|
||||
},
|
||||
"isLibrary": false,
|
||||
"lmsEndpointUrl": "sOmEvaLue.cOm",
|
||||
"openImgModal": [MockFunction modal.openModal],
|
||||
"openSourceCodeModal": [MockFunction modal.openModal],
|
||||
"selection": "hooks.selectedImage.selection",
|
||||
"setSelection": [MockFunction hooks.selectedImage.setSelection],
|
||||
"studioEndpointUrl": "sOmEoThERvaLue.cOm",
|
||||
}
|
||||
|
||||
@@ -11,12 +11,35 @@ import * as module from './hooks';
|
||||
import tinyMCE from '../../data/constants/tinyMCE';
|
||||
|
||||
export const state = StrictDict({
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
isImageModalOpen: (val) => useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
isSourceCodeModalOpen: (val) => useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
imageSelection: (val) => useState(val),
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
refReady: (val) => useState(val),
|
||||
});
|
||||
|
||||
export const addImagesAndDimensionsToRef = ({ imagesRef, assets, editorContentHtml }) => {
|
||||
const imagesWithDimensions = module.filterAssets({ assets }).map((image) => {
|
||||
const imageFragment = module.getImageFromHtmlString(editorContentHtml, image.url);
|
||||
return { ...image, width: imageFragment?.width, height: imageFragment?.height };
|
||||
});
|
||||
|
||||
imagesRef.current = imagesWithDimensions;
|
||||
};
|
||||
|
||||
export const useImages = ({ assets, editorContentHtml }) => {
|
||||
const imagesRef = useRef([]);
|
||||
|
||||
useEffect(() => {
|
||||
module.addImagesAndDimensionsToRef({ imagesRef, assets, editorContentHtml });
|
||||
}, []);
|
||||
|
||||
return { imagesRef };
|
||||
};
|
||||
|
||||
export const parseContentForLabels = ({ editor, updateContent }) => {
|
||||
let content = editor.getContent();
|
||||
if (content && content?.length > 0) {
|
||||
@@ -86,13 +109,31 @@ export const replaceStaticwithAsset = ({
|
||||
});
|
||||
};
|
||||
|
||||
export const getImageResizeHandler = ({ editor, imagesRef, setImage }) => () => {
|
||||
const {
|
||||
src, alt, width, height,
|
||||
} = editor.selection.getNode();
|
||||
|
||||
imagesRef.current = module.updateImageDimensions({
|
||||
images: imagesRef.current, url: src, width, height,
|
||||
}).result;
|
||||
|
||||
setImage({
|
||||
externalUrl: src,
|
||||
altText: alt,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
};
|
||||
|
||||
export const setupCustomBehavior = ({
|
||||
updateContent,
|
||||
openImgModal,
|
||||
openSourceCodeModal,
|
||||
setImage,
|
||||
editorType,
|
||||
imageUrls,
|
||||
images,
|
||||
setImage,
|
||||
lmsEndpointUrl,
|
||||
}) => (editor) => {
|
||||
// image upload button
|
||||
@@ -105,7 +146,9 @@ export const setupCustomBehavior = ({
|
||||
editor.ui.registry.addButton(tinyMCE.buttons.editImageSettings, {
|
||||
icon: 'image',
|
||||
tooltip: 'Edit Image Settings',
|
||||
onAction: module.openModalWithSelectedImage({ editor, setImage, openImgModal }),
|
||||
onAction: module.openModalWithSelectedImage({
|
||||
editor, images, setImage, openImgModal,
|
||||
}),
|
||||
});
|
||||
// overriding the code plugin's icon with 'HTML' text
|
||||
editor.ui.registry.addButton(tinyMCE.buttons.code, {
|
||||
@@ -162,6 +205,8 @@ export const setupCustomBehavior = ({
|
||||
editor.formatter.remove('label');
|
||||
}
|
||||
});
|
||||
// after resizing an image in the editor, synchronize React state and ref
|
||||
editor.on('ObjectResized', getImageResizeHandler({ editor, imagesRef: images, setImage }));
|
||||
};
|
||||
|
||||
// imagetools_cors_hosts needs a protocol-sanatized url
|
||||
@@ -170,7 +215,7 @@ export const removeProtocolFromUrl = (url) => url.replace(/^https?:\/\//, '');
|
||||
export const editorConfig = ({
|
||||
editorType,
|
||||
setEditorRef,
|
||||
textValue,
|
||||
editorContentHtml,
|
||||
images,
|
||||
lmsEndpointUrl,
|
||||
studioEndpointUrl,
|
||||
@@ -181,6 +226,7 @@ export const editorConfig = ({
|
||||
openSourceCodeModal,
|
||||
setSelection,
|
||||
updateContent,
|
||||
content,
|
||||
minHeight,
|
||||
}) => {
|
||||
const {
|
||||
@@ -191,6 +237,7 @@ export const editorConfig = ({
|
||||
quickbarsInsertToolbar,
|
||||
quickbarsSelectionToolbar,
|
||||
} = pluginConfig({ isLibrary, placeholder, editorType });
|
||||
|
||||
return {
|
||||
onInit: (evt, editor) => {
|
||||
setEditorRef(editor);
|
||||
@@ -198,7 +245,7 @@ export const editorConfig = ({
|
||||
initializeEditor();
|
||||
}
|
||||
},
|
||||
initialValue: textValue || '',
|
||||
initialValue: editorContentHtml || '',
|
||||
init: {
|
||||
...config,
|
||||
skin: false,
|
||||
@@ -217,6 +264,8 @@ export const editorConfig = ({
|
||||
openSourceCodeModal,
|
||||
lmsEndpointUrl,
|
||||
setImage: setSelection,
|
||||
content,
|
||||
images,
|
||||
imageUrls: module.fetchImageUrls(images),
|
||||
}),
|
||||
quickbars_insert_toolbar: quickbarsInsertToolbar,
|
||||
@@ -232,11 +281,14 @@ export const editorConfig = ({
|
||||
};
|
||||
|
||||
export const prepareEditorRef = () => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const editorRef = useRef(null);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const setEditorRef = useCallback((ref) => {
|
||||
editorRef.current = ref;
|
||||
}, []);
|
||||
const [refReady, setRefReady] = module.state.refReady(false);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => setRefReady(true), []);
|
||||
return { editorRef, refReady, setEditorRef };
|
||||
};
|
||||
@@ -262,14 +314,68 @@ export const sourceCodeModalToggle = (editorRef) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const openModalWithSelectedImage = ({ editor, setImage, openImgModal }) => () => {
|
||||
const imgHTML = editor.selection.getNode();
|
||||
/**
|
||||
* const imageMatchRegex
|
||||
*
|
||||
* Image urls and ids used in the TinyMceEditor vary wildly, with different base urls,
|
||||
* different lengths and constituent parts, and replacement of some "/" with "@".
|
||||
* Common are the keys "asset-v1", "type", and "block", each holding a value after some separator.
|
||||
* This regex captures only the values for these keys using capture groups, which can be used for matching.
|
||||
*/
|
||||
export const imageMatchRegex = /asset-v1.(.*).type.(.*).block.(.*)/;
|
||||
|
||||
/**
|
||||
* function matchImageStringsByIdentifiers
|
||||
*
|
||||
* matches two strings by comparing their regex capture groups using the `imageMatchRegex`
|
||||
*/
|
||||
export const matchImageStringsByIdentifiers = (a, b) => {
|
||||
if (!a || !b || !(typeof a === 'string') || !(typeof b === 'string')) { return null; }
|
||||
const matchA = JSON.stringify(a.match(imageMatchRegex)?.slice?.(1));
|
||||
const matchB = JSON.stringify(b.match(imageMatchRegex)?.slice?.(1));
|
||||
return matchA && matchA === matchB;
|
||||
};
|
||||
|
||||
export const stringToFragment = (htmlString) => document.createRange().createContextualFragment(htmlString);
|
||||
|
||||
export const getImageFromHtmlString = (htmlString, imageSrc) => {
|
||||
const images = stringToFragment(htmlString)?.querySelectorAll('img') || [];
|
||||
|
||||
return Array.from(images).find((img) => matchImageStringsByIdentifiers(img.src || '', imageSrc));
|
||||
};
|
||||
|
||||
export const detectImageMatchingError = ({ matchingImages, tinyMceHTML }) => {
|
||||
if (!matchingImages.length) { return true; }
|
||||
if (matchingImages.length > 1) { return true; }
|
||||
|
||||
if (!matchImageStringsByIdentifiers(matchingImages[0].id, tinyMceHTML.src)) { return true; }
|
||||
if (!matchingImages[0].width || !matchingImages[0].height) { return true; }
|
||||
if (matchingImages[0].width !== tinyMceHTML.width) { return true; }
|
||||
if (matchingImages[0].height !== tinyMceHTML.height) { return true; }
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const openModalWithSelectedImage = ({
|
||||
editor, images, setImage, openImgModal,
|
||||
}) => () => {
|
||||
const tinyMceHTML = editor.selection.getNode();
|
||||
const { src: mceSrc } = tinyMceHTML;
|
||||
|
||||
const matchingImages = images.current.filter(image => matchImageStringsByIdentifiers(image.id, mceSrc));
|
||||
|
||||
const imageMatchingErrorDetected = detectImageMatchingError({ tinyMceHTML, matchingImages });
|
||||
|
||||
const width = imageMatchingErrorDetected ? null : matchingImages[0]?.width;
|
||||
const height = imageMatchingErrorDetected ? null : matchingImages[0]?.height;
|
||||
|
||||
setImage({
|
||||
externalUrl: imgHTML.src,
|
||||
altText: imgHTML.alt,
|
||||
width: imgHTML.width,
|
||||
height: imgHTML.height,
|
||||
externalUrl: tinyMceHTML.src,
|
||||
altText: tinyMceHTML.alt,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
openImgModal();
|
||||
};
|
||||
|
||||
@@ -324,7 +430,7 @@ export const setAssetToStaticUrl = ({ editorValue, assets, lmsEndpointUrl }) =>
|
||||
|
||||
export const fetchImageUrls = (images) => {
|
||||
const imageUrls = [];
|
||||
images.forEach(image => {
|
||||
images.current.forEach(image => {
|
||||
imageUrls.push({ staticFullUrl: image.staticFullUrl, displayName: image.displayName });
|
||||
});
|
||||
return imageUrls;
|
||||
@@ -338,3 +444,34 @@ export const selectedImage = (val) => {
|
||||
setSelection,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* function updateImageDimensions
|
||||
*
|
||||
* Updates one images' dimensions in an array by identifying one image via a url string match
|
||||
* that includes asset-v1, type, and block. Returns a new array.
|
||||
*
|
||||
* @param {Object[]} images - [{ id, ...other }]
|
||||
* @param {string} url
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
*
|
||||
* @returns {Object} { result, foundMatch }
|
||||
*/
|
||||
export const updateImageDimensions = ({
|
||||
images, url, width, height,
|
||||
}) => {
|
||||
let foundMatch = false;
|
||||
|
||||
const result = images.map((image) => {
|
||||
const imageIdentifier = image.id || image.url || image.src || image.externalUrl;
|
||||
const isMatch = matchImageStringsByIdentifiers(imageIdentifier, url);
|
||||
if (isMatch) {
|
||||
foundMatch = true;
|
||||
return { ...image, width, height };
|
||||
}
|
||||
return image;
|
||||
});
|
||||
|
||||
return { result, foundMatch };
|
||||
};
|
||||
|
||||
@@ -19,16 +19,57 @@ const moduleKeys = keyStore(module);
|
||||
let hook;
|
||||
let output;
|
||||
|
||||
const editorImageWidth = 2022;
|
||||
const editorImageHeight = 1619;
|
||||
|
||||
const mockNode = {
|
||||
src: 'sOmEuRl.cOm',
|
||||
src: 'http://localhost:18000/asset-v1:TestX+Test01+Test0101+type@asset+block/DALL_E_2023-03-10.png',
|
||||
alt: 'aLt tExt',
|
||||
width: 2022,
|
||||
height: 1619,
|
||||
width: editorImageWidth,
|
||||
height: editorImageHeight,
|
||||
};
|
||||
|
||||
const initialContentHeight = 150;
|
||||
const initialContentWidth = 100;
|
||||
const mockNodeWithInitialContentDimensions = { ...mockNode, width: initialContentWidth, height: initialContentHeight };
|
||||
const mockEditorWithSelection = { selection: { getNode: () => mockNode } };
|
||||
|
||||
const mockImage = {
|
||||
displayName: 'DALL·E 2023-03-10.png',
|
||||
contentType: 'image/png',
|
||||
dateAdded: 1682009100000,
|
||||
url: '/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png',
|
||||
externalUrl: 'http://localhost:18000/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png',
|
||||
portableUrl: '/static/DALL_E_2023-03-10.png',
|
||||
thumbnail: '/asset-v1:TestX+Test01+Test0101+type@thumbnail+block@DALL_E_2023-03-10.jpg',
|
||||
locked: false,
|
||||
staticFullUrl: '/assets/courseware/v1/af2bf9ac70804e54c534107160a8e51e/asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png',
|
||||
id: 'asset-v1:TestX+Test01+Test0101+type@asset+block@DALL_E_2023-03-10.png',
|
||||
width: initialContentWidth,
|
||||
height: initialContentHeight,
|
||||
};
|
||||
|
||||
const mockAssets = {
|
||||
[mockImage.id]: mockImage,
|
||||
};
|
||||
|
||||
const mockEditorContentHtml = `
|
||||
<p>
|
||||
<img
|
||||
src="/assets/courseware/v1/7b41573468a356ca8dc975158e388386/asset-v1:TestX+Test01+Test0101+type@asset+block/DALL_E_2023-03-10.png"
|
||||
alt=""
|
||||
width="${initialContentWidth}"
|
||||
height="${initialContentHeight}">
|
||||
</img>
|
||||
</p>
|
||||
`;
|
||||
|
||||
const mockImagesRef = { current: [mockImage] };
|
||||
|
||||
describe('TinyMceEditor hooks', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockImagesRef.current = [mockImage];
|
||||
});
|
||||
describe('state hooks', () => {
|
||||
state.testGetter(state.keys.isImageModalOpen);
|
||||
@@ -40,6 +81,34 @@ describe('TinyMceEditor hooks', () => {
|
||||
beforeEach(() => { state.mock(); });
|
||||
afterEach(() => { state.restore(); });
|
||||
|
||||
describe('detectImageMatchingError', () => {
|
||||
it('should detect an error if the matchingImages array is empty', () => {
|
||||
const matchingImages = [];
|
||||
const tinyMceHTML = mockNode;
|
||||
expect(module.detectImageMatchingError({ matchingImages, tinyMceHTML })).toBe(true);
|
||||
});
|
||||
it('should detect an error if the matchingImages array has more than one element', () => {
|
||||
const matchingImages = [mockImage, mockImage];
|
||||
const tinyMceHTML = mockNode;
|
||||
expect(module.detectImageMatchingError({ matchingImages, tinyMceHTML })).toBe(true);
|
||||
});
|
||||
it('should detect an error if the image id does not match the tinyMceHTML src', () => {
|
||||
const matchingImages = [{ ...mockImage, id: 'some-other-id' }];
|
||||
const tinyMceHTML = mockNode;
|
||||
expect(module.detectImageMatchingError({ matchingImages, tinyMceHTML })).toBe(true);
|
||||
});
|
||||
it('should detect an error if the image id matches the tinyMceHTML src, but width and height do not match', () => {
|
||||
const matchingImages = [{ ...mockImage, width: 100, height: 100 }];
|
||||
const tinyMceHTML = mockNode;
|
||||
expect(module.detectImageMatchingError({ matchingImages, tinyMceHTML })).toBe(true);
|
||||
});
|
||||
it('should not detect any errors if id matches src, and width and height match', () => {
|
||||
const matchingImages = [{ ...mockImage, width: mockNode.width, height: mockNode.height }];
|
||||
const tinyMceHTML = mockNode;
|
||||
expect(module.detectImageMatchingError({ matchingImages, tinyMceHTML })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupCustomBehavior', () => {
|
||||
test('It calls addButton and addToggleButton in the editor, but openModal is not called', () => {
|
||||
const addButton = jest.fn();
|
||||
@@ -62,6 +131,7 @@ describe('TinyMceEditor hooks', () => {
|
||||
const setupCodeFormatting = expect.any(Function);
|
||||
jest.spyOn(module, moduleKeys.openModalWithSelectedImage)
|
||||
.mockImplementationOnce(mockOpenModalWithImage);
|
||||
|
||||
output = module.setupCustomBehavior({
|
||||
editorType,
|
||||
updateContent,
|
||||
@@ -152,16 +222,17 @@ describe('TinyMceEditor hooks', () => {
|
||||
|
||||
describe('editorConfig', () => {
|
||||
const props = {
|
||||
textValue: null,
|
||||
editorContentHtml: null,
|
||||
editorType: 'text',
|
||||
lmsEndpointUrl: 'sOmEuRl.cOm',
|
||||
studioEndpointUrl: 'sOmEoThEruRl.cOm',
|
||||
images: [{ staTICUrl: '/assets/sOmEuiMAge' }],
|
||||
images: mockImagesRef,
|
||||
isLibrary: false,
|
||||
};
|
||||
const evt = 'fakeEvent';
|
||||
const editor = 'myEditor';
|
||||
const setupCustomBehavior = args => ({ setupCustomBehavior: args });
|
||||
|
||||
beforeEach(() => {
|
||||
props.setEditorRef = jest.fn();
|
||||
props.openImgModal = jest.fn();
|
||||
@@ -172,6 +243,7 @@ describe('TinyMceEditor hooks', () => {
|
||||
.mockImplementationOnce(setupCustomBehavior);
|
||||
output = module.editorConfig(props);
|
||||
});
|
||||
|
||||
describe('text editor plugins and toolbar', () => {
|
||||
test('It configures plugins and toolbars correctly', () => {
|
||||
const pluginProps = {
|
||||
@@ -259,9 +331,9 @@ describe('TinyMceEditor hooks', () => {
|
||||
expect(output.initialValue).toBe('');
|
||||
});
|
||||
test('It sets the blockvalue to be the blockvalue if nonempty', () => {
|
||||
const textValue = 'SomE hTML content';
|
||||
output = module.editorConfig({ ...props, textValue });
|
||||
expect(output.initialValue).toBe(textValue);
|
||||
const editorContentHtml = 'SomE hTML content';
|
||||
output = module.editorConfig({ ...props, editorContentHtml });
|
||||
expect(output.initialValue).toBe(editorContentHtml);
|
||||
});
|
||||
|
||||
it('calls setupCustomBehavior on setup', () => {
|
||||
@@ -273,6 +345,7 @@ describe('TinyMceEditor hooks', () => {
|
||||
openSourceCodeModal: props.openSourceCodeModal,
|
||||
setImage: props.setSelection,
|
||||
imageUrls: module.fetchImageUrls(props.images),
|
||||
images: mockImagesRef,
|
||||
lmsEndpointUrl: props.lmsEndpointUrl,
|
||||
}),
|
||||
);
|
||||
@@ -330,20 +403,57 @@ describe('TinyMceEditor hooks', () => {
|
||||
});
|
||||
|
||||
describe('openModalWithSelectedImage', () => {
|
||||
test('image is set to be value stored in editor, modal is opened', () => {
|
||||
const setImage = jest.fn();
|
||||
const openImgModal = jest.fn();
|
||||
const editor = { selection: { getNode: () => mockNode } };
|
||||
module.openModalWithSelectedImage({ editor, openImgModal, setImage })();
|
||||
const setImage = jest.fn();
|
||||
const openImgModal = jest.fn();
|
||||
let editor;
|
||||
|
||||
beforeEach(() => {
|
||||
editor = { selection: { getNode: () => mockNodeWithInitialContentDimensions } };
|
||||
module.openModalWithSelectedImage({
|
||||
editor, images: mockImagesRef, openImgModal, setImage,
|
||||
})();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('updates React state for selected image to be value stored in editor, adding dimensions from images ref', () => {
|
||||
expect(setImage).toHaveBeenCalledWith({
|
||||
externalUrl: mockNode.src,
|
||||
altText: mockNode.alt,
|
||||
width: mockNode.width,
|
||||
height: mockNode.height,
|
||||
width: mockImage.width,
|
||||
height: mockImage.height,
|
||||
});
|
||||
});
|
||||
|
||||
test('opens image setting modal', () => {
|
||||
expect(openImgModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('when images cannot be successfully matched', () => {
|
||||
beforeEach(() => {
|
||||
editor = { selection: { getNode: () => mockNode } };
|
||||
module.openModalWithSelectedImage({
|
||||
editor, images: mockImagesRef, openImgModal, setImage,
|
||||
})();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('updates React state for selected image to be value stored in editor, setting dimensions to null', () => {
|
||||
expect(setImage).toHaveBeenCalledWith({
|
||||
externalUrl: mockNode.src,
|
||||
altText: mockNode.alt,
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectedImage hooks', () => {
|
||||
const val = { a: 'VaLUe' };
|
||||
beforeEach(() => {
|
||||
@@ -361,5 +471,118 @@ describe('TinyMceEditor hooks', () => {
|
||||
expect(hook.setSelection).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
describe('imageMatchRegex', () => {
|
||||
it('should match a valid image url using "@" separators', () => {
|
||||
expect(
|
||||
'http://localhost:18000/asset-v1:TestX+Test01+Test0101+type@asset+block@image-name.png',
|
||||
).toMatch(module.imageMatchRegex);
|
||||
});
|
||||
it('should match a url including the keywords "asset-v1", "type", "block" in that order', () => {
|
||||
expect(
|
||||
'https://some.completely/made.up///url-with.?!keywords/asset-v1:Some-asset-key?type=some.type.key!block@image-name.png',
|
||||
).toMatch(module.imageMatchRegex);
|
||||
});
|
||||
it('should not match a url excluding the keyword "asset-v1"', () => {
|
||||
expect(
|
||||
'https://some.completely/made.up///url-with.?!keywords/Some-asset-key?type=some.type.key!block@image-name.png',
|
||||
).not.toMatch(module.imageMatchRegex);
|
||||
});
|
||||
it('should match an identifier including the keywords "asset-v1", "type", "block" using "/" separators', () => {
|
||||
expect(
|
||||
'asset-v1:TestX+Test01+Test0101+type/asset+block/image-name.png',
|
||||
).toMatch(module.imageMatchRegex);
|
||||
});
|
||||
it('should capture values for the keys "asset-v1", "type", "block"', () => {
|
||||
const match = 'asset-v1:TestX+Test01+Test0101+type/asset+block/image-name.png'.match(module.imageMatchRegex);
|
||||
expect(match[1]).toBe('TestX+Test01+Test0101');
|
||||
expect(match[2]).toBe('asset');
|
||||
expect(match[3]).toBe('image-name.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchImageStringsByIdentifiers', () => {
|
||||
it('should be true for an image url and identifier that have the same values for asset-v1, type, and block', () => {
|
||||
const url = 'http://localhost:18000/asset-v1:TestX+Test01+Test0101+type@asset+block@image-name.png';
|
||||
const id = 'asset-v1:TestX+Test01+Test0101+type/asset+block/image-name.png';
|
||||
expect(module.matchImageStringsByIdentifiers(url, id)).toBe(true);
|
||||
});
|
||||
it('should be false for an image url and identifier that have different values for block', () => {
|
||||
const url = 'http://localhost:18000/asset-v1:TestX+Test01+Test0101+type@asset+block@image-name.png';
|
||||
const id = 'asset-v1:TestX+Test01+Test0101+type/asset+block/different-image-name.png';
|
||||
expect(module.matchImageStringsByIdentifiers(url, id)).toBe(false);
|
||||
});
|
||||
it('should return null if it doesnt receive two strings as input', () => {
|
||||
expect(module.matchImageStringsByIdentifiers(['a'], { b: 'c ' })).toBe(null);
|
||||
});
|
||||
it('should return undefined if the strings dont match the regex at all', () => {
|
||||
expect(module.matchImageStringsByIdentifiers('wrong-url', 'blub')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addImagesAndDimensionsToRef', () => {
|
||||
it('should add images to ref', () => {
|
||||
const imagesRef = { current: null };
|
||||
const assets = { ...mockAssets, height: undefined, width: undefined };
|
||||
module.addImagesAndDimensionsToRef(
|
||||
{
|
||||
imagesRef,
|
||||
assets,
|
||||
editorContentHtml: mockEditorContentHtml,
|
||||
},
|
||||
);
|
||||
expect(imagesRef.current).toEqual([mockImage]);
|
||||
expect(imagesRef.current[0].width).toBe(initialContentWidth);
|
||||
expect(imagesRef.current[0].height).toBe(initialContentHeight);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getImageResizeHandler', () => {
|
||||
const setImage = jest.fn();
|
||||
|
||||
it('sets image ref and state to new width', () => {
|
||||
expect(mockImagesRef.current[0].width).toBe(initialContentWidth);
|
||||
module.getImageResizeHandler({ editor: mockEditorWithSelection, imagesRef: mockImagesRef, setImage })();
|
||||
|
||||
expect(setImage).toHaveBeenCalledTimes(1);
|
||||
expect(setImage).toHaveBeenCalledWith(expect.objectContaining({ width: editorImageWidth }));
|
||||
expect(mockImagesRef.current[0].width).not.toBe(initialContentWidth);
|
||||
expect(mockImagesRef.current[0].width).toBe(editorImageWidth);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateImageDimensions', () => {
|
||||
const unchangedImg = {
|
||||
id: 'asset-v1:TestX+Test01+Test0101+type@asset+block@unchanged-image.png',
|
||||
width: 3,
|
||||
height: 5,
|
||||
};
|
||||
const images = [
|
||||
mockImage,
|
||||
unchangedImg,
|
||||
];
|
||||
|
||||
it('updates dimensions of correct image in images array', () => {
|
||||
const { result, foundMatch } = module.updateImageDimensions({
|
||||
images, url: mockNode.src, width: 123, height: 321,
|
||||
});
|
||||
const imageToHaveBeenUpdated = result.find(img => img.id === mockImage.id);
|
||||
const imageToHaveBeenUnchanged = result.find(img => img.id === unchangedImg.id);
|
||||
|
||||
expect(imageToHaveBeenUpdated.width).toBe(123);
|
||||
expect(imageToHaveBeenUpdated.height).toBe(321);
|
||||
expect(imageToHaveBeenUnchanged.width).toBe(3);
|
||||
expect(imageToHaveBeenUnchanged.height).toBe(5);
|
||||
|
||||
expect(foundMatch).toBe(true);
|
||||
});
|
||||
|
||||
it('does not update images if id is not found', () => {
|
||||
const { result, foundMatch } = module.updateImageDimensions({
|
||||
images, url: 'not_found', width: 123, height: 321,
|
||||
});
|
||||
expect(result.find(img => img.width === 123 || img.height === 321)).toBeFalsy();
|
||||
expect(foundMatch).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export const TinyMceWidget = ({
|
||||
editorRef,
|
||||
disabled,
|
||||
id,
|
||||
editorContentHtml, // editorContent in html form
|
||||
// redux
|
||||
assets,
|
||||
isLibrary,
|
||||
@@ -40,8 +41,10 @@ export const TinyMceWidget = ({
|
||||
}) => {
|
||||
const { isImgOpen, openImgModal, closeImgModal } = hooks.imgModalToggle();
|
||||
const { isSourceCodeOpen, openSourceCodeModal, closeSourceCodeModal } = hooks.sourceCodeModalToggle(editorRef);
|
||||
const images = hooks.filterAssets({ assets });
|
||||
const { imagesRef } = hooks.useImages({ assets, editorContentHtml });
|
||||
|
||||
const imageSelection = hooks.selectedImage(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLibrary ? null : (
|
||||
@@ -49,7 +52,7 @@ export const TinyMceWidget = ({
|
||||
isOpen={isImgOpen}
|
||||
close={closeImgModal}
|
||||
editorRef={editorRef}
|
||||
images={images}
|
||||
images={imagesRef}
|
||||
editorType={editorType}
|
||||
lmsEndpointUrl={lmsEndpointUrl}
|
||||
{...imageSelection}
|
||||
@@ -74,9 +77,9 @@ export const TinyMceWidget = ({
|
||||
isLibrary,
|
||||
lmsEndpointUrl,
|
||||
studioEndpointUrl,
|
||||
images,
|
||||
setSelection: imageSelection.setSelection,
|
||||
clearSelection: imageSelection.clearSelection,
|
||||
images: imagesRef,
|
||||
editorContentHtml,
|
||||
...imageSelection,
|
||||
...props,
|
||||
})
|
||||
}
|
||||
@@ -93,6 +96,7 @@ TinyMceWidget.defaultProps = {
|
||||
assets: null,
|
||||
id: null,
|
||||
disabled: false,
|
||||
editorContentHtml: undefined,
|
||||
};
|
||||
TinyMceWidget.propTypes = {
|
||||
editorType: PropTypes.string,
|
||||
@@ -103,6 +107,7 @@ TinyMceWidget.propTypes = {
|
||||
studioEndpointUrl: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
editorContentHtml: PropTypes.string,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
|
||||
@@ -6,6 +6,8 @@ import ImageUploadModal from '../ImageUploadModal';
|
||||
import { imgModalToggle, sourceCodeModalToggle } from './hooks';
|
||||
import { TinyMceWidget, mapStateToProps } from '.';
|
||||
|
||||
const staticUrl = '/assets/sOmEaSsET';
|
||||
|
||||
// Per https://github.com/tinymce/tinymce-react/issues/91 React unit testing in JSDOM is not supported by tinymce.
|
||||
// Consequently, mock the Editor out.
|
||||
jest.mock('@tinymce/tinymce-react', () => {
|
||||
@@ -48,7 +50,8 @@ jest.mock('./hooks', () => ({
|
||||
setSelection: jest.fn().mockName('hooks.selectedImage.setSelection'),
|
||||
clearSelection: jest.fn().mockName('hooks.selectedImage.clearSelection'),
|
||||
})),
|
||||
filterAssets: jest.fn(() => [{ staTICUrl: '/assets/sOmEaSsET' }]),
|
||||
filterAssets: jest.fn(() => [{ staTICUrl: staticUrl }]),
|
||||
useImages: jest.fn(() => ({ imagesRef: { current: [{ externalUrl: staticUrl }] } })),
|
||||
}));
|
||||
|
||||
describe('TinyMceWidget', () => {
|
||||
@@ -56,7 +59,7 @@ describe('TinyMceWidget', () => {
|
||||
editorType: 'text',
|
||||
editorRef: { current: { value: 'something' } },
|
||||
isLibrary: false,
|
||||
assets: { sOmEaSsET: { staTICUrl: '/assets/sOmEaSsET' } },
|
||||
assets: { sOmEaSsET: { staTICUrl: staticUrl } },
|
||||
lmsEndpointUrl: 'sOmEvaLue.cOm',
|
||||
studioEndpointUrl: 'sOmEoThERvaLue.cOm',
|
||||
disabled: false,
|
||||
|
||||
@@ -2,6 +2,7 @@ import TextEditor from './containers/TextEditor';
|
||||
import VideoEditor from './containers/VideoEditor';
|
||||
import ProblemEditor from './containers/ProblemEditor';
|
||||
import VideoUploadEditor from './containers/VideoUploadEditor';
|
||||
import GameEditor from './containers/GameEditor';
|
||||
|
||||
// ADDED_EDITOR_IMPORTS GO HERE
|
||||
|
||||
@@ -13,6 +14,7 @@ const supportedEditors = {
|
||||
[blockTypes.problem]: ProblemEditor,
|
||||
[blockTypes.video_upload]: VideoUploadEditor,
|
||||
// ADDED_EDITORS GO BELOW
|
||||
[blockTypes.game]: GameEditor,
|
||||
};
|
||||
|
||||
export default supportedEditors;
|
||||
|
||||
166
src/footer/Footer.jsx
Normal file
166
src/footer/Footer.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import _ from 'lodash-es';
|
||||
import { intlShape, injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
Button,
|
||||
Hyperlink,
|
||||
Image,
|
||||
TransitionReplace,
|
||||
} from '@edx/paragon';
|
||||
import { ExpandLess, ExpandMore, Help } from '@edx/paragon/icons';
|
||||
import messages from './messages';
|
||||
|
||||
const Footer = ({
|
||||
marketingBaseUrl,
|
||||
termsOfServiceUrl,
|
||||
privacyPolicyUrl,
|
||||
supportEmail,
|
||||
platformName,
|
||||
lmsBaseUrl,
|
||||
studioBaseUrl,
|
||||
showAccessibilityPage,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="m-0 row align-items-center justify-content-center">
|
||||
<div className="col border-top mr-2" />
|
||||
<Button
|
||||
data-testid="helpToggleButton"
|
||||
variant="outline-primary"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
iconBefore={Help}
|
||||
iconAfter={isOpen ? ExpandLess : ExpandMore}
|
||||
size="sm"
|
||||
>
|
||||
{isOpen ? intl.formatMessage(messages.closeHelpButtonLabel)
|
||||
: intl.formatMessage(messages.openHelpButtonLabel)}
|
||||
</Button>
|
||||
<div className="col border-top ml-2" />
|
||||
</div>
|
||||
<TransitionReplace>
|
||||
{isOpen ? (
|
||||
<ActionRow key="help-link-button-row" className="py-4" data-testid="helpButtonRow">
|
||||
<ActionRow.Spacer />
|
||||
<Button as="a" href="https://docs.edx.org/" size="sm">
|
||||
<FormattedMessage {...messages.edxDocumentationButtonLabel} />
|
||||
</Button>
|
||||
{platformName === 'edX' ? (
|
||||
<Button
|
||||
as="a"
|
||||
href="https://partners.edx.org/"
|
||||
size="sm"
|
||||
data-testid="edXPortalButton"
|
||||
>
|
||||
<FormattedMessage {...messages.parnterPortalButtonLabel} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
as="a"
|
||||
href="https://open.edx.org/"
|
||||
size="sm"
|
||||
data-testid="openEdXPortalButton"
|
||||
>
|
||||
<FormattedMessage {...messages.openEdxPortalButtonLabel} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
as="a"
|
||||
href="https://www.edx.org/course/edx101-overview-of-creating-an-edx-course#.VO4eaLPF-n1"
|
||||
size="sm"
|
||||
>
|
||||
<FormattedMessage {...messages.edx101ButtonLabel} />
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://www.edx.org/course/studiox-creating-a-course-with-edx-studio"
|
||||
size="sm"
|
||||
>
|
||||
<FormattedMessage {...messages.studioXButtonLabel} />
|
||||
</Button>
|
||||
{!_.isEmpty(supportEmail) && (
|
||||
<Button
|
||||
as="a"
|
||||
href={`mailto:${supportEmail}`}
|
||||
size="sm"
|
||||
data-testid="contactUsButton"
|
||||
>
|
||||
<FormattedMessage {...messages.contactUsButtonLabel} />
|
||||
</Button>
|
||||
)}
|
||||
<ActionRow.Spacer />
|
||||
</ActionRow>
|
||||
) : null}
|
||||
</TransitionReplace>
|
||||
<ActionRow className="pt-3 px-4 m-0 x-small">
|
||||
© {new Date().getFullYear()} <Hyperlink destination={marketingBaseUrl} target="_blank" className="ml-2">{platformName}</Hyperlink>
|
||||
<ActionRow.Spacer />
|
||||
{!_.isEmpty(termsOfServiceUrl) && (
|
||||
<Hyperlink destination={termsOfServiceUrl} data-testid="termsOfService">
|
||||
{intl.formatMessage(messages.termsOfServiceLinkLabel)}
|
||||
</Hyperlink>
|
||||
)}{!_.isEmpty(privacyPolicyUrl) && (
|
||||
<Hyperlink destination={privacyPolicyUrl} data-testid="privacyPolicy">
|
||||
{intl.formatMessage(messages.privacyPolicyLinkLabel)}
|
||||
</Hyperlink>
|
||||
)}
|
||||
{showAccessibilityPage && (
|
||||
<Hyperlink
|
||||
destination={`${studioBaseUrl}/accessibility`}
|
||||
data-testid="accessibilityRequest"
|
||||
>
|
||||
{intl.formatMessage(messages.accessibilityRequestLinkLabel)}
|
||||
</Hyperlink>
|
||||
)}
|
||||
<Hyperlink destination={lmsBaseUrl}>LMS</Hyperlink>
|
||||
</ActionRow>
|
||||
<ActionRow className="mt-3 mx-4 pb-4 x-small">
|
||||
{/*
|
||||
Site operators: Please do not remove this paragraph! this attributes back to edX and
|
||||
makes your acknowledgement of edX's trademarks clear.
|
||||
Translators: 'edX' and 'Open edX' are trademarks of 'edX Inc.'. Please do not translate
|
||||
any of these trademarks and company names.
|
||||
*/}
|
||||
<FormattedMessage {...messages.trademarkMessage} />
|
||||
<Hyperlink className="ml-1" destination="https://www.edx.org">edX Inc</Hyperlink>.
|
||||
<ActionRow.Spacer />
|
||||
<Hyperlink destination="https://open.edx.org" className="float-right">
|
||||
<Image
|
||||
width="120px"
|
||||
alt="Powered by Open edX"
|
||||
src="https://logos.openedx.org/open-edx-logo-tag.png"
|
||||
/>
|
||||
</Hyperlink>
|
||||
</ActionRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Footer.defaultProps = {
|
||||
marketingBaseUrl: null,
|
||||
termsOfServiceUrl: null,
|
||||
privacyPolicyUrl: null,
|
||||
spanishPrivacyPolicy: null,
|
||||
supportEmail: null,
|
||||
};
|
||||
|
||||
Footer.propTypes = {
|
||||
marketingBaseUrl: PropTypes.string,
|
||||
termsOfServiceUrl: PropTypes.string,
|
||||
privacyPolicyUrl: PropTypes.string,
|
||||
spanishPrivacyPolicy: PropTypes.string,
|
||||
supportEmail: PropTypes.string,
|
||||
platformName: PropTypes.string.isRequired,
|
||||
lmsBaseUrl: PropTypes.string.isRequired,
|
||||
studioBaseUrl: PropTypes.string.isRequired,
|
||||
showAccessibilityPage: PropTypes.bool.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Footer);
|
||||
120
src/footer/Footer.test.jsx
Normal file
120
src/footer/Footer.test.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { formatMessage } from '../testUtils';
|
||||
import Footer from './Footer';
|
||||
import messages from './messages';
|
||||
|
||||
const renderComponent = (
|
||||
termsOfServiceUrl,
|
||||
privacyPolicyUrl,
|
||||
supportEmail,
|
||||
showAccessibilityPage,
|
||||
platformName,
|
||||
) => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<Footer
|
||||
marketingBaseUrl="#"
|
||||
termsOfServiceUrl={termsOfServiceUrl}
|
||||
privacyPolicyUrl={privacyPolicyUrl}
|
||||
supportEmail={supportEmail}
|
||||
platformName={platformName || 'Test Platform'}
|
||||
lmsBaseUrl="#"
|
||||
studioBaseUrl="#"
|
||||
showAccessibilityPage={showAccessibilityPage || false}
|
||||
intl={{ formatMessage }}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
jest.unmock('@edx/paragon');
|
||||
|
||||
describe('Footer', () => {
|
||||
describe('help section default view', () => {
|
||||
it('help button should read Looking for help with Studio?', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText(messages.openHelpButtonLabel.defaultMessage))
|
||||
.toBeVisible();
|
||||
});
|
||||
it('help button link row should not be visible', () => {
|
||||
renderComponent();
|
||||
expect(screen.queryByTestId('helpButtonRow')).toBeNull();
|
||||
});
|
||||
});
|
||||
describe('help section expanded view', () => {
|
||||
it('help button should read Hide Studio help', () => {
|
||||
renderComponent();
|
||||
const helpToggleButton = screen.getByText(messages.openHelpButtonLabel.defaultMessage);
|
||||
fireEvent.click(helpToggleButton);
|
||||
expect(screen.getByText(messages.closeHelpButtonLabel.defaultMessage))
|
||||
.toBeVisible();
|
||||
});
|
||||
it('help button link row should be visible', () => {
|
||||
renderComponent();
|
||||
const helpToggleButton = screen.getByText(messages.openHelpButtonLabel.defaultMessage);
|
||||
fireEvent.click(helpToggleButton);
|
||||
expect(screen.getByTestId('helpButtonRow')).toBeVisible();
|
||||
});
|
||||
describe('portal button', () => {
|
||||
it('should equal edX partner portal and edX equals platform name', () => {
|
||||
renderComponent(null, null, null, false, 'edX');
|
||||
const helpToggleButton = screen.getByText(messages.openHelpButtonLabel.defaultMessage);
|
||||
fireEvent.click(helpToggleButton);
|
||||
expect(screen.getByTestId('edXPortalButton')).toBeVisible();
|
||||
expect(screen.queryByTestId('openEdXPortalButton')).toBeNull();
|
||||
});
|
||||
it('should equal Open edX portal and edX does not equal platform name', () => {
|
||||
renderComponent();
|
||||
const helpToggleButton = screen.getByText(messages.openHelpButtonLabel.defaultMessage);
|
||||
fireEvent.click(helpToggleButton);
|
||||
expect(screen.queryByTestId('edXPortalButton')).toBeNull();
|
||||
expect(screen.getByTestId('openEdXPortalButton')).toBeVisible();
|
||||
});
|
||||
});
|
||||
it('should not show contact us button', () => {
|
||||
renderComponent();
|
||||
const helpToggleButton = screen.getByText(messages.openHelpButtonLabel.defaultMessage);
|
||||
fireEvent.click(helpToggleButton);
|
||||
expect(screen.queryByTestId('contactUsButton')).toBeNull();
|
||||
});
|
||||
it('should show contact us button', () => {
|
||||
renderComponent(null, null, 'support@email.com', false, null);
|
||||
const helpToggleButton = screen.getByText(messages.openHelpButtonLabel.defaultMessage);
|
||||
fireEvent.click(helpToggleButton);
|
||||
expect(screen.getByTestId('contactUsButton')).toBeVisible();
|
||||
});
|
||||
});
|
||||
describe('policy link row', () => {
|
||||
it('should only show LMS link', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText('LMS')).toBeVisible();
|
||||
expect(screen.queryByTestId('termsOfService')).toBeNull();
|
||||
expect(screen.queryByTestId('privacyPolicy')).toBeNull();
|
||||
expect(screen.queryByTestId('accessibilityRequest')).toBeNull();
|
||||
});
|
||||
it('should show terms of service link', () => {
|
||||
renderComponent('termsofserviceurl', null, null, false, null);
|
||||
expect(screen.getByText('LMS')).toBeVisible();
|
||||
expect(screen.queryByTestId('termsOfService')).toBeVisible();
|
||||
expect(screen.queryByTestId('privacyPolicy')).toBeNull();
|
||||
expect(screen.queryByTestId('accessibilityRequest')).toBeNull();
|
||||
});
|
||||
it('should show privacy policy link', () => {
|
||||
renderComponent(null, 'privacypolicyurl', null, false, null);
|
||||
expect(screen.getByText('LMS')).toBeVisible();
|
||||
expect(screen.queryByTestId('termsOfService')).toBeNull();
|
||||
expect(screen.queryByTestId('privacyPolicy')).toBeVisible();
|
||||
expect(screen.queryByTestId('accessibilityRequest')).toBeNull();
|
||||
});
|
||||
it('should show accessibilty request link', () => {
|
||||
renderComponent(null, null, null, true, null);
|
||||
expect(screen.getByText('LMS')).toBeVisible();
|
||||
expect(screen.queryByTestId('termsOfService')).toBeNull();
|
||||
expect(screen.queryByTestId('privacyPolicy')).toBeNull();
|
||||
expect(screen.queryByTestId('accessibilityRequest')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
3
src/footer/index.jsx
Normal file
3
src/footer/index.jsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import Footer from './Footer';
|
||||
|
||||
export default Footer;
|
||||
66
src/footer/messages.js
Normal file
66
src/footer/messages.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
openHelpButtonLabel: {
|
||||
id: 'authoring.footer.help.openHelp.button.label',
|
||||
defaultMessage: 'Looking for help with Studio?',
|
||||
description: 'Label for button that opens the collapsed section with help buttons',
|
||||
},
|
||||
closeHelpButtonLabel: {
|
||||
id: 'authoring.footer.help.closeHelp.button.label',
|
||||
defaultMessage: 'Hide Studio help',
|
||||
description: 'Label for button that closes the collapsed section with help buttons',
|
||||
},
|
||||
edxDocumentationButtonLabel: {
|
||||
id: 'authoring.footer.help.edxDocumentation.button.label',
|
||||
defaultMessage: 'edX documentation',
|
||||
description: 'Label for button that links to the edX documentation site',
|
||||
},
|
||||
parnterPortalButtonLabel: {
|
||||
id: 'authoring.footer.help.parnterPortal.button.label',
|
||||
defaultMessage: 'edX partner portal',
|
||||
description: 'Label for button that links to the edX partner portal',
|
||||
},
|
||||
openEdxPortalButtonLabel: {
|
||||
id: 'authoring.footer.help.openEdxPortal.button.label',
|
||||
defaultMessage: 'Open edX portal',
|
||||
description: 'Label for button that links to the Open edX portal',
|
||||
},
|
||||
edx101ButtonLabel: {
|
||||
id: 'authoring.footer.help.edx101.button.label',
|
||||
defaultMessage: 'Enroll in edX 101',
|
||||
description: 'Label for button that links to the edX 101 course',
|
||||
},
|
||||
studioXButtonLabel: {
|
||||
id: 'authoring.footer.help.studioX.button.label',
|
||||
defaultMessage: 'Enroll in StudioX',
|
||||
description: 'Label for button that links to the edX StudioX course',
|
||||
},
|
||||
contactUsButtonLabel: {
|
||||
id: 'authoring.footer.help.contactUs.button.label',
|
||||
defaultMessage: 'Contact us',
|
||||
description: 'Label for button that links to the email for partner support',
|
||||
},
|
||||
termsOfServiceLinkLabel: {
|
||||
id: 'authoring.footer.termsOfService.link.label',
|
||||
defaultMessage: 'Terms of Service',
|
||||
description: 'Label for button that links to the terms of service page',
|
||||
},
|
||||
privacyPolicyLinkLabel: {
|
||||
id: 'authoring.footer.privacyPolicy.link.label',
|
||||
defaultMessage: 'Privacy Policy',
|
||||
description: 'Label for button that links to the privacy policy page',
|
||||
},
|
||||
accessibilityRequestLinkLabel: {
|
||||
id: 'authoring.footer.accessibilityRequest.link.label',
|
||||
defaultMessage: 'Accessibility Accomodation Request',
|
||||
description: 'Label for button that links to the accessibility accomodation requests page',
|
||||
},
|
||||
trademarkMessage: {
|
||||
id: 'authoring.footer.trademark.message',
|
||||
defaultMessage: 'edX and Open edX, and the edX and Open edX logos are registered trademarks of',
|
||||
description: 'Message about the use of logos and names edX and Open edX',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -4,6 +4,7 @@ import EditorPage from './editors/EditorPage';
|
||||
import VideoSelectorPage from './editors/VideoSelectorPage';
|
||||
import DraggableList, { SortableItem } from './editors/sharedComponents/DraggableList';
|
||||
import ErrorAlert from './editors/sharedComponents/ErrorAlerts/ErrorAlert';
|
||||
import Footer from './footer';
|
||||
|
||||
export {
|
||||
messages,
|
||||
@@ -12,5 +13,6 @@ export {
|
||||
DraggableList,
|
||||
SortableItem,
|
||||
ErrorAlert,
|
||||
Footer,
|
||||
};
|
||||
export default Placeholder;
|
||||
|
||||
Reference in New Issue
Block a user