Merge branch 'main' of https://github.com/openedx/frontend-lib-content-components into mashal-m/react-upgrade-to-v17

This commit is contained in:
mashal-m
2023-07-31 16:23:46 +05:00
99 changed files with 2481 additions and 797 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,7 +36,12 @@ export const EditorContainer = ({
confirmAction={(
<Button
variant="primary"
onClick={handleCancel}
onClick={() => {
handleCancel();
if (returnFunction) {
closeCancelConfirmModal();
}
}}
>
<FormattedMessage {...messages.okButtonLabel} />
</Button>

View File

@@ -0,0 +1,102 @@
/* eslint-disable import/extensions */
/* eslint-disable import/no-unresolved */
/**
* This is an example component for an xblock Editor
* It uses pre-existing components to handle the saving of a the result of a function into the xblock's data.
* To use run npm run-script addXblock <your>
*/
/* eslint-disable no-unused-vars */
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { Spinner } from '@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));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;assembly instructions <br></br>0x0&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;addi x1, x0, 1<br></br>0x4&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;slli x2, x1, 4<br></br>0x8&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;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&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;assembly instructions <br></br>0x0&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;addi x1, x0, 1<br></br>0x4&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;slli x2, x1, 4<br></br>0x8&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;sub x1, x2, x1</strong></span></p>`,
solutionExplanation: `<p><span style="font-family: 'courier new', courier;"><strong>Address&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;assembly instructions&#160;&#160;&#160;&#160;comment<br></br>0x0&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;addi x1, x0, 1&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;x1 = 0x1<br></br>0x4&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;slli x2, x1, 4&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;x2 = x1 &lt;&lt; 4 = 0x10<br></br>0x8&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;sub x1, x2, x1&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;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>
`
};

View File

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

View File

@@ -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%"

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

@@ -0,0 +1,5 @@
export const analyticsEvents = {
socialSharingSettingChanged: 'edx.social.video_sharing_setting.changed',
};
export default analyticsEvents;

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

@@ -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: () => {

View File

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

View File

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

View File

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

View File

@@ -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 = () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -7,4 +7,5 @@ export const blockTypes = StrictDict({
problem: 'problem',
// ADDED_EDITORS GO BELOW
video_upload: 'video_upload',
game: 'game',
});

View File

@@ -198,7 +198,6 @@ const getStyles = () => (
}
.mce-content-body img {
max-width: 100%;
height: auto;
}
.mce-content-body pre {
margin: 1em 0;

View File

@@ -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 }) => ({

View File

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

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

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

@@ -487,7 +487,6 @@ describe('requests thunkActions module', () => {
requestKey: RequestKeys.fetchVideoFeatures,
promise: api.fetchVideoFeatures({
studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
learningContextId: selectors.app.learningContextId(testState),
}),
},
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;assembly instructions <br />0x0&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;addi x1, x0, 1<br />0x4&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;slli x2, x1, 4<br />0x8&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;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&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;assembly instructions&#160;&#160;&#160;&#160;comment<br />0x0&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;addi x1, x0, 1&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;x1 = 0x1<br />0x4&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;slli x2, x1, 4&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;x2 = x1 &lt;&lt; 4 = 0x10<br />0x8&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;sub x1, x2, x1&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;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,

View File

@@ -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 }) => (

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ export const ExpandableTextArea = ({
<>
<div className="expandable-mce error">
<TinyMceWidget
textValue={value}
editorContentHtml={value}
editorRef={editorRef}
editorType="expandable"
setEditorRef={setEditorRef}

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ jest.mock('./DimensionControls', () => 'DimensionControls');
jest.mock('./hooks', () => ({
altText: () => ({
error: {
show: 'sHoW',
show: true,
dismiss: jest.fn(),
},
isDecorative: false,

View File

@@ -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) => {

View File

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

View File

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

View File

@@ -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",
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
}

View File

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

View File

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

View File

@@ -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) => ({

View File

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

View File

@@ -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
View 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
View 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
View File

@@ -0,0 +1,3 @@
import Footer from './Footer';
export default Footer;

66
src/footer/messages.js Normal file
View 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;

View File

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