with "olx_description" class', () => {
+ 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', () => {
+ test('should parse text in p tags', () => {
+ const olxparser = new OLXParser(checkboxesOLXWithFeedbackAndHintsOLX.rawOLX);
+ const problemType = olxparser.getProblemType();
+ const explanation = olxparser.getSolutionExplanation(problemType);
+ const expected = checkboxesOLXWithFeedbackAndHintsOLX.solutionExplanation;
+ expect(explanation.replace(/\s/g, '')).toBe(expected.replace(/\s/g, ''));
+ });
+ });
+ it('should parse text with proper spacing', () => {
+ const olxparser = new OLXParser(solutionExplanationTest.rawOLX);
+ const problemType = olxparser.getProblemType();
+ 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 Explanation
', () => {
+ const olxparser = new OLXParser(parseOutExplanationTests.rawOLX);
+ const problemType = olxparser.getProblemType();
+ const explanation = olxparser.getSolutionExplanation(problemType);
+ expect(explanation).toBe(parseOutExplanationTests.solutionExplanation);
+ });
+ });
+});
diff --git a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js
new file mode 100644
index 0000000000..673763de61
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js
@@ -0,0 +1,523 @@
+import _ from 'lodash';
+import { XMLParser, XMLBuilder } from 'fast-xml-parser';
+import { ProblemTypeKeys } from '../../../data/constants/problem';
+import { ToleranceTypes } from '../components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants';
+import { findNodesAndRemoveTheirParentNodes } from './reactStateOLXHelpers';
+
+const HtmlBlockTags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'pre', 'blockquote', 'ol', 'ul', 'li', 'dl', 'dt', 'dd', 'hr', 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'colgroup', 'col', 'address', 'fieldset', 'legend'];
+
+class ReactStateOLXParser {
+ constructor(problemState) {
+ const richTextParserOptions = {
+ ignoreAttributes: false,
+ alwaysCreateTextNode: true,
+ numberParseOptions: {
+ leadingZeros: false,
+ hex: false,
+ },
+ preserveOrder: true,
+ // Ensure whitespace inside tags is preserved
+ trimValues: false,
+ // Parse correctly
+ unpairedTags: ['br'],
+ };
+ const richTextBuilderOptions = {
+ ignoreAttributes: false,
+ attributeNamePrefix: '@_',
+ suppressBooleanAttributes: false,
+ // Avoid formatting as it adds unwanted newlines and whitespace,
+ // breaking tags
+ format: false,
+ numberParseOptions: {
+ leadingZeros: false,
+ hex: false,
+ },
+ preserveOrder: true,
+ unpairedTags: ['br'],
+ // Output rather than
+ suppressUnpairedNode: false,
+ };
+
+ this.richTextParser = new XMLParser(richTextParserOptions);
+ this.richTextBuilder = new XMLBuilder(richTextBuilderOptions);
+ this.editorObject = problemState.editorObject;
+ this.problemState = problemState.problem;
+ }
+
+ /** addHints()
+ * The editorObject saved to the class constuctor is parsed for the attribute hints. No hints returns an empty object.
+ * The hints are parsed and appended to the hintsArray as object representations of the hint. The hints array is saved
+ * to the hint key in the demandHint object and returned.
+ * @return {object} demandhint object with atrribut hint with array of objects
+ */
+ addHints() {
+ const hintsArray = [];
+ const { hints } = this.editorObject;
+ if (hints.length < 1) {
+ return hintsArray;
+ }
+ hints.forEach(hint => {
+ if (hint.length > 0) {
+ const parsedHint = this.richTextParser.parse(hint);
+ hintsArray.push({
+ hint: [...parsedHint],
+ });
+ }
+ });
+ const demandhint = [{ demandhint: hintsArray }];
+ return demandhint;
+ }
+
+ /** addSolution()
+ * The editorObject saved to the class constuctor is parsed for the attribute solution. If the soltuion is empty, it
+ * returns an empty object. The solution is parsed and checked if paragraph key's value is a string or array. Studio
+ * requires a div wrapper with a heading (Explanation). The heading is prepended to the parsed solution object. The
+ * solution object is returned with the updated div wrapper.
+ * @return {object} object representation of solution
+ */
+ addSolution() {
+ const { solution } = this.editorObject;
+ if (!solution || solution.length <= 0) { return []; }
+ const solutionTitle = { p: [{ '#text': 'Explanation' }] };
+ const parsedSolution = this.richTextParser.parse(solution);
+ const withWrapper = [solutionTitle, ...parsedSolution];
+ const solutionObject = [{
+ solution: [{
+ ':@': { '@_class': 'detailed-solution' },
+ div: [...withWrapper],
+ }],
+ }];
+ return solutionObject;
+ }
+
+ /** addMultiSelectAnswers(option)
+ * addMultiSelectAnswers takes option. Option is used to assign an answers to the
+ * correct OLX tag. This function is used for multiple choice, checkbox, and
+ * dropdown problems. The editorObject saved to the class constuctor is parsed for
+ * answers (titles only), selectFeedback, and unselectedFeedback. The problemState
+ * saved to the class constructor is parsed for the problemType and answers (full
+ * object). The answers are looped through to pair feedback with its respective
+ * OLX tags. While matching feedback tags, answers are also mapped to their
+ * respective OLX tags. he object representation of the answers is returned with
+ * the correct wrapping tags. For checkbox problems, compound hints are also returned.
+ * @param {string} option - string of answer tag name
+ * @return {object} object representation of answers
+ */
+ addMultiSelectAnswers(option) {
+ const choice = [];
+ let compoundhint = [];
+ // eslint-disable-next-line prefer-const
+ let { answers, problemType } = this.problemState;
+ const answerTitles = this.editorObject?.answers;
+ const { selectedFeedback, unselectedFeedback } = this.editorObject;
+ /* todo */
+ /*
+ * the logic for general feedback is ot current being used.
+ * when component is updated will need to return to this code.
+ * general feedback replaces selected feedback if all incorrect selected feedback is the same.
+ * ******************************************
+ if (generalFeedback !== ''
+ && answers.every(
+ answer => (
+ answer.correct
+ ? true
+ : answer?.selectedFeedback === answers.find(a => a.correct === false).selectedFeedback
+ ),
+ )) {
+ answers = answers.map(answer => (!answer?.correct
+ ? { ...answer, selectedFeedback: generalFeedback }
+ : answer));
+ }
+ */
+ answers.forEach((answer) => {
+ const feedback = [];
+ let singleAnswer = [];
+ const title = answerTitles ? this.richTextParser.parse(answerTitles[answer.id]) : [{ '#text': answer.title }];
+ const currentSelectedFeedback = selectedFeedback?.[answer.id] || null;
+ const currentUnselectedFeedback = unselectedFeedback?.[answer.id] || null;
+ let isEmpty;
+ if (answerTitles) {
+ isEmpty = Object.keys(title)?.length <= 0;
+ } else {
+ isEmpty = title['#text']?.length <= 0;
+ }
+ if (title && !isEmpty) {
+ if (currentSelectedFeedback && problemType === ProblemTypeKeys.MULTISELECT) {
+ const parsedSelectedFeedback = this.richTextParser.parse(currentSelectedFeedback);
+ feedback.push({
+ ':@': { '@_selected': true },
+ [`${option}hint`]: parsedSelectedFeedback,
+ });
+ }
+ if (currentSelectedFeedback && problemType !== ProblemTypeKeys.MULTISELECT) {
+ const parsedSelectedFeedback = this.richTextParser.parse(currentSelectedFeedback);
+ feedback.push({
+ [`${option}hint`]: parsedSelectedFeedback,
+ });
+ }
+ if (currentUnselectedFeedback && problemType === ProblemTypeKeys.MULTISELECT) {
+ const parsedUnselectedFeedback = this.richTextParser.parse(currentUnselectedFeedback);
+ feedback.push({
+ ':@': { '@_selected': false },
+ [`${option}hint`]: parsedUnselectedFeedback,
+ });
+ }
+ singleAnswer = {
+ ':@': { '@_correct': answer.correct },
+ [option]: [...title, ...feedback],
+ };
+ choice.push(singleAnswer);
+ }
+ });
+ if (_.has(this.problemState, 'groupFeedbackList') && problemType === ProblemTypeKeys.MULTISELECT) {
+ compoundhint = this.addGroupFeedbackList();
+ choice.push(...compoundhint);
+ }
+ return choice;
+ }
+
+ /** addGroupFeedbackList()
+ * The problemState saved to the class constuctor is parsed for the attribute groupFeedbackList.
+ * No group feedback returns an empty array. Each groupFeedback in the groupFeedback list is
+ * mapped to a new object and appended to the compoundhint array.
+ * @return {object} object representation of compoundhints
+ */
+ addGroupFeedbackList() {
+ const compoundhint = [];
+ const { groupFeedbackList } = this.problemState;
+ groupFeedbackList.forEach((element) => {
+ compoundhint.push({
+ compoundhint: [{ '#text': element.feedback }],
+ ':@': { '@_value': element.answers.join(' ') },
+ });
+ });
+ return compoundhint;
+ }
+
+ /** addQuestion()
+ * The editorObject saved to the class constuctor is parsed for the attribute question. The question is parsed and
+ * checked for label tags. label tags are extracted from block-type tags like or
, and the block-type tag is
+ * deleted while label is kept. For example, Question
becomes Question , while
+ * Text
remains Text
. The question is returned as an object representation.
+ * @return {object} object representaion of question
+ */
+ addQuestion() {
+ const { question } = this.editorObject;
+ const questionObjectArray = this.richTextParser.parse(question);
+ /* Removes block tags like or
that surround the format.
+ Block tags are required by tinyMCE but have adverse effect on css in studio.
+ */
+ const result = findNodesAndRemoveTheirParentNodes({
+ arrayOfNodes: questionObjectArray,
+ nodesToFind: ['label'],
+ parentsToRemove: HtmlBlockTags,
+ });
+
+ return result;
+ }
+
+ // findNodesWithChildTags(nodes, tagNames, recursive=false) {
+ // const result = [];
+
+ /** buildMultiSelectProblem()
+ * OLX builder for multiple choice, checkbox, and dropdown problems. The question
+ * builder has a different format than the other parts (demand hint, answers, and
+ * solution) of the problem so it has to be inserted into the OLX after the rest
+ * of the problem is built.
+ * @param {string} problemType - string of problem type tag
+ * @param {string} widget - string of answer tag name
+ * @param {string} option - string of feedback tag name
+ * @return {string} string of OLX
+ */
+ buildMultiSelectProblem(problemType, widget, option) {
+ const question = this.addQuestion();
+ const widgetObject = this.addMultiSelectAnswers(option);
+ const demandhint = this.addHints();
+ const solution = this.addSolution();
+
+ const problemBodyArr = [{
+ [problemType]: [
+ { [widget]: widgetObject },
+ ...solution,
+ ],
+ }];
+
+ const questionString = this.richTextBuilder.build(question);
+ const hintString = this.richTextBuilder.build(demandhint);
+ const problemBody = this.richTextBuilder.build(problemBodyArr);
+ let problemTypeTag;
+
+ switch (problemType) {
+ case ProblemTypeKeys.MULTISELECT:
+ [problemTypeTag] = problemBody.match(/|]+>/);
+ break;
+ case ProblemTypeKeys.DROPDOWN:
+ [problemTypeTag] = problemBody.match(/|]+>/);
+ break;
+ case ProblemTypeKeys.SINGLESELECT:
+ [problemTypeTag] = problemBody.match(/|]+>/);
+ break;
+ default:
+ break;
+ }
+ const questionStringWithEmDescriptionReplace = this.replaceEmWithDescriptionTag(questionString);
+ const updatedString = `${problemTypeTag}\n${questionStringWithEmDescriptionReplace}`;
+ const problemBodyString = problemBody.replace(problemTypeTag, updatedString);
+ const fullProblemString = `${problemBodyString}${hintString}\n `;
+
+ return fullProblemString;
+ }
+
+ replaceEmWithDescriptionTag(xmlString) {
+ const regexPattern = /(.*?)<\/em>/g;
+ const replacement = '$1 ';
+
+ const updatedHtml = xmlString.replace(regexPattern, replacement);
+ return updatedHtml;
+ }
+
+ /** buildTextInput()
+ * String response OLX builder. The question builder has a different format than the
+ * other parts (demand hint, answers, and solution) of the problem so it has to be
+ * inserted into the OLX after the rest of the problem is built.
+ * @return {string} string of string response OLX
+ */
+ buildTextInput() {
+ const question = this.addQuestion();
+ const demandhint = this.addHints();
+ const answerObject = this.buildTextInputAnswersFeedback();
+ const solution = this.addSolution();
+
+ answerObject[ProblemTypeKeys.TEXTINPUT].push(...solution);
+
+ const problemBody = this.richTextBuilder.build([answerObject]);
+ const questionString = this.richTextBuilder.build(question);
+ const hintString = this.richTextBuilder.build(demandhint);
+ const [problemTypeTag] = problemBody.match(/|]+>/);
+ const updatedString = `${problemTypeTag}\n${questionString}`;
+ const problemBodyString = problemBody.replace(problemTypeTag, updatedString);
+ const fullProblemString = `${problemBodyString}${hintString}\n `;
+
+ return fullProblemString;
+ }
+
+ /** buildTextInputAnswersFeedback()
+ * The editorObject saved to the class constuctor is parsed for the attribute
+ * selectedFeedback. String response problems have two types of feedback tags,
+ * correcthint and stringequalhint. Correcthint is for feedback associated with
+ * correct answers and stringequalhint is for feedback associated with wrong
+ * answers. The answers are fetched from the problemState and looped through to
+ * pair feedback with its respective OLX tags. While matching feedback tags,
+ * answers are also mapped to their respective OLX tags. The first correct
+ * answer is wrapped in stringreponse tag. All other correct answers are wrapped
+ * in additonal_answer tags. Incorrect answers are wrapped in stringequalhint
+ * tags. The object representation of the answers is returned with the correct
+ * wrapping tags.
+ * @return {object} object representation of answers
+ */
+ buildTextInputAnswersFeedback() {
+ const { answers, problemType } = this.problemState;
+ const { selectedFeedback } = this.editorObject;
+ let answerObject = { [problemType]: [] };
+ let firstCorrectAnswerParsed = false;
+ answers.forEach((answer) => {
+ const correcthint = this.getAnswerHints(selectedFeedback?.[answer.id]);
+ if (this.hasAttributeWithValue(answer, 'title')) {
+ if (answer.correct && firstCorrectAnswerParsed) {
+ answerObject[problemType].push({
+ ':@': { '@_answer': answer.title },
+ additional_answer: [...correcthint],
+ });
+ } else if (answer.correct && !firstCorrectAnswerParsed) {
+ firstCorrectAnswerParsed = true;
+ answerObject = {
+ ':@': {
+ '@_answer': answer.title,
+ '@_type': _.get(this.problemState, 'additionalAttributes.type', 'ci'),
+ },
+ [problemType]: [...correcthint],
+ };
+ } else if (!answer.correct) {
+ const wronghint = correcthint[0]?.correcthint;
+ answerObject[problemType].push({
+ ':@': { '@_answer': answer.title },
+ stringequalhint: wronghint ? [...wronghint] : [],
+ });
+ }
+ }
+ });
+ answerObject[problemType].push({
+ textline: { '#text': '' },
+ ':@': { '@_size': _.get(this.problemState, 'additionalAttributes.textline.size', 20) },
+ });
+ return answerObject;
+ }
+
+ /** buildNumericInput()
+ * Numeric response OLX builder. The question builder has a different format than the
+ * other parts (demand hint, answers, and solution) of the problem so it has to be
+ * inserted into the OLX after the rest of the problem is built.
+ * @return {string} string of numeric response OLX
+ */
+ buildNumericInput() {
+ const question = this.addQuestion();
+ const demandhint = this.addHints();
+ const answerObject = this.buildNumericalResponse();
+ const solution = this.addSolution();
+
+ answerObject[ProblemTypeKeys.NUMERIC].push(...solution);
+
+ const problemBody = this.richTextBuilder.build([answerObject]);
+ const questionString = this.richTextBuilder.build(question);
+ const hintString = this.richTextBuilder.build(demandhint);
+ const [problemTypeTag] = problemBody.match(/|]+>/);
+ const updatedString = `${questionString}\n${problemTypeTag}`;
+ const problemBodyString = problemBody.replace(problemTypeTag, updatedString);
+ const fullProblemString = `${problemBodyString}${hintString}\n `;
+
+ return fullProblemString;
+ }
+
+ /** buildNumericalResponse()
+ * The editorObject saved to the class constuctor is parsed for the attribute
+ * selectedFeedback. The tolerance is fetched from the problemState settings.
+ * The answers are fetched from the problemState and looped through to
+ * pair feedback with its respective OLX tags. While matching feedback tags,
+ * answers are also mapped to their respective OLX tags. For each answer, if
+ * it is an answer range, it is santized to be less than to great than. The
+ * first answer is wrapped in numericresponse tag. All other answers are
+ * wrapped in additonal_answer tags. The object representation of the answers
+ * is returned with the correct wrapping tags.
+ * @return {object} object representation of answers
+ */
+ buildNumericalResponse() {
+ const { answers, problemType } = this.problemState;
+ const { tolerance } = this.problemState.settings;
+ const { selectedFeedback } = this.editorObject;
+ let answerObject = { [problemType]: [] };
+ let firstCorrectAnswerParsed = false;
+ answers.forEach((answer) => {
+ const correcthint = this.getAnswerHints(selectedFeedback?.[answer.id]);
+ if (this.hasAttributeWithValue(answer, 'title')) {
+ let { title } = answer;
+ if (title.startsWith('(') || title.startsWith('[')) {
+ const parsedRange = title.split(',');
+ const [rawLowerBound, rawUpperBound] = parsedRange;
+ let lowerBoundInt;
+ let lowerBoundFraction;
+ let upperBoundInt;
+ let upperBoundFraction;
+ if (rawLowerBound.includes('/')) {
+ lowerBoundFraction = rawLowerBound.replace(/[^0-9-/]/gm, '');
+ const [numerator, denominator] = lowerBoundFraction.split('/');
+ const lowerBoundFloat = Number(numerator) / Number(denominator);
+ lowerBoundInt = lowerBoundFloat;
+ } else {
+ // these regex replaces remove everything that is not a decimal or positive/negative numer
+ lowerBoundInt = Number(rawLowerBound.replace(/[^0-9-.]/gm, ''));
+ }
+ if (rawUpperBound.includes('/')) {
+ upperBoundFraction = rawUpperBound.replace(/[^0-9-/]/gm, '');
+ const [numerator, denominator] = upperBoundFraction.split('/');
+ const upperBoundFloat = Number(numerator) / Number(denominator);
+ upperBoundInt = upperBoundFloat;
+ } else {
+ // these regex replaces remove everything that is not a decimal or positive/negative numer
+ upperBoundInt = Number(rawUpperBound.replace(/[^0-9-.]/gm, ''));
+ }
+ if (lowerBoundInt > upperBoundInt) {
+ const lowerBoundChar = rawUpperBound[rawUpperBound.length - 1] === ']' ? '[' : '(';
+ const upperBoundChar = rawLowerBound[0] === '[' ? ']' : ')';
+ if (lowerBoundFraction) {
+ lowerBoundInt = lowerBoundFraction;
+ }
+ if (upperBoundFraction) {
+ upperBoundInt = upperBoundFraction;
+ }
+ title = `${lowerBoundChar}${upperBoundInt},${lowerBoundInt}${upperBoundChar}`;
+ }
+ }
+ if (answer.correct && !firstCorrectAnswerParsed) {
+ firstCorrectAnswerParsed = true;
+ const responseParam = [];
+ if (tolerance?.value) {
+ responseParam.push({
+ responseparam: [],
+ ':@': {
+ '@_type': 'tolerance',
+ '@_default': `${tolerance.value}${tolerance.type === ToleranceTypes.number.type ? '' : '%'}`,
+ },
+ });
+ }
+ answerObject = {
+ ':@': { '@_answer': title },
+ [problemType]: [...responseParam, ...correcthint],
+ };
+ } else if (answer.correct && firstCorrectAnswerParsed) {
+ answerObject[problemType].push({
+ ':@': { '@_answer': title },
+ additional_answer: [...correcthint],
+ });
+ }
+ }
+ });
+ answerObject[problemType].push({ formulaequationinput: { '#text': '' } });
+ return answerObject;
+ }
+
+ /** getAnswerHints(feedback)
+ * getAnswerHints takes feedback. The feedback is checked for definition. If feedback is
+ * undefined or an empty string, it returns an empty object. The defined feedback is
+ * parsed and saved to the key correcthint. Correcthint is the tag name for
+ * numeric response and string response feedback.
+ * @param {string} feedback - string of feedback
+ * @return {object} object representaion of feedback
+ */
+ getAnswerHints(feedback) {
+ const correcthint = [];
+ if (feedback !== undefined && feedback !== '') {
+ const parsedFeedback = this.richTextParser.parse(feedback);
+ correcthint.push({ correcthint: parsedFeedback });
+ }
+ return correcthint;
+ }
+
+ /** hasAttributeWithValue(obj, attr)
+ * hasAttributeWithValue takes obj and atrr. The obj is checked for the attribute defined by attr.
+ * Returns true if attribute is present, otherwise false.
+ * @param {object} obj - defined object
+ * @param {string} attr - string of desired attribute
+ * @return {bool}
+ */
+ hasAttributeWithValue(obj, attr) {
+ return _.has(obj, attr) && _.get(obj, attr, '').toString().trim() !== '';
+ }
+
+ buildOLX() {
+ const { problemType } = this.problemState;
+ let problemString = '';
+
+ switch (problemType) {
+ case ProblemTypeKeys.MULTISELECT:
+ problemString = this.buildMultiSelectProblem(ProblemTypeKeys.MULTISELECT, 'checkboxgroup', 'choice');
+ break;
+ case ProblemTypeKeys.DROPDOWN:
+ problemString = this.buildMultiSelectProblem(ProblemTypeKeys.DROPDOWN, 'optioninput', 'option');
+ break;
+ case ProblemTypeKeys.SINGLESELECT:
+ problemString = this.buildMultiSelectProblem(ProblemTypeKeys.SINGLESELECT, 'choicegroup', 'choice');
+ break;
+ case ProblemTypeKeys.TEXTINPUT:
+ problemString = this.buildTextInput();
+ break;
+ case ProblemTypeKeys.NUMERIC:
+ problemString = this.buildNumericInput();
+ break;
+ default:
+ break;
+ }
+ return problemString;
+ }
+}
+
+export default ReactStateOLXParser;
diff --git a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.test.js b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.test.js
new file mode 100644
index 0000000000..416fafdcd8
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.test.js
@@ -0,0 +1,150 @@
+import { OLXParser } from './OLXParser';
+import {
+ checkboxesOLXWithFeedbackAndHintsOLX,
+ dropdownOLXWithFeedbackAndHintsOLX,
+ numericInputWithFeedbackAndHintsOLX,
+ numericInputWithAnswerRangeOLX,
+ textInputWithFeedbackAndHintsOLX,
+ multipleChoiceWithFeedbackAndHintsOLX,
+ textInputWithFeedbackAndHintsOLXWithMultipleAnswers,
+ numberParseTestOLX,
+} from './mockData/olxTestData';
+import {
+ checkboxesWithFeedbackAndHints,
+ dropdownWithFeedbackAndHints,
+ textInputWithFeedbackAndHints,
+ multipleChoiceWithFeedbackAndHints,
+ numericInputWithFeedbackAndHints,
+ numericInputWithAnswerRange,
+ textInputWithFeedbackAndHintsWithMultipleAnswers,
+ numberParseTest,
+} from './mockData/editorTestData';
+import ReactStateOLXParser from './ReactStateOLXParser';
+
+describe('Check React State OLXParser problem', () => {
+ test('for checkbox with feedback and hints problem type', () => {
+ const olxparser = new OLXParser(checkboxesOLXWithFeedbackAndHintsOLX.rawOLX);
+ const problem = olxparser.getParsedOLXData();
+ const stateParser = new ReactStateOLXParser({
+ problem,
+ editorObject: checkboxesWithFeedbackAndHints,
+ });
+ const buildOLX = stateParser.buildOLX();
+ expect(buildOLX.replace(/\s/g, '')).toBe(checkboxesOLXWithFeedbackAndHintsOLX.buildOLX.replace(/\s/g, ''));
+ });
+ test('Test dropdown with feedback and hints problem type', () => {
+ const olxparser = new OLXParser(dropdownOLXWithFeedbackAndHintsOLX.rawOLX);
+ const problem = olxparser.getParsedOLXData();
+ const stateParser = new ReactStateOLXParser({
+ problem,
+ editorObject: dropdownWithFeedbackAndHints,
+ });
+ const buildOLX = stateParser.buildOLX();
+ expect(buildOLX.replace(/\s/g, '')).toEqual(dropdownOLXWithFeedbackAndHintsOLX.buildOLX.replace(/\s/g, ''));
+ });
+ test('Test string response with feedback and hints problem type', () => {
+ const olxparser = new OLXParser(textInputWithFeedbackAndHintsOLX.rawOLX);
+ const problem = olxparser.getParsedOLXData();
+ const stateParser = new ReactStateOLXParser({
+ problem,
+ editorObject: textInputWithFeedbackAndHints,
+ });
+ const buildOLX = stateParser.buildOLX();
+ expect(buildOLX.replace(/\s/g, '')).toEqual(textInputWithFeedbackAndHintsOLX.buildOLX.replace(/\s/g, ''));
+ });
+ describe('Multiple choice with feedback and hints problem type', () => {
+ const olxparser = new OLXParser(multipleChoiceWithFeedbackAndHintsOLX.rawOLX);
+ const problem = olxparser.getParsedOLXData();
+ const stateParser = new ReactStateOLXParser({
+ problem,
+ editorObject: multipleChoiceWithFeedbackAndHints,
+ });
+
+ it('should parse correctly', () => {
+ const buildOLX = stateParser.buildOLX();
+ expect(buildOLX.replace(/\s/g, '')).toEqual(multipleChoiceWithFeedbackAndHintsOLX.buildOLX.replace(/\s/g, ''));
+ });
+ });
+
+ describe('with label and em tag wrapped in div: ', () => {
+ const olxparser = new OLXParser(multipleChoiceWithFeedbackAndHintsOLX.rawOLX);
+ const problem = olxparser.getParsedOLXData();
+ const stateParser = new ReactStateOLXParser({
+ problem,
+ editorObject: multipleChoiceWithFeedbackAndHints,
+ });
+ stateParser.editorObject.question = 'You can use this template as a guide to the simple editor markdown and OLX markup to use for multiple choice with hints and feedback problems. Edit this component to replace this template with your own assessment.
\nAdd the question text, or prompt, here. This text is required. You can add an optional tip or note related to the prompt like this. Just a generic em tag
';
+
+ it('parser should not delete tags', () => {
+ const buildOLX = stateParser.buildOLX();
+ expect(buildOLX.replace(/\s/g, '')).toEqual(multipleChoiceWithFeedbackAndHintsOLX.buildOLX.replace(/\s/g, ''));
+ });
+
+ it('addQuestion method should add a question to the problemState correctly', () => {
+ const received = stateParser.addQuestion();
+ expect(received).toEqual(
+ [
+ { p: [{ '#text': 'You can use this template as a guide to the simple editor markdown and OLX markup to use for multiple choice with hints and feedback problems. Edit this component to replace this template with your own assessment.' }] },
+ { label: [{ '#text': 'Add the question text, or prompt, here. This text is required.' }] },
+ { '#text': ' ' },
+ { em: [{ '#text': 'You can add an optional tip or note related to the prompt like this. ' }], ':@': { '@_class': 'olx_description' } },
+ { em: [{ '#text': 'Just a generic em tag' }] },
+ ],
+ );
+ });
+ });
+
+ test('Test numerical response with feedback and hints problem type', () => {
+ const olxparser = new OLXParser(numericInputWithFeedbackAndHintsOLX.rawOLX);
+ const problem = olxparser.getParsedOLXData();
+ const stateParser = new ReactStateOLXParser({
+ problem,
+ editorObject: numericInputWithFeedbackAndHints,
+ });
+ const buildOLX = stateParser.buildOLX();
+ expect(buildOLX.replace(/\s/g, '')).toEqual(numericInputWithFeedbackAndHintsOLX.buildOLX.replace(/\s/g, ''));
+ });
+
+ test('Test numerical response with isAnswerRange true', () => {
+ const olxparser = new OLXParser(numericInputWithAnswerRangeOLX.rawOLX);
+ const problem = olxparser.getParsedOLXData();
+ const stateParser = new ReactStateOLXParser({
+ problem,
+ editorObject: numericInputWithAnswerRange,
+ });
+ const buildOLX = stateParser.buildOLX();
+ expect(buildOLX.replace(/\s/g, '')).toEqual(numericInputWithAnswerRangeOLX.buildOLX.replace(/\s/g, ''));
+ });
+ test('Test string response with feedback and hints, multiple answers', () => {
+ const olxparser = new OLXParser(textInputWithFeedbackAndHintsOLXWithMultipleAnswers.rawOLX);
+ const problem = olxparser.getParsedOLXData();
+ const stateParser = new ReactStateOLXParser({
+ problem,
+ editorObject: textInputWithFeedbackAndHintsWithMultipleAnswers,
+ });
+ const buildOLX = stateParser.buildOLX();
+ expect(buildOLX.replace(/\s/g, '')).toEqual(textInputWithFeedbackAndHintsOLXWithMultipleAnswers.buildOLX.replace(/\s/g, ''));
+ });
+ describe('encode/decode', () => {
+ test('does not change hex values to dec and does not remove leading 0s', () => {
+ const olxparser = new OLXParser(numberParseTestOLX.rawOLX);
+ const problem = olxparser.getParsedOLXData();
+ const stateParser = new ReactStateOLXParser({
+ problem,
+ editorObject: numberParseTest,
+ });
+ const buildOLX = stateParser.buildOLX();
+ expect(buildOLX.replace(/\s/g, '')).toEqual(numberParseTestOLX.buildOLX.replace(/\s/g, ''));
+ });
+ test('correctly preserves whitespace inside pre tags', () => {
+ const stateParser = new ReactStateOLXParser({
+ problem: { problemType: 'optionresponse', answers: [] },
+ editorObject: { question: ' 1 a 2 b ', hints: [] },
+ });
+ const buildOLX = stateParser.buildOLX();
+ expect(buildOLX).toEqual(
+ '\n 1 a 2 b \n ',
+ );
+ });
+ });
+});
diff --git a/src/editors/containers/ProblemEditor/data/ReactStateSettingsParser.js b/src/editors/containers/ProblemEditor/data/ReactStateSettingsParser.js
new file mode 100644
index 0000000000..49ddac5324
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/data/ReactStateSettingsParser.js
@@ -0,0 +1,74 @@
+import { XMLParser } from 'fast-xml-parser';
+import _ from 'lodash';
+
+import {
+ ShowAnswerTypesKeys,
+} from '../../../data/constants/problem';
+import { popuplateItem } from './SettingsParser';
+
+const SETTING_KEYS = [
+ 'max_attempts',
+ 'weight',
+ 'showanswer',
+ 'show_reset_button',
+ 'rerandomize',
+];
+
+class ReactStateSettingsParser {
+ constructor(problemState) {
+ this.problem = problemState.problem;
+ this.rawOLX = problemState.rawOLX;
+ }
+
+ getSettings() {
+ let settings = {};
+ const { defaultSettings } = this.problem;
+ const stateSettings = this.problem.settings;
+
+ const numberOfAttemptsChoice = [
+ ShowAnswerTypesKeys.AFTER_SOME_NUMBER_OF_ATTEMPTS,
+ ShowAnswerTypesKeys.AFTER_ALL_ATTEMPTS,
+ ShowAnswerTypesKeys.AFTER_ALL_ATTEMPTS_OR_CORRECT,
+ ];
+
+ settings = popuplateItem(settings, 'number', 'max_attempts', stateSettings.scoring.attempts, defaultSettings?.maxAttempts, true);
+ settings = popuplateItem(settings, 'weight', 'weight', stateSettings.scoring);
+ settings = popuplateItem(settings, 'on', 'showanswer', stateSettings.showAnswer, defaultSettings?.showanswer);
+ if (_.includes(numberOfAttemptsChoice, stateSettings.showAnswer.on)) {
+ settings = popuplateItem(settings, 'afterAttempts', 'attempts_before_showanswer_button', stateSettings.showAnswer);
+ }
+ settings = popuplateItem(settings, 'showResetButton', 'show_reset_button', stateSettings, defaultSettings?.showResetButton);
+ settings = popuplateItem(settings, 'timeBetween', 'submission_wait_seconds', stateSettings);
+ settings = popuplateItem(settings, 'randomization', 'rerandomize', stateSettings, defaultSettings?.rerandomize);
+
+ return settings;
+ }
+
+ parseRawOlxSettings() {
+ const rawOlxSettings = this.getSettings();
+ const parserOptions = {
+ ignoreAttributes: false,
+ alwaysCreateTextNode: true,
+ numberParseOptions: {
+ leadingZeros: false,
+ hex: false,
+ },
+ };
+ const parser = new XMLParser(parserOptions);
+ const olx = parser.parse(this.rawOLX);
+ const settingAttributes = Object.keys(olx.problem).filter(tag => tag.startsWith('@_'));
+ settingAttributes.forEach(attribute => {
+ const attributeKey = attribute.substring(2);
+ if (SETTING_KEYS.includes(attributeKey)) {
+ if (attributeKey === 'max_attempts' || attributeKey === 'weight') {
+ rawOlxSettings[attributeKey] = parseInt(olx.problem[attribute], 10);
+ } else {
+ rawOlxSettings[attributeKey] = olx.problem[attribute];
+ }
+ }
+ });
+ return rawOlxSettings;
+ }
+}
+
+export default ReactStateSettingsParser;
diff --git a/src/editors/containers/ProblemEditor/data/ReactStateSettingsParser.test.js b/src/editors/containers/ProblemEditor/data/ReactStateSettingsParser.test.js
new file mode 100644
index 0000000000..0979dd3a85
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/data/ReactStateSettingsParser.test.js
@@ -0,0 +1,20 @@
+import ReactStateSettingsParser from './ReactStateSettingsParser';
+import {
+ checklistWithFeebackHints,
+} from './mockData/problemTestData';
+
+describe('Test State to Settings Parser', () => {
+ test('Test settings parsed from react state', () => {
+ const settings = new ReactStateSettingsParser({ problem: checklistWithFeebackHints.state }).getSettings();
+ const { markdown, ...settingsPayload } = checklistWithFeebackHints.metadata;
+ expect(settings).toStrictEqual(settingsPayload);
+ });
+ test('Test settings parsed from raw olx', () => {
+ const settings = new ReactStateSettingsParser({
+ problem: checklistWithFeebackHints.state,
+ rawOLX: 'text ',
+ }).parseRawOlxSettings();
+ const { markdown, ...settingsPayload } = checklistWithFeebackHints.metadata;
+ expect(settings).toStrictEqual({ ...settingsPayload, showanswer: 'always' });
+ });
+});
diff --git a/src/editors/containers/ProblemEditor/data/SettingsParser.js b/src/editors/containers/ProblemEditor/data/SettingsParser.js
new file mode 100644
index 0000000000..1c90470675
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/data/SettingsParser.js
@@ -0,0 +1,85 @@
+import _ from 'lodash';
+
+import { ShowAnswerTypes, RandomizationTypesKeys } from '../../../data/constants/problem';
+
+export const popuplateItem = (parentObject, itemName, statekey, metadata, defaultValue = null, allowNull = false) => {
+ let parent = parentObject;
+ const item = _.get(metadata, itemName, null);
+ const equalsDefault = item === defaultValue;
+ if (allowNull) {
+ parent = { ...parentObject, [statekey]: item };
+ } else if (!_.isNil(item) && !equalsDefault) {
+ parent = { ...parentObject, [statekey]: item };
+ }
+ return parent;
+};
+
+export const parseScoringSettings = (metadata, defaultSettings) => {
+ let scoring = {};
+
+ const attempts = popuplateItem({}, 'max_attempts', 'number', metadata);
+ const initialAttempts = _.get(attempts, 'number', null);
+ const defaultAttempts = _.get(defaultSettings, 'max_attempts', null);
+ attempts.unlimited = false;
+
+ // isFinite checks if value is a finite primitive number.
+ if (!_.isFinite(initialAttempts) || initialAttempts === defaultAttempts) {
+ // set number to null in any case as lms will pick default value if it exists.
+ attempts.number = null;
+ }
+
+ // if both block number and default number are null set unlimited to true.
+ if (_.isNil(initialAttempts) && _.isNil(defaultAttempts)) {
+ attempts.unlimited = true;
+ }
+
+ if (attempts.number < 0) {
+ attempts.number = 0;
+ }
+
+ scoring = { ...scoring, attempts };
+
+ scoring = popuplateItem(scoring, 'weight', 'weight', metadata);
+
+ return scoring;
+};
+
+export const parseShowAnswer = (metadata) => {
+ let showAnswer = {};
+
+ const showAnswerType = _.get(metadata, 'showanswer', {});
+ if (!_.isNil(showAnswerType) && showAnswerType in ShowAnswerTypes) {
+ showAnswer = { ...showAnswer, on: showAnswerType };
+ }
+
+ showAnswer = popuplateItem(showAnswer, 'attempts_before_showanswer_button', 'afterAttempts', metadata);
+
+ return showAnswer;
+};
+
+export const parseSettings = (metadata, defaultSettings) => {
+ let settings = {};
+
+ if (_.isNil(metadata) || _.isEmpty(metadata)) {
+ return settings;
+ }
+
+ const scoring = parseScoringSettings(metadata, defaultSettings);
+ if (!_.isEmpty(scoring)) {
+ settings = { ...settings, scoring };
+ }
+
+ const showAnswer = parseShowAnswer(metadata);
+ if (!_.isEmpty(showAnswer)) {
+ settings = { ...settings, showAnswer };
+ }
+
+ const randomizationType = _.get(metadata, 'rerandomize', {});
+ if (!_.isEmpty(randomizationType) && Object.values(RandomizationTypesKeys).includes(randomizationType)) {
+ settings = popuplateItem(settings, 'rerandomize', 'randomization', metadata);
+ }
+
+ settings = popuplateItem(settings, 'show_reset_button', 'showResetButton', metadata);
+ settings = popuplateItem(settings, 'submission_wait_seconds', 'timeBetween', metadata);
+ return settings;
+};
diff --git a/src/editors/containers/ProblemEditor/data/SettingsParser.test.js b/src/editors/containers/ProblemEditor/data/SettingsParser.test.js
new file mode 100644
index 0000000000..063484ceac
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/data/SettingsParser.test.js
@@ -0,0 +1,82 @@
+import { parseScoringSettings, parseSettings, parseShowAnswer } from './SettingsParser';
+import {
+ checklistWithFeebackHints,
+ numericWithHints,
+ textInputWithHints,
+ singleSelectWithHints,
+ negativeAttempts,
+} from './mockData/problemTestData';
+
+describe('Test Settings to State Parser', () => {
+ const defaultSettings = { max_attempts: 1 };
+ test('Test all fields populated', () => {
+ const settings = parseSettings(checklistWithFeebackHints.metadata, defaultSettings);
+ const { hints, ...settingsPayload } = checklistWithFeebackHints.state.settings;
+ expect(settings).toStrictEqual(settingsPayload);
+ });
+
+ test('Test score settings', () => {
+ const scoreSettings = parseScoringSettings(checklistWithFeebackHints.metadata, defaultSettings);
+ expect(scoreSettings).toStrictEqual(checklistWithFeebackHints.state.settings.scoring);
+ });
+
+ test('Test score settings zero attempts', () => {
+ const scoreSettings = parseScoringSettings(numericWithHints.metadata, defaultSettings);
+ expect(scoreSettings).toStrictEqual(numericWithHints.state.settings.scoring);
+ });
+
+ test('Test score settings attempts missing with no default max_attempts', () => {
+ const scoreSettings = parseScoringSettings(singleSelectWithHints.metadata, {});
+ expect(scoreSettings.attempts).toStrictEqual(singleSelectWithHints.state.settings.scoring.attempts);
+ });
+
+ test('Test score settings attempts missing with default max_attempts', () => {
+ const scoreSettings = parseScoringSettings(singleSelectWithHints.metadata, defaultSettings);
+ expect(scoreSettings.attempts).toStrictEqual({ number: null, unlimited: false });
+ });
+
+ test('Test negative attempts in score', () => {
+ const scoreSettings = parseScoringSettings(negativeAttempts.metadata, defaultSettings);
+ expect(scoreSettings.attempts).toStrictEqual(negativeAttempts.state.settings.scoring.attempts);
+ });
+
+ test('Test score settings missing with no default', () => {
+ const settings = parseSettings(singleSelectWithHints.metadata, {});
+ expect(settings.scoring).toStrictEqual(singleSelectWithHints.state.settings.scoring);
+ });
+
+ test('Test score settings missing with default', () => {
+ const settings = parseSettings(singleSelectWithHints.metadata, defaultSettings);
+ expect(settings.scoring).toStrictEqual({ attempts: { number: null, unlimited: false } });
+ });
+
+ test('Test score settings missing with null default', () => {
+ const settings = parseSettings(singleSelectWithHints.metadata, { max_attempts: null });
+ expect(settings.scoring).toStrictEqual({ attempts: { number: null, unlimited: true } });
+ });
+
+ test('Test invalid randomization', () => {
+ const settings = parseSettings(numericWithHints.metadata, defaultSettings);
+ expect(settings.randomization).toBeUndefined();
+ });
+
+ test('Test invalid show answer', () => {
+ const showAnswerSettings = parseShowAnswer(numericWithHints.metadata);
+ expect(showAnswerSettings.on).toBeUndefined();
+ });
+
+ test('Test show answer settings missing', () => {
+ const settings = parseShowAnswer(textInputWithHints.metadata);
+ expect(settings.showAnswer).toBeUndefined();
+ });
+
+ test('Test empty metadata', () => {
+ const scoreSettings = parseSettings({}, defaultSettings);
+ expect(scoreSettings).toStrictEqual({});
+ });
+
+ test('Test null metadata', () => {
+ const scoreSettings = parseSettings(null, defaultSettings);
+ expect(scoreSettings).toStrictEqual({});
+ });
+});
diff --git a/src/editors/containers/ProblemEditor/data/mockData/editorTestData.js b/src/editors/containers/ProblemEditor/data/mockData/editorTestData.js
new file mode 100644
index 0000000000..a96230c46e
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/data/mockData/editorTestData.js
@@ -0,0 +1,132 @@
+export const checkboxesWithFeedbackAndHints = {
+ solution: `
+
+ You can form a voltage divider that evenly divides the input
+ voltage with two identically valued resistors, with the sampled
+ voltage taken in between the two.
+
+
+ `,
+ question: 'You can use this template as a guide to the simple editor markdown and OLX markup to use for checkboxes with hints and feedback problems. Edit this component to replace this template with your own assessment.
Add the question text, or prompt, here. This text is required. You can add an optional tip or note related to the prompt like this. ',
+ answers: {
+ A: 'a correct answer
',
+ B: 'an incorrect answer
',
+ C: 'an incorrect answer
',
+ D: 'a correct answer
',
+ },
+ selectedFeedback: {
+ A: 'You can specify optional feedback that appears after the learner selects and submits this answer.
',
+ B: '',
+ C: 'You can specify optional feedback for none, all, or a subset of the answers.
',
+ D: '',
+ },
+ unselectedFeedback: {
+ A: 'You can specify optional feedback that appears after the learner clears and submits this answer.
',
+ B: '',
+ C: 'You can specify optional feedback for selected answers, cleared answers, or both.
',
+ D: '',
+ },
+ hints: [
+ 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
',
+ 'If you add more than one hint, a different hint appears each time learners select the hint button.
',
+ ],
+ groupFeedback: {
+ 0: 'You can specify optional feedback for a combination of answers which appears after the specified set of answers is submitted.',
+ 1: 'You can specify optional feedback for one, several, or all answer combinations.',
+ },
+};
+
+export const dropdownWithFeedbackAndHints = {
+ solution: '',
+ selectedFeedback: {
+ A: 'You can specify optional feedback like this, which appears after this answer is submitted.
',
+ B: '',
+ C: 'You can specify optional feedback for none, a subset, or all of the answers.
',
+ },
+ hints: [
+ 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
',
+ 'If you add more than one hint, a different hint appears each time learners select the hint button.
',
+ ],
+ question: 'You can use this template as a guide to the simple editor markdown and OLX markup to use for dropdown with hints and feedback problems. Edit this component to replace this template with your own assessment.
Add the question text, or prompt, here. This text is required. You can add an optional tip or note related to the prompt like this. ',
+};
+
+export const multipleChoiceWithFeedbackAndHints = {
+ solution: 'You can add a solution
',
+ question: `You can use this template as a guide to the simple editor markdown and OLX markup to use for multiple choice with hints and feedback problems. Edit this component to replace this template with your own assessment.
+ Add the question text, or prompt, here. This text is required.
+ You can add an optional tip or note related to the prompt like this. `,
+ answers: {
+ A: 'an incorrect answer
',
+ B: 'the correct answer
',
+ C: 'an incorrect answer
',
+ },
+ selectedFeedback: {
+ A: 'You can specify optional feedback like this, which appears after this answer is submitted.
',
+ B: '',
+ C: 'You can specify optional feedback for none, a subset, or all of the answers.
',
+ },
+ hints: [
+ 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
',
+ 'If you add more than one hint, a different hint appears each time learners select the hint button.
',
+ ],
+};
+
+export const numericInputWithFeedbackAndHints = {
+ solution: '',
+ selectedFeedback: {
+ A: 'You can specify optional feedback like this, which appears after this answer is submitted.
',
+ B: 'You can specify optional feedback like this, which appears after this answer is submitted.
',
+ },
+ hints: [
+ 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
',
+ 'If you add more than one hint, a different hint appears each time learners select the hint button.
',
+ ],
+ question: 'You can use this template as a guide to the simple editor markdown and OLX markup to use for numerical input with hints and feedback problems. Edit this component to replace this template with your own assessment.
Add the question text, or prompt, here. This text is required. You can add an optional tip or note related to the prompt like this. ',
+};
+
+export const numericInputWithAnswerRange = {
+ solution: '',
+ hints: [],
+ question: 'You can use this template as a guide to the simple editor markdown and OLX markup to use for numerical input with hints and feedback problems. Edit this component to replace this template with your own assessment.
Add the question text, or prompt, here. This text is required. You can add an optional tip or note related to the prompt like this. ',
+};
+
+export const textInputWithFeedbackAndHints = {
+ solution: '',
+ selectedFeedback: {
+ A: 'You can specify optional feedback like this, which appears after this answer is submitted.
',
+ B: '',
+ C: 'You can specify optional feedback for none, a subset, or all of the answers.
',
+ },
+ hints: [
+ 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
',
+ 'If you add more than one hint, a different hint appears each time learners select the hint button.
',
+ ],
+ question: '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.
Add the question text, or prompt, here. This text is required. You can add an optional tip or note related to the prompt like this. ',
+};
+
+export const textInputWithFeedbackAndHintsWithMultipleAnswers = {
+ solution: '',
+ selectedFeedback: {
+ A: 'You can specify optional feedback like this, which appears after this answer is submitted.
',
+ B: 'You can specify optional feedback like this, which appears after this answer is submitted.
',
+ C: 'You can specify optional feedback like this, which appears after this answer is submitted.
',
+ D: 'You can specify optional feedback for none, a subset, or all of the answers.
',
+ },
+ hints: [
+ 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
',
+ 'If you add more than one hint, a different hint appears each time learners select the hint button.
',
+ ],
+ question: '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.
Add the question text, or prompt, here. This text is required. You can add an optional tip or note related to the prompt like this. ',
+};
+
+export const numberParseTest = {
+ solution: '',
+ answers: {
+ A: `0x10 `, // eslint-disable-line
+ B: `0x0f `, // eslint-disable-line
+ C: `0x07 `, // eslint-disable-line
+ D: `0009 `, // eslint-disable-line
+ },
+ hints: [],
+ question: 'What is the content of the register x2 after executing the following three lines of instructions?
',
+};
diff --git a/src/editors/containers/ProblemEditor/data/mockData/olxTestData.js b/src/editors/containers/ProblemEditor/data/mockData/olxTestData.js
new file mode 100644
index 0000000000..0158428f03
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/data/mockData/olxTestData.js
@@ -0,0 +1,1174 @@
+/* eslint-disable */
+// lint is disabled for this file due to strict spacing
+
+export const checkboxesOLXWithFeedbackAndHintsOLX = {
+ rawOLX: `
+
+ You can use this template as a guide to the simple editor markdown and OLX markup to use for checkboxes with hints and feedback problems. Edit this component to replace this template with your own assessment.
+ Add the question text, or prompt, here. This text is required.
+ You can add an optional tip or note related to the prompt like this.
+
+ a correct answer
+ You can specify optional feedback that appears after the learner selects and submits this answer.
+ You can specify optional feedback that appears after the learner clears and submits this answer.
+
+ an incorrect answer
+ an incorrect answer
+ You can specify optional feedback for none, all, or a subset of the answers.
+ You can specify optional feedback for selected answers, cleared answers, or both.
+
+ a correct answer
+ You can specify optional feedback for a combination of answers which appears after the specified set of answers is submitted.
+ You can specify optional feedback for one, several, or all answer combinations.
+
+
+
+
Explanation
+
+ You can form a voltage divider that evenly divides the input
+ voltage with two identically valued resistors, with the sampled
+ voltage taken in between the two.
+
+
+
+
+
+
+ You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
+ If you add more than one hint, a different hint appears each time learners select the hint button.
+
+ `,
+ hints: [
+ {
+ id: 0,
+ value: 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
',
+ },
+ {
+ id: 1,
+ value: 'If you add more than one hint, a different hint appears each time learners select the hint button.
',
+ },
+ ],
+ solutionExplanation: `
+
+ You can form a voltage divider that evenly divides the input
+ voltage with two identically valued resistors, with the sampled
+ voltage taken in between the two.
+
+
`,
+ data: {
+ answers: [
+ {
+ id: 'A',
+ title: `a correct answer
\n \n \n `,
+ correct: true,
+ selectedFeedback: 'You can specify optional feedback that appears after the learner selects and submits this answer.
',
+ unselectedFeedback: 'You can specify optional feedback that appears after the learner clears and submits this answer.
',
+ },
+ {
+ id: 'B',
+ title: 'an incorrect answer
',
+ correct: false,
+ },
+ {
+ id: 'C',
+ title: `an incorrect answer
\n \n \n `,
+ correct: false,
+ selectedFeedback: 'You can specify optional feedback for none, all, or a subset of the answers.
',
+ unselectedFeedback: 'You can specify optional feedback for selected answers, cleared answers, or both.
',
+ },
+ {
+ id: 'D',
+ title: 'a correct answer
',
+ correct: true,
+ },
+ ],
+ groupFeedbackList: [
+ {
+ id: 0,
+ answers: [
+ 'A',
+ 'B',
+ 'D',
+ ],
+ feedback: 'You can specify optional feedback for a combination of answers which appears after the specified set of answers is submitted.',
+ },
+ {
+ id: 1,
+ answers: [
+ 'A',
+ 'B',
+ 'C',
+ 'D',
+ ],
+ feedback: 'You can specify optional feedback for one, several, or all answer combinations.',
+ },
+ ],
+ },
+ question: 'You can use this template as a guide to the simple editor markdown and OLX markup to use for checkboxes with hints and feedback problems. Edit this component to replace this template with your own assessment.
Add the question text, or prompt, here. This text is required. You can add an optional tip or note related to the prompt like this. ',
+ buildOLX: `
+
+ You can use this template as a guide to the simple editor markdown and OLX markup to use for checkboxes with hints and feedback problems. Edit this component to replace this template with your own assessment.
+ Add the question text, or prompt, here. This text is required.
+ You can add an optional tip or note related to the prompt like this.
+
+
+ a correct answer
You can specify optional feedback that appears after the learner selects and submits this answer.
+ You can specify optional feedback that appears after the learner clears and submits this answer.
+
+ an incorrect answer
+
+ an incorrect answer
You can specify optional feedback for none, all, or a subset of the answers.
+ You can specify optional feedback for selected answers, cleared answers, or both.
+
+ a correct answer
+ You can specify optional feedback for a combination of answers which appears after the specified set of answers is submitted.
+ You can specify optional feedback for one, several, or all answer combinations.
+
+
+
+
Explanation
+
+ You can form a voltage divider that evenly divides the input
+ voltage with two identically valued resistors, with the sampled
+ voltage taken in between the two.
+
+
+
+
+
+
+ You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
+ If you add more than one hint, a different hint appears each time learners select the hint button.
+
+
+ `,
+};
+
+export const multipleChoiceWithoutAnswers = {
+ rawOLX: `
+
+
+
+
+
+
+ `,
+ data: {
+ answers: [
+ {
+ id: 'A',
+ title: '',
+ correct: true,
+ },
+ ],
+ },
+};
+
+export const multipleChoiceSingleAnswer = {
+ rawOLX: `
+
+ You can use this template as a guide to the simple editor markdown and OLX markup to use for checkboxes with hints and feedback problems. Edit this component to replace this template with your own assessment.
+ Add the question text, or prompt, here. This text is required.
+ You can add an optional tip or note related to the prompt like this.
+
+ a correct answer
image with
caption .
+ You can specify optional feedback that appears after the learner selects and submits this answer.
+ You can specify optional feedback that appears after the learner clears and submits this answer.
+
+
+
+
+
Explanation
+
+ You can form a voltage divider that evenly divides the input
+ voltage with two identically valued resistors, with the sampled
+ voltage taken in between the two.
+
+
+
+
+
+ `,
+ solutionExplanation: `
+
+ You can form a voltage divider that evenly divides the input
+ voltage with two identically valued resistors, with the sampled
+ voltage taken in between the two.
+
+
`,
+ data: {
+ answers: [
+ {
+ id: 'A',
+ title: `a correct answer
image with
caption .
\n \n \n `,
+ correct: true,
+ selectedFeedback: 'You can specify optional feedback that appears after the learner selects and submits this answer.
',
+ unselectedFeedback: 'You can specify optional feedback that appears after the learner clears and submits this answer.
',
+ },
+ ],
+ },
+ question: 'You can use this template as a guide to the simple editor markdown and OLX markup to use for checkboxes with hints and feedback problems. Edit this component to replace this template with your own assessment.
Add the question text, or prompt, here. This text is required. You can add an optional tip or note related to the prompt like this. ',
+ buildOLX: `
+
+ You can use this template as a guide to the simple editor markdown and OLX markup to use for checkboxes with hints and feedback problems. Edit this component to replace this template with your own assessment.
+ Add the question text, or prompt, here. This text is required.
+ You can add an optional tip or note related to the prompt like this.
+
+
+a correct answer
You can specify optional feedback that appears after the learner selects and submits this answer.
+ You can specify optional feedback that appears after the learner clears and submits this answer.
+
+
+
+
+
Explanation
+
+ You can form a voltage divider that evenly divides the input
+ voltage with two identically valued resistors, with the sampled
+ voltage taken in between the two.
+
+
+
+
+
+
+`,
+};
+
+export const dropdownOLXWithFeedbackAndHintsOLX = {
+ rawOLX: `
+
+ You can use this template as a guide to the simple editor markdown and OLX markup to use for dropdown with hints and feedback problems. Edit this component to replace this template with your own assessment.
+Add the question text, or prompt, here. This text is required.
+You can add an optional tip or note related to the prompt like this.
+
+ an incorrect answer You can specify optional feedback like this, which appears after this answer is submitted.
+
+ the correct answer
+ an incorrect answer You can specify optional feedback for none, a subset, or all of the answers.
+
+
+
+
+ You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
+ If you add more than one hint, a different hint appears each time learners select the hint button.
+
+ `,
+ hints: [{
+ id: 0,
+ value: 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
',
+ },
+ {
+ id: 1,
+ value: 'If you add more than one hint, a different hint appears each time learners select the hint button.
',
+ },
+ ],
+ data: {
+ answers: [
+ {
+ id: 'A',
+ title: 'an incorrect answer',
+ correct: false,
+ selectedFeedback: 'You can specify optional feedback like this, which appears after this answer is submitted.
',
+ },
+ {
+ id: 'B',
+ title: 'the correct answer',
+ correct: true,
+ },
+ {
+ id: 'C',
+ title: 'an incorrect answer',
+ correct: false,
+ selectedFeedback: 'You can specify optional feedback for none, a subset, or all of the answers.
',
+ },
+ ],
+ },
+ question: 'You can use this template as a guide to the simple editor markdown and OLX markup to use for dropdown with hints and feedback problems. Edit this component to replace this template with your own assessment.
Add the question text, or prompt, here. This text is required. You can add an optional tip or note related to the prompt like this. ',
+ buildOLX: `
+
+ You can use this template as a guide to the simple editor markdown and OLX markup to use for dropdown with hints and feedback problems. Edit this component to replace this template with your own assessment.
+ Add the question text, or prompt, here. This text is required.
+ You can add an optional tip or note related to the prompt like this.
+
+
+an incorrect answer You can specify optional feedback like this, which appears after this answer is submitted.
+
+ the correct answer
+
+an incorrect answer You can specify optional feedback for none, a subset, or all of the answers.
+
+
+
+
+ You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
+ If you add more than one hint, a different hint appears each time learners select the hint button.
+
+
+`,
+};
+
+export const multipleChoiceWithFeedbackAndHintsOLX = {
+ rawOLX: `
+
+ You can use this template as a guide to the simple editor markdown and OLX markup to use for multiple choice with hints and feedback problems. Edit this component to replace this template with your own assessment.
+Add the question text, or prompt, here. This text is required.
+You can add an optional tip or note related to the prompt like this.
+Just a generic em tag
+
+ an incorrect answer
You can specify optional feedback like this, which appears after this answer is submitted.
+ the correct answer
+ an incorrect answer
You can specify optional feedback for none, a subset, or all of the answers.
+
+
+ You can add a solution
+
+
+
+ You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
+ If you add more than one hint, a different hint appears each time learners select the hint button.
+
+ `,
+ hints: [{
+ id: 0,
+ value: 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
',
+ },
+ {
+ id: 1,
+ value: 'If you add more than one hint, a different hint appears each time learners select the hint button.
',
+ },
+ ],
+ solutionExplanation: 'You can add a solution
',
+ data: {
+ answers: [
+ {
+ id: 'A',
+ title: 'an incorrect answer
',
+ correct: false,
+ selectedFeedback: 'You can specify optional feedback like this, which appears after this answer is submitted.
',
+ },
+ {
+ id: 'B',
+ title: 'the correct answer
',
+ correct: true,
+ },
+ {
+ id: 'C',
+ title: 'an incorrect answer
',
+ correct: false,
+ selectedFeedback: 'You can specify optional feedback for none, a subset, or all of the answers.
',
+ },
+ ],
+ },
+ question: 'You can use this template as a guide to the simple editor markdown and OLX markup to use for multiple choice with hints and feedback problems. Edit this component to replace this template with your own assessment.
Add the question text, or prompt, here. This text is required. You can add an optional tip or note related to the prompt like this. Just a generic em tag ',
+ buildOLX: `
+
+ You can use this template as a guide to the simple editor markdown and OLX markup to use for multiple choice with hints and feedback problems. Edit this component to replace this template with your own assessment.
+ Add the question text, or prompt, here. This text is required.
+ You can add an optional tip or note related to the prompt like this.
+ Just a generic em tag
+
+
+an incorrect answer
You can specify optional feedback like this, which appears after this answer is submitted.
+
+ the correct answer
+
+an incorrect answer
You can specify optional feedback for none, a subset, or all of the answers.
+
+
+
+
+
Explanation
+
You can add a solution
+
+
+
+
+ You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
+ If you add more than one hint, a different hint appears each time learners select the hint button.
+
+
+`,
+};
+
+export const numericInputWithFeedbackAndHintsOLX = {
+ rawOLX: `
+
+ You can use this template as a guide to the simple editor markdown and OLX markup to use for numerical input with hints and feedback problems. Edit this component to replace this template with your own assessment.
+Add the question text, or prompt, here. This text is required.
+You can add an optional tip or note related to the prompt like this.
+
+
+ You can specify optional feedback like this, which appears after this answer is submitted.
+ You can specify optional feedback like this, which appears after this answer is submitted.
+
+
+ You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
+ If you add more than one hint, a different hint appears each time learners select the hint button.
+
+ `,
+ hints: [{
+ id: 0,
+ value: 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
',
+ },
+ {
+ id: 1,
+ value: 'If you add more than one hint, a different hint appears each time learners select the hint button.
',
+ },
+ ],
+ data: {
+ answers: [
+ {
+ id: 'A',
+ title: '100',
+ correct: true,
+ selectedFeedback: 'You can specify optional feedback like this, which appears after this answer is submitted.
',
+ isAnswerRange: false,
+ tolerance: '5',
+ },
+ {
+ id: 'B',
+ title: '200',
+ correct: true,
+ isAnswerRange: false,
+ selectedFeedback: 'You can specify optional feedback like this, which appears after this answer is submitted.
',
+ },
+ ],
+ },
+ question: 'You can use this template as a guide to the simple editor markdown and OLX markup to use for numerical input with hints and feedback problems. Edit this component to replace this template with your own assessment.
Add the question text, or prompt, here. This text is required. You can add an optional tip or note related to the prompt like this. ',
+ buildOLX: `
+ You can use this template as a guide to the simple editor markdown and OLX markup to use for numerical input with hints and feedback problems. Edit this component to replace this template with your own assessment.
+ Add the question text, or prompt, here. This text is required.
+ You can add an optional tip or note related to the prompt like this.
+
+
+ You can specify optional feedback like this, which appears after this answer is submitted.
+
+ You can specify optional feedback like this, which appears after this answer is submitted.
+
+
+
+
+ You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
+ If you add more than one hint, a different hint appears each time learners select the hint button.
+
+
+`,
+};
+
+export const numericInputWithAnswerRangeOLX = {
+ rawOLX: `
+
+ You can use this template as a guide to the simple editor markdown and OLX markup to use for numerical input with hints and feedback problems. Edit this component to replace this template with your own assessment.
+Add the question text, or prompt, here. This text is required.
+You can add an optional tip or note related to the prompt like this.
+ \
+
+ `,
+ data: {
+ answers: [
+ {
+ id: 'A',
+ title: '[32,-1.3)',
+ correct: true,
+ selectedFeedback: 'You can specify optional feedback like this, which appears after this answer is submitted.
',
+ isAnswerRange: true,
+ },
+ ],
+ },
+ question: 'You can use this template as a guide to the simple editor markdown and OLX markup to use for numerical input with hints and feedback problems. Edit this component to replace this template with your own assessment.
Add the question text, or prompt, here. This text is required. You can add an optional tip or note related to the prompt like this. ',
+ buildOLX: `
+ You can use this template as a guide to the simple editor markdown and OLX markup to use for numerical input with hints and feedback problems. Edit this component to replace this template with your own assessment.
+ Add the question text, or prompt, here. This text is required.
+ You can add an optional tip or note related to the prompt like this.
+
+
+
+
+`,
+};
+
+export const textInputWithFeedbackAndHintsOLX = {
+ rawOLX: `
+
+ 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.
+Add the question text, or prompt, here. This text is required.
+You can add an optional tip or note related to the prompt like this.
+You can specify optional feedback like this, which appears after this answer is submitted.
+
+ You can specify optional feedback for none, a subset, or all of the answers.
+
+
+
+ You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
+ If you add more than one hint, a different hint appears each time learners select the hint button.
+
+ `,
+ hints: [{
+ id: 0,
+ value: 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
',
+ },
+ {
+ id: 1,
+ value: 'If you add more than one hint, a different hint appears each time learners select the hint button.
',
+ },
+ ],
+ data: {
+ answers: [
+ {
+ id: 'A',
+ title: 'the correct answer',
+ correct: true,
+ selectedFeedback: 'You can specify optional feedback like this, which appears after this answer is submitted.
',
+ },
+ {
+ id: 'B',
+ title: 'optional acceptable variant of the correct answer',
+ correct: true,
+ selectedFeedback: '',
+ },
+ {
+ id: 'C',
+ title: 'optional incorrect answer such as a frequent misconception',
+ correct: false,
+ selectedFeedback: 'You can specify optional feedback for none, a subset, or all of the answers.
',
+ },
+ ],
+ additionalStringAttributes: {
+ type: 'ci',
+ textline: {
+ size: '20',
+ },
+ },
+ },
+ question: `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.
+Add the question text, or prompt, here. This text is required.
+You can add an optional tip or note related to the prompt like this. `,
+ buildOLX: `
+
+ 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.
+ Add the question text, or prompt, here. This text is required.
+ You can add an optional tip or note related to the prompt like this.
+ You can specify optional feedback like this, which appears after this answer is submitted.
+
+ You can specify optional feedback for none, a subset, or all of the answers.
+
+
+
+ You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
+ If you add more than one hint, a different hint appears each time learners select the hint button.
+
+
+`,
+};
+
+export const textInputWithFeedbackAndHintsOLXWithMultipleAnswers = {
+ rawOLX: `
+
+ 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.
+Add the question text, or prompt, here. This text is required.
+You can add an optional tip or note related to the prompt like this.
+You can specify optional feedback like this, which appears after this answer is submitted.
+ You can specify optional feedback like this, which appears after this answer is submitted.
+ You can specify optional feedback like this, which appears after this answer is submitted.
+ You can specify optional feedback for none, a subset, or all of the answers.
+
+
+
+ You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
+ If you add more than one hint, a different hint appears each time learners select the hint button.
+
+ `,
+ hints: [{
+ id: 0,
+ value: 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
',
+ },
+ {
+ id: 1,
+ value: 'If you add more than one hint, a different hint appears each time learners select the hint button.
',
+ },
+ ],
+ data: {
+ answers: [
+ {
+ id: 'A',
+ title: 'the correct answer',
+ correct: true,
+ selectedFeedback: 'You can specify optional feedback like this, which appears after this answer is submitted.',
+ },
+ {
+ id: 'B',
+ title: '300',
+ correct: true,
+ selectedFeedback: 'You can specify optional feedback like this, which appears after this answer is submitted.',
+ },
+ {
+ correct: true,
+ selectedFeedback: 'You can specify optional feedback like this, which appears after this answer is submitted.',
+ id: 'C',
+ title: '400',
+ },
+ {
+ id: 'D',
+ title: 'optional incorrect answer such as a frequent misconception',
+ correct: false,
+ selectedFeedback: 'You can specify optional feedback for none, a subset, or all of the answers.',
+ },
+ ],
+ additionalStringAttributes: {
+ type: 'ci',
+ textline: {
+ size: '20',
+ },
+ },
+ },
+ question: '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.
Add the question text, or prompt, here. This text is required. You can add an optional tip or note related to the prompt like this. ',
+ buildOLX: `
+
+ 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.
+ Add the question text, or prompt, here. This text is required.
+ You can add an optional tip or note related to the prompt like this.
+ You can specify optional feedback like this, which appears after this answer is submitted.
+
+ You can specify optional feedback like this, which appears after this answer is submitted.
+
+
+ You can specify optional feedback like this, which appears after this answer is submitted.
+
+ You can specify optional feedback for none, a subset, or all of the answers.
+
+
+
+ You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
+ If you add more than one hint, a different hint appears each time learners select the hint button.
+
+
+`,
+};
+
+export const advancedProblemOlX = {
+ rawOLX: `
+
+ You can use this template as a guide to the OLX markup to use for math expression problems. Edit this component to replace the example with your own assessment.
+ Add the question text, or prompt, here. This text is required. Example: Write an expression for the product of R_1, R_2, and the inverse of R_3.
+ You can add an optional tip or note related to the prompt like this. Example: To test this example, the correct answer is R_1*R_2/R_3
+
+
+
+ `,
+};
+export const scriptProblemOlX = {
+ rawOLX: `
+
+
+ You can use this template as a guide to the simple editor markdown and OLX markup to use for numerical input with hints and feedback problems. Edit this component to replace this template with your own assessment.
+Add the question text, or prompt, here. This text is required.
+You can add an optional tip or note related to the prompt like this.
+
+
+ You can specify optional feedback like this, which appears after this answer is submitted.
+ You can specify optional feedback like this, which appears after this answer is submitted.
+
+ `,
+};
+export const multipleTextInputProblemOlX = {
+ rawOLX: `
+
+
+
+
+
+
+ `,
+};
+export const multipleNumericProblemOlX = {
+ rawOLX: `
+
+
+
+
+
+
+ `,
+};
+export const NumericAndTextInputProblemOlX = {
+ rawOLX: `
+
+
+
+
+
+
+ `,
+};
+export const blankProblemOLX = {
+ rawOLX: ' ',
+};
+export const blankQuestionOLX = {
+ rawOLX: `
+
+
+
+
+ `,
+ question: '',
+};
+export const styledQuestionOLX = {
+ rawOLX: `
+
+
+
+ test
+
+
+
+
+
+
+
+ `,
+ question: `
+
+
+ test
+
+
+
`,
+};
+
+export const shuffleProblemOLX = {
+ rawOLX: `
+
+ What Apple device competed with the portable CD player?
+
+ The iPad
+ Napster
+ The iPod
+ The vegetable peeler
+
+
+ `,
+};
+
+export const labelDescriptionQuestionOLX = {
+ rawOLX:
+`
+
+
+ Taking the system as just the water , as indicated by the red dashed line, what would be the correct expression for the first law of thermodynamics applied to this system?
+ Watch out, boiling water is hot
+
+ ( Delta E_text{water} = Q )
+ ( Delta E_text{water} = - W )
+ ( Delta E_text{water} = 0 )
+
+
+
+
+
Explanation
+
+
+ `,
+
+ question: `
+
+ Taking the system as just the water , as indicated by the red dashed line, what would be the correct expression for the first law of thermodynamics applied to this system?
+ Watch out, boiling water is hot `,
+};
+
+export const htmlEntityTestOLX = {
+ rawOLX:
+ `
+
+ What is the content of the register x2 after executing the following three lines of instructions?
+ Address assembly instructions 0x0 addi x1, x0, 1 0x4 slli x2, x1, 4 0x8 sub x1, x2, x1
+
+ answerA
+ answerB
+
+
+
+
Explanation
+
Address assembly instructions comment 0x0 addi x1, x0, 1 x1 = 0x1 0x4 slli x2, x1, 4 x2 = x1 << 4 = 0x10 0x8 sub x1, x2, x1 x1 = x2 - x1 = 0x10 - 0x01 = 0xf
+
+
+
+ `,
+ data: {
+ answers: [
+ {
+ id: 'A',
+ title: 'answerA',
+ correct: false,
+ },
+ {
+ id: 'B',
+ title: 'answerB',
+ correct: true,
+ },
+ ],
+ },
+ question: `What is the content of the register x2 after executing the following three lines of instructions?
+ Address assembly instructions 0x0 addi x1, x0, 1 0x4 slli x2, x1, 4 0x8 sub x1, x2, x1
`,
+ solutionExplanation: `Address assembly instructions comment 0x0 addi x1, x0, 1 x1 = 0x1 0x4 slli x2, x1, 4 x2 = x1 << 4 = 0x10 0x8 sub x1, x2, x1 x1 = x2 - x1 = 0x10 - 0x01 = 0xf
`,
+};
+
+export const numberParseTestOLX = {
+ rawOLX: `
+
+ What is the content of the register x2 after executing the following three lines of instructions?
+
+ 0x10
+ 0x0f
+ 0x07
+ 0009
+
+
+ `,
+ data: {
+ answers: [
+ {
+ id: 'A',
+ title: `0x10 `,
+ correct: false,
+ },
+ {
+ id: 'B',
+ title: `0x0f `,
+ correct: true,
+ },
+ {
+ id: 'C',
+ title: `0x07 `,
+ correct: false,
+ },
+ {
+ id: 'D',
+ title: `0009 `,
+ correct: false,
+ },
+ ],
+ },
+ question: 'What is the content of the register x2 after executing the following three lines of instructions?
',
+ buildOLX: `
+
+ What is the content of the register x2 after executing the following three lines of instructions?
+
+ 0x10
+ 0x0f
+ 0x07
+ 0009
+
+
+ `,
+};
+
+export const solutionExplanationTest = {
+ rawOLX: `
+ How 99
long is the array q
after the following loop runs?
+ for i = 1:99
+ q(2*i - 1) = i;
+ end
+
+
+ Enter your answer below. Type "e" if this code would produce an error
+
+
+
+
+
Explanation
+ This loop will iterate
99
times, but the length of
q
will not be
99
due to indexing with the value
2*i -1
. On the last iteration,
i = 99
, so
2*i - 1 = 2*78 - 1 = 197
. This will be the last position filled in
q
, so the answer is
197
.
+
+
+
+ `,
+ solutionExplanation: `\n
+ This loop will iterate 99
times, but the length of q
will not be 99
due to indexing with the value 2*i -1
. On the last iteration, i = 99
, so 2*i - 1 = 2*78 - 1 = 197
. This will be the last position filled in q
, so the answer is 197
.\n `,
+};
+
+export const solutionExplanationWithoutDivTest = {
+ rawOLX: `
+
+ Considering a list z=[8,12,2,9,7] and the following for loop:
+
+ for i in z:
+ y=i+1
+ print(y)
+
+ What would be the result of running this code?
+ Select the correct answer
+
+ 8
+ [9,13,3,10,8]
+ 9
+ 13
+ 3
+ 10
+ 8
+
+
+
+
+ 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.
+
+
+ `,
+ solutionExplanation: `
+
+
+ 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.
+ `,
+};
+
+export const tablesInRichTextTest = {
+ rawOLX: `
+
+
+ The table shows the number of protein-coding genes, chromosomes, and bases in a range of eukaryotic species.
+
+
+ Eukaryotic Genomes Comparison
+
+
+
+ Species
+
+
+ Protein-coding genes
+
+
+ Chromosomes
+
+
+ Bases
+
+
+
+ Yeast (S. cerevisiae )
+ ~5,800
+ 16
+ ~12 Mb
+
+
+ Arabidopsis (A. thaliana )
+ ~27,000
+ 5
+ ~115 Mb
+
+
+ Rice (O. sativa )
+ ~41,000
+ 12
+ ~390 Mb
+
+
+ Worm (C. elegans )
+ ~19,000
+ 6
+ ~100 Mb
+
+
+ Fly (D. melanogaster )
+ ~14,000
+ 4
+ ~165 Mb
+
+
+ Mouse (M. musculus )
+ ~23,000
+ 20
+ ~3 Gb
+
+
+ Human (H. sapiens )
+ ~21,000
+ 23
+ ~3 Gb
+
+
+
+
+ In which of the following observations does the C-value paradox apply? Select all that apply.
+
+
+
+
+ Rice has a larger genome and more genes than Arabidopsis .
+
+
+
+
+ Humans have a larger genome but fewer genes than Arabidopsis .
+
+
+
+
+ Worms have a smaller genome but more genes than flies.
+
+
+
+
+
+
+ Explanation
+
+
+ Explanation
+
+
+ The C-value paradox states that there is no relation between size of genome and number of genes. In the comparison between rice and Arabidopsis , the size of the rice genome is both larger and contains a greater number of genes than Arabidopsis , so the C-value paradox does not apply here. In the remaining options, the larger genomes have fewer genes.
+
+
+
+
+ `,
+ question: `
+ The table shows the number of protein-coding genes, chromosomes, and bases in a range of eukaryotic species.
+
+
+ Eukaryotic Genomes Comparison
+
+
+
+ Species
+
+
+ Protein-coding genes
+
+
+ Chromosomes
+
+
+ Bases
+
+
+
+ Yeast (S. cerevisiae )
+ ~5,800
+ 16
+ ~12 Mb
+
+
+ Arabidopsis (A. thaliana )
+ ~27,000
+ 5
+ ~115 Mb
+
+
+ Rice (O. sativa )
+ ~41,000
+ 12
+ ~390 Mb
+
+
+ Worm (C. elegans )
+ ~19,000
+ 6
+ ~100 Mb
+
+
+ Fly (D. melanogaster )
+ ~14,000
+ 4
+ ~165 Mb
+
+
+ Mouse (M. musculus )
+ ~23,000
+ 20
+ ~3 Gb
+
+
+ Human (H. sapiens )
+ ~21,000
+ 23
+ ~3 Gb
+
+
+
+
+ In which of the following observations does the C-value paradox apply? Select all that apply.
+
`,
+};
+
+export const parseOutExplanationTests = {
+ rawOLX: `
+
+
+
+
+ Explanation
+
+ Explanation
+
+ solution meat
+
+
+
+ `,
+ solutionExplanation: `
+
+
+ solution meat
+ `
+};
+
+export const multiSelectPartialCredit = {
+ rawOLX: `
+
+ Which of the following is a fruit?
+ Select all that apply.
+
+ apple
+ pumpkin
+ potato
+ tomato
+
+
+ `
+}
+
+export const singleSelectPartialCredit = {
+ rawOLX: `
+
+ What Apple device competed with the portable CD player?
+
+ The iPad
+ Napster
+ The iPod
+ The vegetable peeler
+
+
+ `
+}
+
+export const numericalProblemPartialCredit = {
+ rawOLX: `
+
+ How many miles away from Earth is the sun?
+ Use scientific notation to answer.
+
+
+
+ `
+}
+
+export const unexpectOlxAfterProblemTypeTags = {
+ rawOLX: `
+
+ What Apple device competed with the portable CD player?
+
+ The iPad
+ Napster
+ The iPod
+
+
+ Check out Apple's history for more information.
+
+ You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.
+ If you add more than one hint, a different hint appears each time learners select the hint button.
+
+ `
+}
diff --git a/src/editors/containers/ProblemEditor/data/mockData/problemTestData.js b/src/editors/containers/ProblemEditor/data/mockData/problemTestData.js
new file mode 100644
index 0000000000..65e6d3a896
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/data/mockData/problemTestData.js
@@ -0,0 +1,417 @@
+export const checklistWithFeebackHints = {
+ state: {
+ rawOLX: '\n \n You can use this template as a guide to the simple editor markdown and OLX markup to use for checkboxes with hints and feedback problems. Edit this component to replace this template with your own assessment.
\n Add the question text, or prompt, here. This text is required. \n You can add an optional tip or note related to the prompt like this. \n \n a correct answer\n You can specify optional feedback that appears after the learner selects and submits this answer. \n You can specify optional feedback that appears after the learner clears and submits this answer. \n \n an incorrect answer\n \n an incorrect answer\n You can specify optional feedback for none, all, or a subset of the answers. \n You can specify optional feedback for selected answers, cleared answers, or both. \n \n a correct answer\n \n You can specify optional feedback for a combination of answers which appears after the specified set of answers is submitted. \n You can specify optional feedback for one, several, or all answer combinations. \n \n \n\n \n You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button. \n If you add more than one hint, a different hint appears each time learners select the hint button. \n \n \n',
+ problemType: 'MULTISELECT',
+ question: 'You can use this template as a guide to the simple editor markdown and OLX markup to use for checkboxes with hints and feedback problems. Edit this component to replace this template with your own assessment.\n\nAdd the question text, or prompt, here. This text is required.||You can add an optional tip or note related to the prompt like this.
\n\n',
+ answers: [
+ {
+ id: 'A',
+ title: 'a correct answer',
+ correct: true,
+ selectedFeedback: ' You can specify optional feedback that appears after the learner selects and submits this answer.',
+ unselectedFeedback: 'You can specify optional feedback that appears after the learner clears and submits this answer.',
+ },
+ {
+ id: 'B',
+ title: 'an incorrect answer',
+ correct: false,
+ selectedFeedback: '',
+ unselectedFeedback: '',
+ },
+ {
+ id: 'C',
+ title: 'an incorrect answer',
+ correct: false,
+ selectedFeedback: ' You can specify optional feedback for none, all, or a subset of the answers.',
+ unselectedFeedback: 'You can specify optional feedback for selected answers, cleared answers, or both.',
+ },
+ {
+ id: 'D',
+ title: 'a correct answer',
+ correct: true,
+ selectedFeedback: '',
+ unselectedFeedback: '',
+ },
+ ],
+ groupFeedbackList: [
+ {
+ id: 3,
+ answers: [
+ 'A',
+ 'B',
+ 'D',
+ ],
+ selectedFeedback: 'You can specify optional feedback for a combination of answers which appears after the specified set of answers is submitted.',
+ },
+ {
+ id: 4,
+ answers: [
+ 'A',
+ 'B',
+ 'C',
+ 'D',
+ ],
+ selectedFeedback: 'You can specify optional feedback for one, several, or all answer combinations.',
+ },
+ ],
+ settings: {
+ hints: [
+ {
+ id: 14,
+ value: 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.',
+ },
+ {
+ id: 15,
+ value: 'If you add more than one hint, a different hint appears each time learners select the hint button.',
+ },
+ ],
+ scoring: {
+ weight: 2.5,
+ attempts: {
+ unlimited: false,
+ number: 5,
+ },
+ },
+ timeBetween: 3,
+ showAnswer: {
+ on: 'after_attempts',
+ afterAttempts: 2,
+ },
+ showResetButton: true,
+ },
+ },
+ metadata: {
+ markdown: `You can use this template as a guide to the simple editor markdown and OLX markup to use for checkboxes with hints and feedback problems. Edit this component to replace this template with your own assessment.
+
+>>Add the question text, or prompt, here. This text is required.||You can add an optional tip or note related to the prompt like this.<<
+[x] a correct answer{{selected: You can specify optional feedback that appears after the learner selects and submits this answer.},{unselected: You can specify optional feedback that appears after the learner clears and submits this answer.}}
+[ ] an incorrect answer
+[ ] an incorrect answer{{selected: You can specify optional feedback for none, all, or a subset of the answers.},{unselected: You can specify optional feedback for selected answers, cleared answers, or both.}}
+[x] a correct answer
+||You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.||
+||If you add more than one hint, a different hint appears each time learners select the hint button.||
+{{ (( A B D )) You can specify optional feedback for a combination of answers which appears after the specified set of answers is submitted. }}
+{{ (( A B C D )) You can specify optional feedback for one, several, or all answer combinations. }}
+`,
+ max_attempts: 5,
+ show_reset_button: true,
+ showanswer: 'after_attempts',
+ attempts_before_showanswer_button: 2,
+ submission_wait_seconds: 3,
+ weight: 2.5,
+ },
+};
+
+export const dropdownWithFeedbackHints = {
+ state: {
+ rawOLX: '\n \n You can use this template as a guide to the simple editor markdown and OLX markup to use for dropdown with hints and feedback problems. Edit this component to replace this template with your own assessment.
\n Add the question text, or prompt, here. This text is required. \n You can add an optional tip or note related to the prompt like this. \n \n an incorrect answer You can specify optional feedback like this, which appears after this answer is submitted. \n the correct answer \n an incorrect answer You can specify optional feedback for none, a subset, or all of the answers. \n \n \n \n You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button. \n If you add more than one hint, a different hint appears each time learners select the hint button. \n \n \n',
+ problemType: 'DROPDOWN',
+ question: 'You can use this template as a guide to the simple editor markdown and OLX markup to use for dropdown with hints and feedback problems. Edit this component to replace this template with your own assessment.\nAdd the question text, or prompt, here. This text is required.||You can add an optional tip or note related to the prompt like this.
\n',
+ answers: [
+ {
+ id: 'A',
+ title: 'an incorrect answer',
+ correct: false,
+ selectedFeedback: 'You can specify optional feedback like this, which appears after this answer is submitted.',
+ },
+ {
+ id: 'B',
+ title: 'the correct answer',
+ correct: true,
+ selectedFeedback: '',
+ },
+ {
+ id: 'C',
+ title: 'an incorrect answer',
+ correct: false,
+ selectedFeedback: 'You can specify optional feedback for none, a subset, or all of the answers.',
+ },
+ ],
+ groupFeedbackList: [],
+ settings: {
+ hints: [
+ {
+ id: 8,
+ value: 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.',
+ },
+ {
+ id: 9,
+ value: 'If you add more than one hint, a different hint appears each time learners select the hint button.',
+ },
+ ],
+ scoring: {
+ weight: 2.5,
+ attempts: {
+ unlimited: false,
+ number: 5,
+ },
+ },
+ timeBetween: 3,
+ showAnswer: {
+ on: 'after_attempts',
+ afterAttempts: 2,
+ },
+ showResetButton: true,
+ },
+ },
+ metadata: {
+ markdown: `You can use this template as a guide to the simple editor markdown and OLX markup to use for dropdown with hints and feedback problems. Edit this component to replace this template with your own assessment.
+>>Add the question text, or prompt, here. This text is required.||You can add an optional tip or note related to the prompt like this. <<
+[[
+ an incorrect answer {{You can specify optional feedback like this, which appears after this answer is submitted.}}
+ (the correct answer)
+ an incorrect answer {{You can specify optional feedback for none, a subset, or all of the answers.}}
+]]
+||You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.||
+||If you add more than one hint, a different hint appears each time learners select the hint button.||
+`,
+ max_attempts: 5,
+ show_reset_button: true,
+ showanswer: 'after_attempts',
+ attempts_before_showanswer_button: 2,
+ submission_wait_seconds: 3,
+ weight: 2.5,
+ },
+};
+
+export const numericWithHints = {
+ state: {
+ rawOLX: '\n \n You can use this template as a guide to the simple editor markdown and OLX markup to use for numerical input with hints and feedback problems. Edit this component to replace this template with your own assessment.
\n Add the question text, or prompt, here. This text is required. \n You can add an optional tip or note related to the prompt like this. \n \n \n You can specify optional feedback like this, which appears after this answer is submitted. \n \n \n You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button. \n If you add more than one hint, a different hint appears each time learners select the hint button. \n \n \n',
+ problemType: 'TEXTINPUT',
+ question: 'You can use this template as a guide to the simple editor markdown and OLX markup to use for numerical input with hints and feedback problems. Edit this component to replace this template with your own assessment.\n\nAdd the question text, or prompt, here. This text is required.||You can add an optional tip or note related to the prompt like this.
\n\n',
+ answers: [
+ {
+ id: 'A',
+ title: '100 +-5',
+ selectedFeedback: 'You can specify optional feedback like this, which appears after this answer is submitted.',
+ correct: true,
+ },
+ {
+ id: 'B',
+ title: '90 +-5',
+ selectedFeedback: 'You can specify optional feedback like this, which appears after this answer is submitted.',
+ correct: true,
+ },
+ {
+ id: 'C',
+ title: '60 +-5',
+ selectedFeedback: 'You can specify optional feedback like this, which appears after this answer is submitted.',
+ correct: false,
+ },
+ ],
+ groupFeedbackList: [],
+ settings: {
+ hints: [
+ {
+ id: 6,
+ value: 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.',
+ },
+ {
+ id: 7,
+ value: 'If you add more than one hint, a different hint appears each time learners select the hint button.',
+ },
+ ],
+ scoring: {
+ weight: 2.5,
+ attempts: {
+ unlimited: false,
+ number: 0,
+ },
+ },
+ timeBetween: 0,
+ showAnswer: {
+ on: 'after_attempts',
+ afterAttempts: 1,
+ },
+ showResetButton: false,
+ },
+ },
+ metadata: {
+ markdown: `You can use this template as a guide to the simple editor markdown and OLX markup to use for numerical input with hints and feedback problems. Edit this component to replace this template with your own assessment.
+
+>>Add the question text, or prompt, here. This text is required.||You can add an optional tip or note related to the prompt like this. <<
+=100 +-5 {{You can specify optional feedback like this, which appears after this answer is submitted.}}
+or=90 +-5 {{You can specify optional feedback like this, which appears after this answer is submitted.}}
+not=60 +-5 {{You can specify optional feedback like this, which appears after this answer is submitted.}}
+||You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.||
+||If you add more than one hint, a different hint appears each time learners select the hint button.||
+`,
+ weight: 2.5,
+ max_attempts: 0,
+ rerandomize: 'invalid_input',
+ showanswer: 'invalid_input',
+ attempts_before_showanswer_button: 2,
+ },
+};
+
+export const textInputWithHints = {
+ state: {
+ rawOLX: '\n \n 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.
\n Add the question text, or prompt, here. This text is required. \n You can add an optional tip or note related to the prompt like this. \n You can specify optional feedback like this, which appears after this answer is submitted. \n \n You can specify optional feedback for none, a subset, or all of the answers. \n \n \n \n You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button. \n If you add more than one hint, a different hint appears each time learners select the hint button. \n \n \n',
+ problemType: 'TEXTINPUT',
+ question: '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.\n\nAdd the question text, or prompt, here. This text is required.||You can add an optional tip or note related to the prompt like this.
\n\n',
+ answers: [
+ {
+ id: 'A',
+ title: 'the correct answer',
+ selectedFeedback: 'You can specify optional feedback like this, which appears after this answer is submitted.',
+ correct: true,
+ },
+ {
+ id: 'B',
+ title: 'optional acceptable variant of the correct answer',
+ selectedFeedback: '',
+ correct: true,
+ },
+ {
+ id: 'C',
+ title: 'optional incorrect answer such as a frequent misconception',
+ selectedFeedback: 'You can specify optional feedback for none, a subset, or all of the answers.',
+ correct: false,
+ },
+ ],
+ groupFeedbackList: [],
+ settings: {
+ hints: [
+ {
+ id: 9,
+ value: 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.',
+ },
+ {
+ id: 10,
+ value: 'If you add more than one hint, a different hint appears each time learners select the hint button.',
+ },
+ ],
+ scoring: {
+ weight: 2.5,
+ attempts: {
+ unlimited: false,
+ number: 0,
+ },
+ },
+ timeBetween: 0,
+ showAnswer: {
+ on: '',
+ afterAttempts: 1,
+ },
+ showResetButton: false,
+ },
+ },
+ metadata: {
+ markdown: `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.
+
+>>Add the question text, or prompt, here. This text is required.||You can add an optional tip or note related to the prompt like this. <<
+=the correct answer {{You can specify optional feedback like this, which appears after this answer is submitted.}}
+or=optional acceptable variant of the correct answer
+not=optional incorrect answer such as a frequent misconception {{You can specify optional feedback for none, a subset, or all of the answers.}}
+||You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.||
+||If you add more than one hint, a different hint appears each time learners select the hint button.||
+`,
+ weight: 2.5,
+ },
+};
+
+export const singleSelectWithHints = {
+ state: {
+ rawOLX: '\nYou can use this template as a guide to the simple editor markdown and OLX markup to use for checkboxes with hints and feedback problems. Edit this component to replace this template with your own assessment.
\n\nAdd the question text, or prompt, here. This text is required. \nYou can add an optional tip or note related to the prompt like this. \n\n \n a correct answer selected: You can specify optional feedback that appears after the learner selects and submits this answer. }, { unselected: You can specify optional feedback that appears after the learner clears and submits this answer. \n an incorrect answer \n an incorrect answer selected: You can specify optional feedback for none, all, or a subset of the answers. }, { unselected: You can specify optional feedback for selected answers, cleared answers, or both. \n an incorrect answer again \n \n \n\n \n You can specify optional feedback for a combination of answers which appears after the specified set of answers is submitted. \n You can specify optional feedback for one, several, or all answer combinations. \n \n \n\n\n\n You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button. \n If you add more than one hint, a different hint appears each time learners select the hint button. \n \n ',
+ problemType: 'SINGLESELECT',
+ question: 'You can use this template as a guide to the simple editor markdown and OLX markup to use for checkboxes with hints and feedback problems. Edit this component to replace this template with your own assessment.\n\nAdd the question text, or prompt, here. This text is required.||You can add an optional tip or note related to the prompt like this.
\n\n',
+ answers: [
+ {
+ id: 'A',
+ title: 'a correct answer',
+ correct: true,
+ selectedFeedback: 'Some new feedback',
+ },
+ {
+ id: 'B',
+ title: 'an incorrect answer',
+ correct: false,
+ selectedFeedback: '',
+ },
+ {
+ id: 'C',
+ title: 'an incorrect answer',
+ correct: false,
+ selectedFeedback: 'Wrong feedback',
+ },
+ {
+ id: 'D',
+ title: 'an incorrect answer again',
+ correct: false,
+ selectedFeedback: '',
+ },
+ ],
+ groupFeedbackList: [],
+ settings: {
+ hints: [
+ {
+ id: 13,
+ value: 'You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.',
+ },
+ {
+ id: 14,
+ value: 'If you add more than one hint, a different hint appears each time learners select the hint button.',
+ },
+ ],
+ scoring: {
+ attempts: {
+ unlimited: true,
+ number: null,
+ },
+ },
+ timeBetween: 0,
+ showAnswer: {
+ on: '',
+ afterAttempts: 1,
+ },
+ showResetButton: false,
+ },
+ },
+ metadata: {
+ markdown: `You can use this template as a guide to the simple editor markdown and OLX markup to use for checkboxes with hints and feedback problems. Edit this component to replace this template with your own assessment.
+
+>>Add the question text, or prompt, here. This text is required.||You can add an optional tip or note related to the prompt like this.<<
+(x) a correct answer {{Some new feedback}}
+( ) an incorrect answer
+( ) an incorrect answer {{Wrong feedback}}
+( ) an incorrect answer again
+||You can add an optional hint like this. Problems that have a hint include a hint button, and this text appears the first time learners select the button.||
+||If you add more than one hint, a different hint appears each time learners select the hint button.||
+`,
+ },
+};
+
+export const negativeAttempts = {
+ state: {
+ rawOLX: '\n \n \n \n \n \n',
+ problemType: 'TEXTINPUT',
+ question: '',
+ answers: [
+ {
+ id: 'A',
+ title: '100 +-5',
+ correct: true,
+ },
+ ],
+ groupFeedbackList: [],
+ settings: {
+ scoring: {
+ weight: 2.5,
+ attempts: {
+ unlimited: false,
+ number: 0,
+ },
+ },
+ },
+ },
+ metadata: {
+ markdown: `
+=100 +-5 {{You can specify optional feedback like this, which appears after this answer is submitted.}}
+`,
+ weight: 2.5,
+ max_attempts: -1,
+ rerandomize: 'invalid_input',
+ showanswer: 'invalid_input',
+ attempts_before_showanswer_button: 2,
+ },
+};
diff --git a/src/editors/containers/ProblemEditor/data/reactStateOLXHelpers.js b/src/editors/containers/ProblemEditor/data/reactStateOLXHelpers.js
new file mode 100644
index 0000000000..2815228969
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/data/reactStateOLXHelpers.js
@@ -0,0 +1,116 @@
+/* eslint-disable import/prefer-default-export */
+
+import { flatten } from 'lodash';
+
+/**
+ * flattenSubNodes - appends a nodes children to a given array 'subNodes' in a flattened format.
+ * Only appends direct children unless `recursive` is set to true.
+ * @param {object} node
+ * @param {object[]} subNodes
+ * @param {object} options - { recursive: false } Whether to search recursively for child tags.
+ * @returns {void}
+ */
+function flattenSubNodes(node, subNodes, options = { recursive: false }) {
+ const values = Array.isArray(node) ? (
+ flatten(node.map(n => Object.values(n)))
+ ) : Object.values(node);
+
+ values.forEach(value => {
+ if (Array.isArray(value)) {
+ subNodes.push(...value);
+
+ if (options.recursive) {
+ value.forEach(nestedNode => {
+ flattenSubNodes(nestedNode, subNodes, options);
+ });
+ }
+ }
+ });
+}
+
+/**
+ * function nodeContainsChildTags - checks whether a node contains any subnodes with the specified tag names.
+ *
+ * @param {object} node - Example for node:
+ * {"div":
+ * [
+ * {"label":
+ * [
+ * {"#text":"def"}
+ * ]
+ * },
+ * {"div":
+ * [{
+ * label:
+ * [
+ * { '#text': 'def' },
+ * ],
+ * }],
+ * },
+ * {"#text":" "},
+ * {"em":
+ * [
+ * {"#text":"ghi"}
+ * ],
+ * ":@": {"@_class":"olx_description"}
+ * },
+ * {"em":
+ * [
+ * {"#text":"jkl"}
+ * ]
+ * }
+ * ]
+ * }
+ * @param {string[]} tagNames
+ * @param {boolean} [recursive=false] - Whether to search recursively for child tags.
+ *
+ * @returns {boolean} whether node contains the specified child tags
+ */
+export function nodeContainsChildTags(node, tagNames, options = { recursive: false }) {
+ const subNodes = [];
+ flattenSubNodes(node, subNodes, options);
+ const res = subNodes.some(subNode => tagNames.includes(Object.keys(subNode)[0]));
+ return res;
+}
+
+/**
+ * @param {object} node
+ * @returns {string} the tag name of the node
+ */
+export function tagName(node) {
+ if (Array.isArray(node)) {
+ throw new TypeError('function tagName does not accept arrays as input');
+ }
+ return Object.keys(node).find(key => key.match(/^[a-zA-Z].*/));
+}
+
+/**
+ * findNodesAndRemoveTheirParentNodes - given 'arrayOfNodes', an array that results from the
+ * XMLParser, this looks for 'nodesToFind': specific child tags (e.g. 'label'), and removes
+ * their parent tags (e.g. 'div') but keeps all their content one level higher.
+ * The 'parentsToRemove' array should contain a list of parent tags that will be removed.
+ *
+ * For example, if you have a div containing a p that contains a label and an em, and you specify
+ * 'label' as the nodeToFind and 'p' as the parentToRemove, then you will get a div containing a label and an em.
+ * @param {object} { arrayOfNodes, nodesToFind, parentsToRemove }
+ *
+ * @returns {object} - an array of nodes
+ */
+export function findNodesAndRemoveTheirParentNodes({
+ arrayOfNodes,
+ nodesToFind,
+ parentsToRemove,
+}) {
+ const result = [];
+ arrayOfNodes.forEach((node) => {
+ const containsRelevantSubnodes = nodeContainsChildTags(node, nodesToFind);
+
+ if (!containsRelevantSubnodes || !parentsToRemove.includes(tagName(node))) {
+ result.push(node);
+ } else {
+ const subNodes = Object.values(node)[0];
+ result.push(...subNodes);
+ }
+ });
+ return result;
+}
diff --git a/src/editors/containers/ProblemEditor/data/reactStateOLXHelpers.test.js b/src/editors/containers/ProblemEditor/data/reactStateOLXHelpers.test.js
new file mode 100644
index 0000000000..ab5b31c1bf
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/data/reactStateOLXHelpers.test.js
@@ -0,0 +1,182 @@
+import { nodeContainsChildTags, findNodesAndRemoveTheirParentNodes, tagName } from './reactStateOLXHelpers';
+
+describe('reactStateOLXHelpers', () => {
+ describe('findNodesWithChildTags', () => {
+ const node = {
+ div:
+ [
+ {
+ label:
+ [
+ { '#text': 'def' },
+ ],
+ },
+ { '#text': ' ' },
+ {
+ em:
+ [
+ { '#text': 'ghi' },
+ ],
+ ':@': { '@_class': 'olx_description' },
+ },
+ {
+ em:
+ [
+ { '#text': 'jkl' },
+ ],
+ },
+ ],
+ };
+
+ const nodeWithNestedLabel = {
+ div:
+ [
+ {
+ div:
+ [{
+ label:
+ [
+ { '#text': 'def' },
+ ],
+ }],
+ },
+ { '#text': ' ' },
+ {
+ em:
+ [
+ { '#text': 'ghi' },
+ ],
+ ':@': { '@_class': 'olx_description' },
+ },
+ {
+ em:
+ [
+ { '#text': 'jkl' },
+ ],
+ },
+ ],
+ };
+
+ it('should return true if node contains specified child tags', () => {
+ const received = nodeContainsChildTags(node, ['p', 'label']);
+ expect(received).toEqual(true);
+ });
+
+ it('should return false if node does not contain child tags', () => {
+ const received = nodeContainsChildTags(node, ['p', 'span']);
+ expect(received).toEqual(false);
+ });
+
+ it('should return true if node contains specified nested child tags and recursive is true', () => {
+ const received = nodeContainsChildTags(nodeWithNestedLabel, ['p', 'label'], { recursive: true });
+ expect(received).toEqual(true);
+ });
+
+ it('should return false if node contains specified nested child tags and recursive is not set', () => {
+ const received = nodeContainsChildTags(nodeWithNestedLabel, ['p', 'label']);
+ expect(received).toEqual(false);
+ });
+
+ it('should handle arrays somehow, in case there is an edge case where it is passed', () => {
+ const received = nodeContainsChildTags([nodeWithNestedLabel, node], ['p', 'label'], { recursive: true });
+ expect(received).toEqual(true);
+ });
+ });
+
+ describe('findNodesAndRemoveTheirParentNodes', () => {
+ const exampleQuestionObject = [
+ {
+ p: [{
+ '#text': 'abc',
+ }],
+ },
+ {
+ div:
+ [
+ {
+ label:
+ [{ '#text': 'def' }],
+ },
+ { '#text': ' ' },
+ {
+ em:
+ [{
+ '#text': 'ghi',
+ }],
+ ':@': { '@_class': 'olx_description' },
+ },
+ { em: [{ '#text': 'Just a generic em tag' }] },
+ ],
+ },
+ ];
+
+ const questionObjectWithoutDiv = [
+ {
+ p: [{
+ '#text': 'abc',
+ }],
+ },
+ {
+ label: [{ '#text': 'def' }],
+ },
+ { '#text': ' ' },
+ {
+ em:
+ [{
+ '#text': 'ghi',
+ }],
+ ':@': { '@_class': 'olx_description' },
+ },
+ { em: [{ '#text': 'Just a generic em tag' }] },
+ ];
+
+ it('should remove parent nodes of specified child tags', () => {
+ expect(findNodesAndRemoveTheirParentNodes({
+ arrayOfNodes: exampleQuestionObject,
+ nodesToFind: ['label'],
+ parentsToRemove: ['div'],
+ })).toEqual(questionObjectWithoutDiv);
+ });
+
+ it('should not remove anything unless specified in "parentsToRemove"', () => {
+ expect(findNodesAndRemoveTheirParentNodes({
+ arrayOfNodes: exampleQuestionObject,
+ nodesToFind: ['label'],
+ parentsToRemove: ['span'],
+ })).toEqual(exampleQuestionObject);
+ });
+ });
+
+ describe('tagName', () => {
+ it('should return the tag name of the node', () => {
+ expect(tagName({
+ p: [{
+ '#text': 'abc',
+ }],
+ })).toEqual('p');
+ });
+
+ it('should throw an error if the node is an array', () => {
+ expect(() => tagName([])).toThrow(TypeError);
+ });
+
+ it('should return undefined if the node is text-only', () => {
+ expect(tagName({ '#text': 'abc' })).toEqual(undefined);
+ });
+
+ it('should return undefined if the node is an empty object', () => {
+ expect(tagName({})).toEqual(undefined);
+ });
+
+ it('should return correct tagName if the node has text and class properties as well', () => {
+ expect(tagName({
+ ':@': { '@_class': 'olx_description' },
+ '#text': 'abc',
+ em:
+ [{
+ '#text': 'ghi',
+ }],
+ })).toEqual('em');
+ });
+ });
+});
diff --git a/src/editors/containers/ProblemEditor/index.jsx b/src/editors/containers/ProblemEditor/index.jsx
new file mode 100644
index 0000000000..5f342ad1e4
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/index.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { Spinner } from '@openedx/paragon';
+import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
+import SelectTypeModal from './components/SelectTypeModal';
+import EditProblemView from './components/EditProblemView';
+import { selectors, thunkActions } from '../../data/redux';
+import { RequestKeys } from '../../data/constants/requests';
+import messages from './messages';
+
+const ProblemEditor = ({
+ onClose,
+ returnFunction,
+ // Redux
+ problemType,
+ blockFinished,
+ blockFailed,
+ blockValue,
+ initializeProblemEditor,
+ advancedSettingsFinished,
+}) => {
+ React.useEffect(() => {
+ if (blockFinished && !blockFailed) {
+ initializeProblemEditor(blockValue);
+ }
+ }, [blockFinished, blockFailed]);
+
+ if (!blockFinished || !advancedSettingsFinished) {
+ return (
+
+
+
+ );
+ }
+
+ if (blockFailed) {
+ return (
+
+
+
+ );
+ }
+
+ if (problemType === null) {
+ return ( );
+ }
+ return ( );
+};
+
+ProblemEditor.defaultProps = {
+ returnFunction: null,
+};
+ProblemEditor.propTypes = {
+ onClose: PropTypes.func.isRequired,
+ returnFunction: PropTypes.func,
+ // redux
+ advancedSettingsFinished: PropTypes.bool.isRequired,
+ blockFinished: PropTypes.bool.isRequired,
+ blockFailed: PropTypes.bool.isRequired,
+ problemType: PropTypes.string.isRequired,
+ initializeProblemEditor: PropTypes.func.isRequired,
+ blockValue: PropTypes.objectOf(PropTypes.shape({})).isRequired,
+};
+
+export const mapStateToProps = (state) => ({
+ blockFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchBlock }),
+ blockFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchBlock }),
+ problemType: selectors.problem.problemType(state),
+ blockValue: selectors.app.blockValue(state),
+ advancedSettingsFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchAdvancedSettings }),
+});
+
+export const mapDispatchToProps = {
+ initializeProblemEditor: thunkActions.problem.initializeProblem,
+};
+
+export const ProblemEditorInternal = ProblemEditor; // For testing only
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ProblemEditor));
diff --git a/src/editors/containers/ProblemEditor/index.test.jsx b/src/editors/containers/ProblemEditor/index.test.jsx
new file mode 100644
index 0000000000..f6866b56a6
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/index.test.jsx
@@ -0,0 +1,123 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+import { Spinner } from '@openedx/paragon';
+import { thunkActions, selectors } from '../../data/redux';
+import { RequestKeys } from '../../data/constants/requests';
+import { ProblemEditorInternal as ProblemEditor, mapStateToProps, mapDispatchToProps } from '.';
+
+jest.mock('./components/EditProblemView', () => 'EditProblemView');
+jest.mock('./components/SelectTypeModal', () => 'SelectTypeModal');
+
+jest.mock('react', () => {
+ const updateState = jest.fn();
+ return {
+ ...jest.requireActual('react'),
+ updateState,
+ useState: jest.fn(val => ([{ state: val }, jest.fn().mockName('setState')])),
+ };
+});
+
+jest.mock('../../data/redux', () => ({
+ thunkActions: {
+ problem: {
+ initializeProblemEditor: jest.fn().mockName('thunkActions.problem.initializeProblem'),
+ },
+ },
+ selectors: {
+ app: {
+ blockValue: jest.fn(state => ({ blockValue: state })),
+ },
+ problem: {
+ problemType: jest.fn(state => ({ problemType: state })),
+ },
+ requests: {
+ isFinished: jest.fn((state, params) => ({ isFinished: { state, params } })),
+ isFailed: jest.fn((state, params) => ({ isFailed: { state, params } })),
+ },
+ },
+}));
+
+describe('ProblemEditor', () => {
+ const props = {
+ onClose: jest.fn().mockName('props.onClose'),
+ // redux
+ problemType: null,
+ blockValue: { data: { data: 'eDiTablE Text' } },
+ blockFinished: false,
+ blockFailed: false,
+ initializeProblemEditor: jest.fn().mockName('args.intializeProblemEditor'),
+ advancedSettingsFinished: false,
+ };
+ describe('snapshots', () => {
+ test('renders as expected with default behavior', () => {
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ test('block loaded, studio view and assets not yet loaded, Spinner appears', () => {
+ const wrapper = shallow( );
+ expect(wrapper.instance.findByType(Spinner)).toBeTruthy();
+ });
+ test('advanceSettings loaded, block and studio view not yet loaded, Spinner appears', () => {
+ const wrapper = shallow( );
+ expect(wrapper.instance.findByType(Spinner)).toBeTruthy();
+ });
+ test('block failed, message appears', () => {
+ const wrapper = shallow( );
+ expect(wrapper.snapshot).toMatchSnapshot();
+ });
+ test('renders SelectTypeModal', () => {
+ const wrapper = shallow( );
+ expect(wrapper.instance.findByType('SelectTypeModal')).toHaveLength(1);
+ });
+ test('renders EditProblemView', () => {
+ const wrapper = shallow( );
+ expect(wrapper.instance.findByType('EditProblemView')).toHaveLength(1);
+ });
+ });
+
+ describe('mapStateToProps', () => {
+ const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
+ test('blockValue from app.blockValue', () => {
+ expect(
+ mapStateToProps(testState).blockValue,
+ ).toEqual(selectors.app.blockValue(testState));
+ });
+ test('problemType from problem.problemType', () => {
+ expect(
+ mapStateToProps(testState).problemType,
+ ).toEqual(selectors.problem.problemType(testState));
+ });
+ test('blockFinished from requests.isFinished', () => {
+ expect(
+ mapStateToProps(testState).blockFinished,
+ ).toEqual(selectors.requests.isFinished(testState, { requestKey: RequestKeys.fetchBlock }));
+ });
+ test('advancedSettingsFinished from requests.isFinished', () => {
+ expect(
+ mapStateToProps(testState).advancedSettingsFinished,
+ ).toEqual(selectors.requests.isFinished(testState, { requestKey: RequestKeys.fetchAdvancedSettings }));
+ });
+ });
+ describe('mapDispatchToProps', () => {
+ test('initializeProblemEditor from thunkActions.problem.initializeProblem', () => {
+ expect(mapDispatchToProps.initializeProblemEditor).toEqual(thunkActions.problem.initializeProblem);
+ });
+ });
+});
diff --git a/src/editors/containers/ProblemEditor/messages.js b/src/editors/containers/ProblemEditor/messages.js
new file mode 100644
index 0000000000..d9fa05cb42
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/messages.js
@@ -0,0 +1,12 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+
+ blockFailed: {
+ id: 'authoring.problemEditor.blockFailed',
+ defaultMessage: 'Problem failed to load',
+ description: 'Error message for problem block failing to load',
+ },
+});
+
+export default messages;
diff --git a/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap b/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..9a2b331968
--- /dev/null
+++ b/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,242 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
+
+
+
+
+
+ <[object Object]
+ editorContentHtml="eDiTablE Text"
+ editorRef={
+ {
+ "current": {
+ "value": "something",
+ },
+ }
+ }
+ editorType="text"
+ height="100%"
+ initializeEditor={[MockFunction args.intializeEditor]}
+ minHeight={500}
+ setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]}
+ />
+
+
+`;
+
+exports[`TextEditor snapshots loaded, raw editor 1`] = `
+
+
+
+
+
+
+
+
+`;
+
+exports[`TextEditor snapshots not yet loaded, Spinner appears 1`] = `
+
+
+
+`;
+
+exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
+
+
+
+
+
+ <[object Object]
+ editorContentHtml="eDiTablE Text"
+ editorRef={
+ {
+ "current": {
+ "value": "something",
+ },
+ }
+ }
+ editorType="text"
+ height="100%"
+ initializeEditor={[MockFunction args.intializeEditor]}
+ minHeight={500}
+ setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]}
+ />
+
+
+`;
+
+exports[`TextEditor snapshots renders static images with relative paths 1`] = `
+
+
+
+
+
+ <[object Object]
+ editorContentHtml="eDiTablE Text with
"
+ editorRef={
+ {
+ "current": {
+ "value": "something",
+ },
+ }
+ }
+ editorType="text"
+ height="100%"
+ initializeEditor={[MockFunction args.intializeEditor]}
+ minHeight={500}
+ setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]}
+ />
+
+
+`;
diff --git a/src/editors/containers/TextEditor/hooks.js b/src/editors/containers/TextEditor/hooks.js
new file mode 100644
index 0000000000..3d725f114c
--- /dev/null
+++ b/src/editors/containers/TextEditor/hooks.js
@@ -0,0 +1,11 @@
+import * as appHooks from '../../hooks';
+import { setAssetToStaticUrl } from '../../sharedComponents/TinyMceWidget/hooks';
+
+export const { nullMethod, navigateCallback, navigateTo } = appHooks;
+
+export const getContent = ({ editorRef, showRawEditor }) => () => {
+ const content = (showRawEditor && editorRef && editorRef.current
+ ? editorRef.current.state.doc.toString()
+ : editorRef.current?.getContent());
+ return setAssetToStaticUrl({ editorValue: content });
+};
diff --git a/src/editors/containers/TextEditor/hooks.test.jsx b/src/editors/containers/TextEditor/hooks.test.jsx
new file mode 100644
index 0000000000..bac63ab334
--- /dev/null
+++ b/src/editors/containers/TextEditor/hooks.test.jsx
@@ -0,0 +1,65 @@
+import { keyStore } from '../../utils';
+import * as appHooks from '../../hooks';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './hooks';
+import * as tinyMceHooks from '../../sharedComponents/TinyMceWidget/hooks';
+
+const tinyMceHookKeys = keyStore(tinyMceHooks);
+
+describe('TextEditor hooks', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('appHooks', () => {
+ it('forwards navigateCallback from appHooks', () => {
+ expect(module.navigateCallback).toEqual(appHooks.navigateCallback);
+ });
+ it('forwards navigateTo from appHooks', () => {
+ expect(module.navigateTo).toEqual(appHooks.navigateTo);
+ });
+ it('forwards nullMethod from appHooks', () => {
+ expect(module.nullMethod).toEqual(appHooks.nullMethod);
+ });
+ });
+
+ describe('non-state hooks', () => {
+ describe('getContent', () => {
+ const visualContent = 'sOmEViSualContent';
+ const rawContent = 'soMeRawContent';
+ const editorRef = {
+ current: {
+ getContent: () => visualContent,
+ state: {
+ doc: rawContent,
+ },
+ },
+ };
+ const spies = {};
+ spies.visualHtml = jest.spyOn(
+ tinyMceHooks,
+ tinyMceHookKeys.setAssetToStaticUrl,
+ ).mockReturnValueOnce(visualContent);
+ spies.rawHtml = jest.spyOn(
+ tinyMceHooks,
+ tinyMceHookKeys.setAssetToStaticUrl,
+ ).mockReturnValueOnce(rawContent);
+ test('returns correct content based on showRawEditor equals false', () => {
+ const getContent = module.getContent({ editorRef, showRawEditor: false })();
+ expect(spies.visualHtml.mock.calls.length).toEqual(1);
+ expect(spies.visualHtml).toHaveBeenCalledWith({ editorValue: visualContent });
+ expect(getContent).toEqual(visualContent);
+ });
+ test('returns correct content based on showRawEditor equals true', () => {
+ jest.clearAllMocks();
+ const getContent = module.getContent({ editorRef, showRawEditor: true })();
+ expect(spies.rawHtml.mock.calls.length).toEqual(1);
+ expect(spies.rawHtml).toHaveBeenCalledWith({ editorValue: rawContent });
+ expect(getContent).toEqual(rawContent);
+ });
+ });
+ });
+});
diff --git a/src/editors/containers/TextEditor/index.jsx b/src/editors/containers/TextEditor/index.jsx
new file mode 100644
index 0000000000..830f3b510a
--- /dev/null
+++ b/src/editors/containers/TextEditor/index.jsx
@@ -0,0 +1,125 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+
+import {
+ Spinner,
+ Toast,
+} from '@openedx/paragon';
+import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+
+import { actions, selectors } from '../../data/redux';
+import { RequestKeys } from '../../data/constants/requests';
+
+import EditorContainer from '../EditorContainer';
+import RawEditor from '../../sharedComponents/RawEditor';
+import * as hooks from './hooks';
+import messages from './messages';
+import TinyMceWidget from '../../sharedComponents/TinyMceWidget';
+import { prepareEditorRef, replaceStaticWithAsset } from '../../sharedComponents/TinyMceWidget/hooks';
+
+const TextEditor = ({
+ onClose,
+ returnFunction,
+ // redux
+ showRawEditor,
+ blockValue,
+ blockFailed,
+ initializeEditor,
+ blockFinished,
+ learningContextId,
+ // inject
+ intl,
+}) => {
+ const { editorRef, refReady, setEditorRef } = prepareEditorRef();
+ const initialContent = blockValue ? blockValue.data.data : '';
+ const newContent = replaceStaticWithAsset({
+ initialContent,
+ learningContextId,
+ });
+ const editorContent = newContent || initialContent;
+
+ if (!refReady) { return null; }
+
+ const selectEditor = () => {
+ if (showRawEditor) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ };
+
+ return (
+
+
+
+
+
+
+ {(!blockFinished)
+ ? (
+
+
+
+ ) : (selectEditor())}
+
+
+ );
+};
+TextEditor.defaultProps = {
+ blockValue: null,
+ blockFinished: null,
+ returnFunction: null,
+};
+TextEditor.propTypes = {
+ onClose: PropTypes.func.isRequired,
+ returnFunction: PropTypes.func,
+ // redux
+ blockValue: PropTypes.shape({
+ data: PropTypes.shape({ data: PropTypes.string }),
+ }),
+ blockFailed: PropTypes.bool.isRequired,
+ initializeEditor: PropTypes.func.isRequired,
+ showRawEditor: PropTypes.bool.isRequired,
+ blockFinished: PropTypes.bool,
+ learningContextId: PropTypes.string.isRequired,
+ // inject
+ intl: intlShape.isRequired,
+};
+
+export const mapStateToProps = (state) => ({
+ blockValue: selectors.app.blockValue(state),
+ blockFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchBlock }),
+ showRawEditor: selectors.app.showRawEditor(state),
+ blockFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchBlock }),
+ learningContextId: selectors.app.learningContextId(state),
+});
+
+export const mapDispatchToProps = {
+ initializeEditor: actions.app.initializeEditor,
+};
+
+export const TextEditorInternal = TextEditor; // For testing only
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextEditor));
diff --git a/src/editors/containers/TextEditor/index.test.jsx b/src/editors/containers/TextEditor/index.test.jsx
new file mode 100644
index 0000000000..ac337f68f7
--- /dev/null
+++ b/src/editors/containers/TextEditor/index.test.jsx
@@ -0,0 +1,139 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { formatMessage } from '../../testUtils';
+import { actions, selectors } from '../../data/redux';
+import { RequestKeys } from '../../data/constants/requests';
+import { TextEditorInternal as TextEditor, mapStateToProps, mapDispatchToProps } from '.';
+
+// 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', () => {
+ const originalModule = jest.requireActual('@tinymce/tinymce-react');
+ return {
+ __esModule: true,
+ ...originalModule,
+ Editor: () => 'TiNYmCE EDitOR',
+ };
+});
+
+jest.mock('../EditorContainer', () => 'EditorContainer');
+
+jest.mock('./hooks', () => ({
+ getContent: jest.fn(args => ({ getContent: args })),
+ nullMethod: jest.fn().mockName('hooks.nullMethod'),
+}));
+
+jest.mock('../../sharedComponents/TinyMceWidget/hooks', () => ({
+ ...jest.requireActual('../../sharedComponents/TinyMceWidget/hooks'),
+ prepareEditorRef: jest.fn(() => ({
+ editorRef: { current: { value: 'something' } },
+ refReady: true,
+ setEditorRef: jest.fn().mockName('hooks.prepareEditorRef.setEditorRef'),
+ })),
+}));
+
+jest.mock('react', () => {
+ const updateState = jest.fn();
+ return {
+ ...jest.requireActual('react'),
+ updateState,
+ useState: jest.fn(val => ([{ state: val }, jest.fn().mockName('setState')])),
+ };
+});
+
+jest.mock('../../data/redux', () => ({
+ __esModule: true,
+ default: jest.fn(),
+ actions: {
+ app: {
+ initializeEditor: jest.fn().mockName('actions.app.initializeEditor'),
+ },
+ },
+ selectors: {
+ app: {
+ blockValue: jest.fn(state => ({ blockValue: state })),
+ lmsEndpointUrl: jest.fn(state => ({ lmsEndpointUrl: state })),
+ studioEndpointUrl: jest.fn(state => ({ studioEndpointUrl: state })),
+ showRawEditor: jest.fn(state => ({ showRawEditor: state })),
+ isLibrary: jest.fn(state => ({ isLibrary: state })),
+ learningContextId: jest.fn(state => ({ learningContextId: state })),
+ },
+ requests: {
+ isFailed: jest.fn((state, params) => ({ isFailed: { state, params } })),
+ isFinished: jest.fn((state, params) => ({ isFailed: { state, params } })),
+ },
+ },
+ thunkActions: {
+ video: {
+ importTranscript: jest.fn(),
+ },
+ },
+}));
+
+describe('TextEditor', () => {
+ const props = {
+ onClose: jest.fn().mockName('props.onClose'),
+ // redux
+ blockValue: { data: { data: 'eDiTablE Text' } },
+ blockFailed: false,
+ initializeEditor: jest.fn().mockName('args.intializeEditor'),
+ showRawEditor: false,
+ blockFinished: true,
+ learningContextId: 'course+org+run',
+ // inject
+ intl: { formatMessage },
+ };
+ describe('snapshots', () => {
+ test('renders as expected with default behavior', () => {
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ test('renders static images with relative paths', () => {
+ const updatedProps = {
+ ...props,
+ blockValue: { data: { data: 'eDiTablE Text with ' } },
+ };
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ test('not yet loaded, Spinner appears', () => {
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ test('loaded, raw editor', () => {
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ test('block failed to load, Toast is shown', () => {
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ });
+
+ describe('mapStateToProps', () => {
+ const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
+ test('blockValue from app.blockValue', () => {
+ expect(
+ mapStateToProps(testState).blockValue,
+ ).toEqual(selectors.app.blockValue(testState));
+ });
+ test('blockFailed from requests.isFailed', () => {
+ expect(
+ mapStateToProps(testState).blockFailed,
+ ).toEqual(selectors.requests.isFailed(testState, { requestKey: RequestKeys.fetchBlock }));
+ });
+ test('blockFinished from requests.isFinished', () => {
+ expect(
+ mapStateToProps(testState).blockFinished,
+ ).toEqual(selectors.requests.isFinished(testState, { requestKey: RequestKeys.fetchBlock }));
+ });
+ test('learningContextId from app.learningContextId', () => {
+ expect(
+ mapStateToProps(testState).learningContextId,
+ ).toEqual(selectors.app.learningContextId(testState));
+ });
+ });
+
+ describe('mapDispatchToProps', () => {
+ test('initializeEditor from actions.app.initializeEditor', () => {
+ expect(mapDispatchToProps.initializeEditor).toEqual(actions.app.initializeEditor);
+ });
+ });
+});
diff --git a/src/editors/containers/TextEditor/messages.js b/src/editors/containers/TextEditor/messages.js
new file mode 100644
index 0000000000..e6f870a4b6
--- /dev/null
+++ b/src/editors/containers/TextEditor/messages.js
@@ -0,0 +1,17 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+
+ couldNotLoadTextContext: {
+ id: 'authoring.texteditor.load.error',
+ defaultMessage: 'Error: Could Not Load Text Content',
+ description: 'Error Message Dispayed When HTML content fails to Load',
+ },
+ spinnerScreenReaderText: {
+ id: 'authoring.texteditor.spinnerScreenReaderText',
+ defaultMessage: 'loading',
+ description: 'Loading message for spinner screenreader text.',
+ },
+});
+
+export default messages;
diff --git a/src/editors/containers/VideoEditor/__snapshots__/index.test.jsx.snap b/src/editors/containers/VideoEditor/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..b821fe3ecf
--- /dev/null
+++ b/src/editors/containers/VideoEditor/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,50 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`VideoEditor snapshots renders as expected with default behavior 1`] = `
+
+
+
+
+
+
+
+`;
+
+exports[`VideoEditor snapshots renders as expected with default behavior 2`] = `
+
+
+
+
+
+
+
+`;
diff --git a/src/editors/containers/VideoEditor/components/SelectVideoModal.jsx b/src/editors/containers/VideoEditor/components/SelectVideoModal.jsx
new file mode 100644
index 0000000000..07167d65ca
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/SelectVideoModal.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+
+import { Button } from '@openedx/paragon';
+
+import { thunkActions } from '../../../data/redux';
+import BaseModal from '../../../sharedComponents/BaseModal';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+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 });
+ }, []);
+ return videos;
+ },
+ onSelectClick: ({ setSelection, videos }) => () => setSelection(videos[0]),
+};
+
+export const SelectVideoModal = ({
+ fetchVideos,
+ isOpen,
+ close,
+ setSelection,
+}) => {
+ const videos = module.hooks.videoList({ fetchVideos });
+ const onSelectClick = module.hooks.onSelectClick({
+ setSelection,
+ videos,
+ });
+
+ return (
+ Next}
+ >
+ {/* Content selection */}
+ {videos && (videos.map(
+ img => (
+
+ ),
+ ))}
+
+ );
+};
+
+SelectVideoModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ close: PropTypes.func.isRequired,
+ setSelection: PropTypes.func.isRequired,
+ // redux
+ fetchVideos: PropTypes.func.isRequired,
+};
+
+export const mapStateToProps = () => ({});
+export const mapDispatchToProps = {
+ fetchVideos: thunkActions.app.fetchVideos,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(SelectVideoModal);
diff --git a/src/editors/containers/VideoEditor/components/VideoEditorModal.jsx b/src/editors/containers/VideoEditor/components/VideoEditorModal.jsx
new file mode 100644
index 0000000000..ce5ac64a29
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoEditorModal.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import PropTypes from 'prop-types';
+import * as appHooks from '../../../hooks';
+import { thunkActions, selectors } from '../../../data/redux';
+import VideoSettingsModal from './VideoSettingsModal';
+// import SelectVideoModal from './SelectVideoModal';
+
+export const {
+ navigateTo,
+} = appHooks;
+
+export const hooks = {
+ initialize: (dispatch, selectedVideoId, selectedVideoUrl) => {
+ dispatch(thunkActions.video.loadVideoData(selectedVideoId, selectedVideoUrl));
+ },
+ useReturnToGallery: () => {
+ const learningContextId = useSelector(selectors.app.learningContextId);
+ const blockId = useSelector(selectors.app.blockId);
+ return () => (navigateTo(`/course/${learningContextId}/editor/course-videos/${blockId}`));
+ },
+};
+
+const VideoEditorModal = ({
+ close,
+ isOpen,
+ isLibrary,
+}) => {
+ const dispatch = useDispatch();
+ const searchParams = new URLSearchParams(document.location.search);
+ const selectedVideoId = searchParams.get('selectedVideoId');
+ const selectedVideoUrl = searchParams.get('selectedVideoUrl');
+ const onReturn = hooks.useReturnToGallery();
+ hooks.initialize(dispatch, selectedVideoId, selectedVideoUrl);
+ return (
+
+ );
+ // TODO: add logic to show SelectVideoModal if no selection
+};
+
+VideoEditorModal.defaultProps = {
+};
+VideoEditorModal.propTypes = {
+ close: PropTypes.func.isRequired,
+ isOpen: PropTypes.bool.isRequired,
+ isLibrary: PropTypes.bool.isRequired,
+};
+export default VideoEditorModal;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/ErrorSummary.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/ErrorSummary.jsx
new file mode 100644
index 0000000000..a7acc05a41
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/ErrorSummary.jsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+
+import { Alert } from '@openedx/paragon';
+import { InfoOutline } from '@openedx/paragon/icons';
+
+import messages from './components/messages';
+import { ErrorContext } from '../../hooks';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './ErrorSummary';
+
+export const hasNoError = (error) => Object.keys(error[0]).length === 0;
+
+export const showAlert = (errors) => !Object.values(errors).every(module.hasNoError);
+
+export const ErrorSummary = () => {
+ const errors = React.useContext(ErrorContext);
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/ErrorSummary.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/ErrorSummary.test.jsx
new file mode 100644
index 0000000000..7a34297aef
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/ErrorSummary.test.jsx
@@ -0,0 +1,48 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import * as module from './ErrorSummary';
+
+const { ErrorSummary } = module;
+
+describe('ErrorSummary', () => {
+ const errors = {
+ widgetWithError: [{ err1: 'mSg', err2: 'msG2' }, jest.fn()],
+ widgetWithNoError: [{}, jest.fn()],
+ };
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+ describe('render', () => {
+ beforeEach(() => {
+ jest.spyOn(React, 'useContext').mockReturnValueOnce({});
+ });
+ test('snapshots: renders as expected when there are no errors', () => {
+ jest.spyOn(module, 'showAlert').mockReturnValue(false);
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected when there are errors', () => {
+ jest.spyOn(module, 'showAlert').mockReturnValue(true);
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ });
+ describe('hasNoError', () => {
+ it('returns true', () => {
+ expect(module.hasNoError(errors.widgetWithError)).toEqual(false);
+ });
+ it('returns false', () => {
+ expect(module.hasNoError(errors.widgetWithNoError)).toEqual(true);
+ });
+ });
+ describe('showAlert', () => {
+ it('returns true', () => {
+ jest.spyOn(module, 'hasNoError').mockReturnValue(false);
+ expect(module.showAlert(errors)).toEqual(true);
+ });
+ it('returns false', () => {
+ jest.spyOn(module, 'hasNoError').mockReturnValue(true);
+ expect(module.showAlert(errors)).toEqual(false);
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/__snapshots__/ErrorSummary.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/__snapshots__/ErrorSummary.test.jsx.snap
new file mode 100644
index 0000000000..88baf91bd1
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/__snapshots__/ErrorSummary.test.jsx.snap
@@ -0,0 +1,45 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ErrorSummary render snapshots: renders as expected when there are errors 1`] = `
+
+
+
+
+
+
+
+
+`;
+
+exports[`ErrorSummary render snapshots: renders as expected when there are no errors 1`] = `
+
+
+
+
+
+
+
+
+`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/CollapsibleFormWidget.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/CollapsibleFormWidget.jsx
new file mode 100644
index 0000000000..9545195421
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/CollapsibleFormWidget.jsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { Collapsible, Icon, IconButton } from '@openedx/paragon';
+import { ExpandLess, ExpandMore, InfoOutline } from '@openedx/paragon/icons';
+
+import messages from './messages';
+
+/**
+ * Simple Wrapper for a Form Widget component in the Video Settings modal
+ * Takes a title element and children, and produces a collapsible widget container
+ * My Title }>
+ * My Widget
+ *
+ */
+const CollapsibleFormWidget = ({
+ children,
+ isError,
+ subtitle,
+ title,
+ fontSize,
+ // injected
+ intl,
+}) => (
+
+
+
+
+
{title}
+ {subtitle ?
{subtitle}
:
}
+
+
+ {isError && }
+
+
+
+
+ {title}
+
+
+
+
+
+
+ {children}
+
+
+);
+
+CollapsibleFormWidget.defaultProps = {
+ subtitle: null,
+ fontSize: '',
+};
+
+CollapsibleFormWidget.propTypes = {
+ children: PropTypes.node.isRequired,
+ isError: PropTypes.bool.isRequired,
+ subtitle: PropTypes.node,
+ title: PropTypes.node.isRequired,
+ fontSize: PropTypes.string,
+ // injected
+ intl: intlShape.isRequired,
+};
+
+export const CollapsibleFormWidgetInternal = CollapsibleFormWidget; // For testing only
+export default injectIntl(CollapsibleFormWidget);
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/CollapsibleFormWidget.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/CollapsibleFormWidget.test.jsx
new file mode 100644
index 0000000000..6cbd4d5b41
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/CollapsibleFormWidget.test.jsx
@@ -0,0 +1,29 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { formatMessage } from '../../../../../testUtils';
+import { CollapsibleFormWidgetInternal as CollapsibleFormWidget } from './CollapsibleFormWidget';
+
+describe('CollapsibleFormWidget', () => {
+ const props = {
+ isError: false,
+ subtitle: 'SuBTItle',
+ title: 'tiTLE',
+ // inject
+ intl: { formatMessage },
+ };
+ describe('render', () => {
+ const testContent = (Some test string
);
+ test('snapshots: renders as expected with default props', () => {
+ expect(
+ shallow({testContent} ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders with open={true} when there is error', () => {
+ expect(
+ shallow({testContent} ).snapshot,
+ ).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..1455c89bdc
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,64 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DurationWidget render snapshots: renders as expected with default props 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Total: 00:00:00
+
+
+
+`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.js
new file mode 100644
index 0000000000..ba84d31dfa
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.js
@@ -0,0 +1,234 @@
+import { useEffect, useState } from 'react';
+
+import messages from '../messages';
+
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './hooks';
+
+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]);
+
+ return {
+ unsavedDuration,
+ onBlur: (index) => (
+ (e) => module.updateDuration({
+ duration,
+ setDuration,
+ unsavedDuration,
+ setUnsavedDuration,
+ index,
+ inputString: e.target.value,
+ })
+ ),
+ onChange: (index) => (
+ (e) => setUnsavedDuration(module.onDurationChange(unsavedDuration, index, e.target.value))
+ ),
+ onKeyDown: (index) => (
+ (e) => setUnsavedDuration(module.onDurationKeyDown(unsavedDuration, index, e))
+ ),
+ getTotalLabel: ({ durationString, subtitle, intl }) => {
+ if (!durationString.stopTime) {
+ if (!durationString.startTime) {
+ return intl.formatMessage(messages.fullVideoLength);
+ }
+ if (subtitle) {
+ return intl.formatMessage(
+ messages.startsAt,
+ { startTime: module.durationStringFromValue(durationString.startTime) },
+ );
+ }
+ return null;
+ }
+ const total = durationString.stopTime - (durationString.startTime || 0);
+ return intl.formatMessage(
+ subtitle ? messages.custom : messages.total,
+ { total: module.durationStringFromValue(total) },
+ );
+ },
+ };
+};
+
+/**
+ * durationString(duration)
+ * Returns the display value for embedded start and stop times
+ * @param {object} duration - object containing startTime and stopTime millisecond values
+ * @return {object} - start and stop time from incoming object mapped to duration strings.
+ */
+export const durationString = (duration) => ({
+ startTime: module.durationStringFromValue(duration.startTime),
+ stopTime: module.durationStringFromValue(duration.stopTime),
+});
+
+/**
+ * durationStringFromValue(value)
+ * Returns a duration string in 'hh:mm:ss' format from the given ms value
+ * @param {number} value - duration (in milliseconds)
+ * @return {string} - duration in 'hh:mm:ss' format
+ */
+export const durationStringFromValue = (value) => {
+ // return 'why';
+ if (!value || typeof value !== 'number' || value <= 0) {
+ return '00:00:00';
+ }
+ const seconds = Math.floor((value / 1000) % 60);
+ const minutes = Math.floor((value / 60000) % 60);
+ const hours = Math.floor((value / 3600000) % 60);
+ const zeroPad = (num) => String(num).padStart(2, '0');
+ return [hours, minutes, seconds].map(zeroPad).join(':');
+};
+
+/**
+ * updateDuration({ duration, unsavedDuration, setUnsavedDuration, setDuration })
+ * Returns a memoized callback based on inputs that updates unsavedDuration value and form value
+ * if the new string is valid (duration stores a number, unsavedDuration stores a string).
+ * If the duration string is invalid, resets the unsavedDuration value to the latest good value.
+ * @param {object} duration - redux-stored durations in milliseconds
+ * @param {object} unsavedDuration - hook-stored duration in 'hh:mm:ss' format
+ * @param {func} setDuration - set form value
+ * @param {func} setUnsavedDuration - set unsavedDuration object
+ * @param {string} index - startTime or stopTime
+ * @param {string} inputString - string value of user input for either the start or stop time fields
+ * @return {func} - callback to update duration unsavedDurationly and in redux
+ * updateDuration(args)(index, durationString)
+ */
+export const updateDuration = ({
+ duration,
+ unsavedDuration,
+ setDuration,
+ setUnsavedDuration,
+ index,
+ inputString,
+}) => {
+ let newDurationString = inputString;
+ let newValue = module.valueFromDuration(newDurationString);
+ // maxTime is 23:59:59 or 86399 seconds
+ if (newValue > 86399000) {
+ newValue = 86399000;
+ }
+
+ // stopTime must not be equal to 24:00:00, so when the user types 23:59:59 in the startTime field and stopTime field -
+ // set the startTime field to 23:59:58.
+ if (index === 'stopTime' && duration.startTime === 86399000) {
+ const startTime = 86399000 - 1000;
+
+ setUnsavedDuration({
+ startTime: module.durationStringFromValue(startTime),
+ stopTime: module.durationStringFromValue(newValue),
+ });
+ setDuration({
+ ...duration,
+ startTime,
+ stopTime: newValue,
+ });
+
+ return;
+ }
+
+ // stopTime must be at least 1 second, if not zero
+ if (index === 'stopTime' && newValue > 0 && newValue < 1000) {
+ newValue = 1000;
+ }
+ // stopTime must be at least 1 second after startTime, except 0 means no custom stopTime
+ if (index === 'stopTime' && newValue > 0 && newValue < (duration.startTime + 1000)) {
+ newValue = duration.startTime + 1000;
+ }
+ // startTime must be at least 1 second before stopTime, except when stopTime is less than a second
+ // (stopTime should only be less than a second if it's zero, but we're being paranoid)
+ if (index === 'startTime' && duration.stopTime >= 1000 && newValue > (duration.stopTime - 1000)) {
+ newValue = duration.stopTime - 1000;
+ }
+ newDurationString = module.durationStringFromValue(newValue);
+ setUnsavedDuration({ ...unsavedDuration, [index]: newDurationString });
+ setDuration({ ...duration, [index]: newValue });
+};
+
+/**
+ * onDurationChange(duration)
+ * Returns a new duration value based on onChange event
+ * @param {object} duration - object containing startTime and stopTime millisecond values
+ * @param {string} index - 'startTime or 'stopTime'
+ * @param {string} val - duration in 'hh:mm:ss' format
+ * @return {object} duration - object containing startTime and stopTime millisecond values
+ */
+export const onDurationChange = (duration, index, val) => {
+ const match = val.trim().match(durationMatcher);
+ if (!match) {
+ return duration;
+ }
+
+ const caretPos = document.activeElement.selectionStart;
+ let newDuration = val;
+ if (caretPos === newDuration.length && (newDuration.length === 2 || newDuration.length === 5)) {
+ newDuration += ':';
+ }
+
+ return {
+ ...duration,
+ [index]: newDuration,
+ };
+};
+
+/**
+ * onDurationKeyDown(duration)
+ * Returns a new duration value based on onKeyDown event
+ * @param {object} duration - object containing startTime and stopTime millisecond values
+ * @param {string} index - 'startTime or 'stopTime'
+ * @param {Event} event - event from onKeyDown
+ * @return {object} duration - object containing startTime and stopTime millisecond values
+ */
+export const onDurationKeyDown = (duration, index, event) => {
+ const caretPos = document.activeElement.selectionStart;
+ let newDuration = duration[index];
+
+ switch (event.key) {
+ case 'Enter':
+ document.activeElement.blur();
+ break;
+ case 'Backspace':
+ if (caretPos === newDuration.length && newDuration.slice(-1) === ':') {
+ newDuration = newDuration.slice(0, -1);
+ }
+ break;
+ default:
+ break;
+ }
+
+ return {
+ ...duration,
+ [index]: newDuration,
+ };
+};
+
+/**
+ * valueFromDuration(duration)
+ * Returns a millisecond duration value from the given 'hh:mm:ss' format string
+ * @param {string} duration - duration in 'hh:mm:ss' format
+ * @return {number} - duration in milliseconds. Returns null if duration is invalid.
+ */
+export const valueFromDuration = (duration) => {
+ let matches = duration.trim().match(durationMatcher);
+ if (!matches) {
+ return 0;
+ }
+ matches = matches.slice(1).filter(v => v !== undefined);
+ if (matches.length < 3) {
+ for (let i = 0; i <= 3 - matches.length; i++) {
+ matches.unshift(0);
+ }
+ }
+ const [hours, minutes, seconds] = matches.map(x => parseInt(x, 10) || 0);
+ return ((hours * 60 + minutes) * 60 + seconds) * 1000;
+};
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.test.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.test.js
new file mode 100644
index 0000000000..f05de3fbbd
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.test.js
@@ -0,0 +1,348 @@
+import React from 'react';
+
+import * as hooks from './hooks';
+import messages from '../messages';
+
+jest.mock('react', () => {
+ const updateState = jest.fn();
+ return {
+ ...jest.requireActual('react'),
+ updateState,
+ useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])),
+ useCallback: (cb, prereqs) => ({ useCallback: { cb, prereqs } }),
+ useEffect: jest.fn(),
+ };
+});
+
+let testMethod;
+const intl = {
+ formatMessage: jest.fn(val => val),
+};
+
+const [h, m, s] = [3600000, 60000, 1000];
+const durationPairs = [
+ [0, '00:00:00'],
+ [5000, '00:00:05'],
+ [60000, '00:01:00'],
+ [3600000, '01:00:00'],
+ [3665000, '01:01:05'],
+];
+const trickyDurations = [
+ ['10:00', 600000],
+ ['23', 23000],
+ ['99:99:99', 99 * (m + s + h)],
+ ['23:42:81', 23 * h + 42 * m + 81 * s],
+];
+let props;
+const e = {
+ target: {
+ value: 'vAlUE',
+ },
+};
+
+describe('Video Settings DurationWidget hooks', () => {
+ describe('durationWidget', () => {
+ const duration = {
+ startTime: '00:00:00',
+ stopTime: '00:00:10',
+ };
+ const updateField = jest.fn();
+ beforeEach(() => {
+ testMethod = hooks.durationWidget({ duration, updateField });
+ });
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+ describe('behavior', () => {
+ describe('initialization', () => {
+ test('useEffect memoized on duration', () => {
+ hooks.durationWidget({ duration, updateField });
+ expect(React.useEffect).toHaveBeenCalled();
+ expect(React.useEffect.mock.calls[0][1]).toEqual([duration]);
+ });
+ test('calls setUnsavedDuration with durationString(duration)', () => {
+ hooks.durationWidget({ duration, updateField });
+ React.useEffect.mock.calls[0][0]();
+ expect(React.updateState).toHaveBeenCalled();
+ });
+ });
+ });
+ describe('returns', () => {
+ beforeEach(() => {
+ testMethod = hooks.durationWidget({ duration, updateField });
+ });
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+ describe('unsavedDuration, defaulted to duration', () => {
+ it('should default unsavedDuration to duration', () => {
+ expect(testMethod.unsavedDuration).toEqual({ state: hooks.durationString(duration) });
+ });
+ });
+ describe('onBlur', () => {
+ it('calls updateDuration on blur', () => {
+ jest.spyOn(hooks, 'updateDuration').mockImplementation(jest.fn());
+ testMethod.onBlur('IndEX')(e);
+ expect(hooks.updateDuration).toHaveBeenCalled();
+ });
+ });
+ describe('onChange', () => {
+ it('calls updateState on change', () => {
+ testMethod.onChange('IndEX')(e);
+ expect(React.updateState).toHaveBeenCalled();
+ });
+ });
+ describe('onKeyDown', () => {
+ it('calls updateState on key down', () => {
+ testMethod.onKeyDown('iNDex')(e);
+ expect(React.updateState).toHaveBeenCalled();
+ });
+ });
+ describe('getTotalLabel', () => {
+ it('returns fullVideoLength message when no startTime and no stop Time are set', () => {
+ expect(testMethod.getTotalLabel({
+ durationString: {},
+ subtitle: true,
+ intl,
+ })).toEqual(messages.fullVideoLength);
+ });
+ it('returns startAt message for subtitle when only startTime is set', () => {
+ expect(testMethod.getTotalLabel({
+ durationString: {
+ startTime: '00:00:00',
+ },
+ subtitle: true,
+ intl,
+ })).toEqual(messages.startsAt);
+ });
+ it('returns null for widget (not subtitle) when there only startTime is set', () => {
+ expect(testMethod.getTotalLabel({
+ durationString: {
+ startTime: '00:00:00',
+ },
+ subtitle: false,
+ intl,
+ })).toEqual(null);
+ });
+ it('returns total message when at least stopTime is set', () => {
+ expect(testMethod.getTotalLabel({
+ durationString: {
+ startTime: '00:00:00',
+ stopTime: '00:00:10',
+ },
+ subtitle: false,
+ intl,
+ })).toEqual(messages.total);
+ });
+ it('returns custom message when at least stopTime is set and subtitle is true', () => {
+ expect(testMethod.getTotalLabel({
+ durationString: {
+ startTime: '00:00:00',
+ stopTime: '00:00:10',
+ },
+ subtitle: true,
+ intl,
+ })).toEqual(messages.custom);
+ });
+ });
+ });
+ });
+ describe('durationString', () => {
+ beforeEach(() => {
+ testMethod = hooks.durationString;
+ });
+ it('returns an object that maps durationStringFromValue to the passed duration keys', () => {
+ const testDuration = { startTime: 1000, stopTime: 2000, other: 'values' };
+ expect(testMethod(testDuration)).toEqual({
+ startTime: '00:00:01',
+ stopTime: '00:00:02',
+ });
+ });
+ });
+ describe('durationStringFromValue', () => {
+ beforeEach(() => {
+ testMethod = hooks.durationStringFromValue;
+ });
+ it('returns 00:00:00 if given a bad value', () => {
+ const badChecks = ['a', '', null, -1];
+ badChecks.forEach(val => expect(testMethod(val)).toEqual('00:00:00'));
+ });
+ it('translates milliseconds into hh:mm:ss format', () => {
+ durationPairs.forEach(
+ ([val, dur]) => expect(testMethod(val)).toEqual(dur),
+ );
+ });
+ });
+ describe('updateDuration', () => {
+ const testValidIndex = 'startTime';
+ const testStopIndex = 'stopTime';
+ const testValidDuration = '00:00:00';
+ const testValidValue = 0;
+ const testInvalidDuration = 'abc';
+ beforeEach(() => {
+ testMethod = hooks.updateDuration;
+ props = {
+ duration: { startTime: 23000, stopTime: 600000 },
+ unsavedDuration: { startTime: '00:00:23', stopTime: '00:10:00' },
+ setDuration: jest.fn(),
+ setUnsavedDuration: jest.fn(),
+ index: 'startTime',
+ inputString: '01:23:45',
+ };
+ });
+ describe('if the passed durationString is valid', () => {
+ it('sets the unsavedDuration to updated strings and duration to new timestamp value', () => {
+ testMethod({
+ ...props,
+ index: testValidIndex,
+ inputString: testValidDuration,
+ });
+ expect(props.setUnsavedDuration).toHaveBeenCalledWith({
+ ...props.unsavedDuration,
+ [testValidIndex]: testValidDuration,
+ });
+ expect(props.setDuration).toHaveBeenCalledWith({
+ ...props.duration,
+ [testValidIndex]: testValidValue,
+ });
+ });
+ });
+ describe('if the passed durationString is not valid', () => {
+ it('updates unsavedDuration values to 0 (the default)', () => {
+ testMethod({
+ ...props,
+ index: testValidIndex,
+ inputString: testInvalidDuration,
+ });
+ expect(props.setUnsavedDuration).toHaveBeenCalledWith({
+ ...props.unsavedDuration,
+ [testValidIndex]: testValidDuration,
+ });
+ expect(props.setDuration).toHaveBeenCalledWith({
+ ...props.duration,
+ [testValidIndex]: testValidValue,
+ });
+ });
+ });
+ describe('if the passed startTime is after (or equal to) the stored non-zero stopTime', () => {
+ it('updates unsavedDuration startTime values to 1 second before stopTime', () => {
+ testMethod({
+ ...props,
+ index: testValidIndex,
+ inputString: '00:10:00',
+ });
+ expect(props.setUnsavedDuration).toHaveBeenCalledWith({
+ ...props.unsavedDuration,
+ [testValidIndex]: '00:09:59',
+ });
+ expect(props.setDuration).toHaveBeenCalledWith({
+ ...props.duration,
+ [testValidIndex]: 599000,
+ });
+ });
+ });
+ describe('if the passed stopTime is before (or equal to) the stored startTime', () => {
+ it('updates unsavedDuration stopTime values to 1 second after startTime', () => {
+ testMethod({
+ ...props,
+ index: testStopIndex,
+ inputString: '00:00:22',
+ });
+ expect(props.setUnsavedDuration).toHaveBeenCalledWith({
+ ...props.unsavedDuration,
+ [testStopIndex]: '00:00:24',
+ });
+ expect(props.setDuration).toHaveBeenCalledWith({
+ ...props.duration,
+ [testStopIndex]: 24000,
+ });
+ });
+ });
+ describe('if the passed stopTime = startTime', () => {
+ it('sets the startTime value less than stopTime value', () => {
+ testMethod({
+ ...props,
+ duration: { startTime: 86399000, stopTime: 86399000 },
+ unsavedDuration: { startTime: '23:59:59', stopTime: '23:59:59' },
+ index: testStopIndex,
+ inputString: '23:59:59',
+ });
+ expect(props.setUnsavedDuration).toHaveBeenCalledWith({
+ startTime: '23:59:58',
+ stopTime: '23:59:59',
+ });
+ expect(props.setDuration).toHaveBeenCalledWith({
+ ...props.duration,
+ startTime: 86399000 - 1000,
+ stopTime: 86399000,
+ });
+ });
+ });
+ });
+ describe('onDurationChange', () => {
+ beforeEach(() => {
+ props = {
+ duration: { startTime: '00:00:00' },
+ index: 'startTime',
+ val: 'vAl',
+ };
+ testMethod = hooks.onDurationChange;
+ });
+ it('returns duration with no change if duration[index] does not match HH:MM:SS format', () => {
+ const badChecks = [
+ 'ab:cd:ef', // non-digit characters
+ '12:34:567', // characters past max length
+ ];
+ badChecks.forEach(val => expect(testMethod(props.duration, props.index, val)).toEqual(props.duration));
+ });
+ it('returns duration with an added \':\' after 2 characters when caret is at end', () => {
+ props.duration = { startTime: '0' };
+ props.val = '00';
+ document.activeElement.selectionStart = props.duration[props.index].length + 1;
+ expect(testMethod(props.duration, props.index, props.val)).toEqual({ startTime: '00:' });
+ });
+ it('returns duration with an added \':\' after 5 characters when caret is at end', () => {
+ props.duration = { startTime: '00:0' };
+ props.val = '00:00';
+ document.activeElement.selectionStart = props.duration[props.index].length + 1;
+ expect(testMethod(props.duration, props.index, props.val)).toEqual({ startTime: '00:00:' });
+ });
+ });
+ describe('onDurationKeyDown', () => {
+ beforeEach(() => {
+ props = {
+ duration: { startTime: '00:00:00' },
+ index: 'startTime',
+ event: 'eVeNt',
+ };
+ testMethod = hooks.onDurationKeyDown;
+ });
+ it('enter event: calls blur()', () => {
+ props.event = { key: 'Enter' };
+ const blurSpy = jest.spyOn(document.activeElement, 'blur');
+ testMethod(props.duration, props.index, props.event);
+ expect(blurSpy).toHaveBeenCalled();
+ });
+ it('backspace event: returns duration with deleted end character when that character is \':\' and caret is at end', () => {
+ props.duration = { startTime: '00:' };
+ props.event = { key: 'Backspace' };
+ document.activeElement.selectionStart = props.duration[props.index].length;
+ expect(testMethod(props.duration, props.index, props.event)).toEqual({ startTime: '00' });
+ });
+ });
+ describe('valueFromDuration', () => {
+ beforeEach(() => {
+ testMethod = hooks.valueFromDuration;
+ });
+ it('returns 0 if given a bad duration string', () => {
+ const badChecks = ['a', '00:00:1f', '0adg:00:04'];
+ badChecks.forEach(dur => expect(testMethod(dur)).toEqual(0));
+ });
+ it('returns simple durations', () => {
+ durationPairs.forEach(([val, dur]) => expect(testMethod(dur)).toEqual(val));
+ });
+ it('returns tricky durations, prepending zeros and expanding out sections', () => {
+ trickyDurations.forEach(([dur, val]) => expect(testMethod(dur)).toEqual(val));
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.jsx
new file mode 100644
index 0000000000..5975edf7ae
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.jsx
@@ -0,0 +1,104 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+
+import { Col, Form } from '@openedx/paragon';
+import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
+
+import { actions, selectors } from '../../../../../../data/redux';
+import { keyStore } from '../../../../../../utils';
+import CollapsibleFormWidget from '../CollapsibleFormWidget';
+import * as hooks from './hooks';
+import messages from '../messages';
+
+import './index.scss';
+
+/**
+ * Collapsible Form widget controlling video start and end times
+ * Also displays the total run time of the video.
+ */
+const DurationWidget = ({
+ // redux
+ duration,
+ updateField,
+ // injected
+ intl,
+}) => {
+ const {
+ unsavedDuration,
+ onBlur,
+ onChange,
+ onKeyDown,
+ getTotalLabel,
+ } = hooks.durationWidget({ duration, updateField });
+
+ const timeKeys = keyStore(duration);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {getTotalLabel({
+ durationString: duration,
+ subtitle: false,
+ intl,
+ })}
+
+
+
+ );
+};
+
+DurationWidget.propTypes = {
+ // redux
+ duration: PropTypes.objectOf(PropTypes.number).isRequired,
+ updateField: PropTypes.func.isRequired,
+ // injected
+ intl: intlShape.isRequired,
+};
+
+export const mapStateToProps = (state) => ({
+ duration: selectors.video.duration(state),
+});
+
+export const mapDispatchToProps = {
+ updateField: actions.video.updateField,
+};
+
+export const DurationWidgetInternal = DurationWidget; // For testing only
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(DurationWidget));
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.scss b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.scss
new file mode 100644
index 0000000000..ed9a26ebfc
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.scss
@@ -0,0 +1,3 @@
+.total-label {
+ border: 1px solid #707070;
+}
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.test.jsx
new file mode 100644
index 0000000000..4053431075
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.test.jsx
@@ -0,0 +1,52 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { actions, selectors } from '../../../../../../data/redux';
+import { formatMessage } from '../../../../../../testUtils';
+import { DurationWidgetInternal as DurationWidget, mapStateToProps, mapDispatchToProps } from '.';
+
+jest.mock('../../../../../../data/redux', () => ({
+ actions: {
+ video: {
+ updateField: jest.fn().mockName('actions.video.updateField'),
+ },
+ },
+ selectors: {
+ video: {
+ duration: jest.fn(state => ({ duration: state })),
+ },
+ },
+}));
+
+describe('DurationWidget', () => {
+ const props = {
+ duration: {
+ startTime: '00:00:00',
+ stopTime: '00:00:10',
+ },
+ updateField: jest.fn().mockName('updateField'),
+ // inject
+ intl: { formatMessage },
+ };
+ describe('render', () => {
+ test('snapshots: renders as expected with default props', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ });
+ describe('mapStateToProps', () => {
+ const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
+ test('duration from video.duration', () => {
+ expect(
+ mapStateToProps(testState).duration,
+ ).toEqual(selectors.video.duration(testState));
+ });
+ });
+ describe('mapDispatchToProps', () => {
+ test('updateField from actions.video.updateField', () => {
+ expect(mapDispatchToProps.updateField).toEqual(actions.video.updateField);
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..056123b55f
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,165 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`HandoutWidget snapshots snapshots: renders as expected with default props 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`HandoutWidget snapshots snapshots: renders as expected with handout 1`] = `
+
+
+
+
+
+
+
+
+ sOMeUrl
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`HandoutWidget snapshots snapshots: renders as expected with isLibrary true 1`] = `null`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/hooks.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/hooks.jsx
new file mode 100644
index 0000000000..5861715c49
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/hooks.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import { useDispatch } from 'react-redux';
+import { thunkActions } from '../../../../../../data/redux';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './hooks';
+
+export const state = {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ showSizeError: (args) => React.useState(args),
+};
+
+export const parseHandoutName = ({ handout }) => {
+ if (handout) {
+ const handoutName = handout.slice(handout.lastIndexOf('@') + 1);
+ return handoutName;
+ }
+ return 'None';
+};
+
+export const checkValidFileSize = ({
+ file,
+ onSizeFail,
+}) => {
+ // Check if the file size is greater than 20 MB, upload size limit
+ if (file.size > 20000000) {
+ onSizeFail();
+ return false;
+ }
+ return true;
+};
+
+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) => {
+ const file = e.target.files[0];
+ if (file && module.checkValidFileSize({
+ file,
+ onSizeFail: () => {
+ fileSizeError.set();
+ },
+ })) {
+ dispatch(thunkActions.video.uploadHandout({
+ file,
+ }));
+ }
+ };
+ return {
+ click,
+ addFile,
+ ref,
+ };
+};
+
+export const fileSizeError = () => {
+ const [showSizeError, setShowSizeError] = module.state.showSizeError(false);
+ return {
+ fileSizeError: {
+ show: showSizeError,
+ set: () => setShowSizeError(true),
+ dismiss: () => setShowSizeError(false),
+ },
+ };
+};
+
+export default { fileInput, fileSizeError, parseHandoutName };
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/hooks.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/hooks.test.jsx
new file mode 100644
index 0000000000..aead6fb96f
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/hooks.test.jsx
@@ -0,0 +1,106 @@
+import React from 'react';
+import { dispatch } from 'react-redux';
+import { thunkActions } from '../../../../../../data/redux';
+import { MockUseState } from '../../../../../../testUtils';
+import { keyStore } from '../../../../../../utils';
+import * as hooks from './hooks';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useRef: jest.fn(val => ({ current: val })),
+ useEffect: jest.fn(),
+ useCallback: (cb, prereqs) => ({ cb, prereqs }),
+}));
+
+jest.mock('react-redux', () => {
+ const dispatchFn = jest.fn();
+ return {
+ ...jest.requireActual('react-redux'),
+ dispatch: dispatchFn,
+ useDispatch: jest.fn(() => dispatchFn),
+ };
+});
+
+jest.mock('../../../../../../data/redux', () => ({
+ thunkActions: {
+ video: {
+ uploadHandout: jest.fn(),
+ },
+ },
+}));
+
+const state = new MockUseState(hooks);
+const hookKeys = keyStore(hooks);
+let hook;
+const testValue = '@testVALUEVALIDhANdoUT';
+const selectedFileSuccess = { name: testValue, size: 20000 };
+describe('VideoEditorHandout hooks', () => {
+ describe('state hooks', () => {
+ state.testGetter(state.keys.showSizeError);
+ });
+
+ describe('parseHandoutName', () => {
+ test('it returns none when given null', () => {
+ expect(hooks.parseHandoutName({ handout: null })).toEqual('None');
+ });
+ test('it creates a list based on transcript object', () => {
+ expect(hooks.parseHandoutName({ handout: testValue })).toEqual('testVALUEVALIDhANdoUT');
+ });
+ });
+
+ describe('checkValidSize', () => {
+ const onSizeFail = jest.fn();
+ it('returns false for file size', () => {
+ hook = hooks.checkValidFileSize({ file: { name: testValue, size: 20000001 }, onSizeFail });
+ expect(onSizeFail).toHaveBeenCalled();
+ expect(hook).toEqual(false);
+ });
+ it('returns true for valid file size', () => {
+ hook = hooks.checkValidFileSize({ file: selectedFileSuccess, onSizeFail });
+ expect(hook).toEqual(true);
+ });
+ });
+ describe('fileInput', () => {
+ const spies = {};
+ const fileSizeError = { set: jest.fn() };
+ beforeEach(() => {
+ jest.clearAllMocks();
+ hook = hooks.fileInput({ fileSizeError });
+ });
+ it('returns a ref for the file input', () => {
+ expect(hook.ref).toEqual({ current: undefined });
+ });
+ test('click calls current.click on the ref', () => {
+ const click = jest.fn();
+ React.useRef.mockReturnValueOnce({ current: { click } });
+ hook = hooks.fileInput({ fileSizeError });
+ hook.click();
+ expect(click).toHaveBeenCalled();
+ });
+ describe('addFile', () => {
+ const eventSuccess = { target: { files: [{ selectedFileSuccess }] } };
+ const eventFailure = { target: { files: [{ name: testValue, size: 20000001 }] } };
+ it('image fails to upload if file size is greater than 2000000', () => {
+ const checkValidFileSize = false;
+ spies.checkValidFileSize = jest.spyOn(hooks, hookKeys.checkValidFileSize)
+ .mockReturnValueOnce(checkValidFileSize);
+ hook.addFile(eventFailure);
+ expect(spies.checkValidFileSize.mock.calls.length).toEqual(1);
+ expect(spies.checkValidFileSize).toHaveReturnedWith(false);
+ });
+ it('dispatches updateField action with the first target file', () => {
+ const checkValidFileSize = true;
+ spies.checkValidFileSize = jest.spyOn(hooks, hookKeys.checkValidFileSize)
+ .mockReturnValueOnce(checkValidFileSize);
+ hook.addFile(eventSuccess);
+ expect(spies.checkValidFileSize.mock.calls.length).toEqual(1);
+ expect(spies.checkValidFileSize).toHaveReturnedWith(true);
+ expect(dispatch).toHaveBeenCalledWith(
+ thunkActions.video.uploadHandout({
+ thumbnail: eventSuccess.target.files[0],
+ }),
+ );
+ });
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.jsx
new file mode 100644
index 0000000000..799c06a0c1
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.jsx
@@ -0,0 +1,138 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+
+import {
+ Button,
+ Stack,
+ Icon,
+ IconButton,
+ Dropdown,
+ ActionRow,
+} from '@openedx/paragon';
+import { FileUpload, MoreHoriz } from '@openedx/paragon/icons';
+import {
+ FormattedMessage,
+ injectIntl,
+ intlShape,
+} from '@edx/frontend-platform/i18n';
+
+import { actions, selectors } from '../../../../../../data/redux';
+import * as hooks from './hooks';
+import messages from './messages';
+
+import { FileInput } from '../../../../../../sharedComponents/FileInput';
+import ErrorAlert from '../../../../../../sharedComponents/ErrorAlerts/ErrorAlert';
+import UploadErrorAlert from '../../../../../../sharedComponents/ErrorAlerts/UploadErrorAlert';
+import CollapsibleFormWidget from '../CollapsibleFormWidget';
+import { ErrorContext } from '../../../../hooks';
+import { RequestKeys } from '../../../../../../data/constants/requests';
+
+/**
+ * Collapsible Form widget controlling video handouts
+ */
+const HandoutWidget = ({
+ // injected
+ intl,
+ // redux
+ isLibrary,
+ handout,
+ getHandoutDownloadUrl,
+ updateField,
+ isUploadError,
+}) => {
+ const [error] = React.useContext(ErrorContext).handout;
+ const { fileSizeError } = hooks.fileSizeError();
+ const fileInput = hooks.fileInput({ fileSizeError });
+ const handoutName = hooks.parseHandoutName({ handout });
+ const downloadLink = getHandoutDownloadUrl({ handout });
+
+ return (!isLibrary ? (
+
+
+
+
+
+
+ {handout ? (
+
+
+ {handoutName}
+
+
+
+
+
+
+
+
+
+
+ updateField({ handout: null })}>
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+ )}
+
+ ) : null);
+};
+
+HandoutWidget.propTypes = {
+ // injected
+ intl: intlShape.isRequired,
+ // redux
+ isLibrary: PropTypes.bool.isRequired,
+ handout: PropTypes.shape({}).isRequired,
+ updateField: PropTypes.func.isRequired,
+ isUploadError: PropTypes.bool.isRequired,
+ getHandoutDownloadUrl: PropTypes.func.isRequired,
+};
+export const mapStateToProps = (state) => ({
+ isLibrary: selectors.app.isLibrary(state),
+ handout: selectors.video.handout(state),
+ getHandoutDownloadUrl: selectors.video.getHandoutDownloadUrl(state),
+ isUploadError: selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadAsset }),
+});
+
+export const mapDispatchToProps = (dispatch) => ({
+ updateField: (payload) => dispatch(actions.video.updateField(payload)),
+});
+
+export const HandoutWidgetInternal = HandoutWidget; // For testing only
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(HandoutWidget));
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.test.jsx
new file mode 100644
index 0000000000..8ecb7e613f
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.test.jsx
@@ -0,0 +1,81 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { formatMessage } from '../../../../../../testUtils';
+import { actions, selectors } from '../../../../../../data/redux';
+import { HandoutWidgetInternal as HandoutWidget, mapStateToProps, mapDispatchToProps } from '.';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useContext: jest.fn(() => ({ handout: ['error.handout', jest.fn().mockName('error.setHandout')] })),
+}));
+
+jest.mock('../../../../../../data/redux', () => ({
+ actions: {
+ video: {
+ updateField: jest.fn().mockName('actions.video.updateField'),
+ },
+ },
+ selectors: {
+ video: {
+ getHandoutDownloadUrl: jest.fn(args => ({ getHandoutDownloadUrl: args })).mockName('selectors.video.getHandoutDownloadUrl'),
+ handout: jest.fn(state => ({ handout: state })),
+ },
+ app: {
+ isLibrary: jest.fn(args => ({ isLibrary: args })),
+ },
+ requests: {
+ isFailed: jest.fn(args => ({ isFailed: args })),
+ },
+ },
+}));
+
+describe('HandoutWidget', () => {
+ const props = {
+ subtitle: 'SuBTItle',
+ title: 'tiTLE',
+ intl: { formatMessage },
+ isLibrary: false,
+ handout: '',
+ getHandoutDownloadUrl: jest.fn().mockName('args.getHandoutDownloadUrl'),
+ updateField: jest.fn().mockName('args.updateField'),
+ };
+
+ describe('snapshots', () => {
+ test('snapshots: renders as expected with default props', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with isLibrary true', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with handout', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ });
+ describe('mapStateToProps', () => {
+ const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
+ test('isLibrary from app.isLibrary', () => {
+ expect(
+ mapStateToProps(testState).isLibrary,
+ ).toEqual(selectors.app.isLibrary(testState));
+ });
+ test('getHandoutDownloadUrl from video.getHandoutDownloadUrl', () => {
+ expect(
+ mapStateToProps(testState).getHandoutDownloadUrl,
+ ).toEqual(selectors.video.getHandoutDownloadUrl(testState));
+ });
+ });
+ describe('mapDispatchToProps', () => {
+ const dispatch = jest.fn();
+ test('updateField from actions.video.updateField', () => {
+ expect(mapDispatchToProps.updateField).toEqual(dispatch(actions.video.updateField));
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/messages.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/messages.js
new file mode 100644
index 0000000000..4e7114ef70
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/messages.js
@@ -0,0 +1,53 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+
+ titleLabel: {
+ id: 'authoring.videoeditor.handout.title.label',
+ defaultMessage: 'Handout',
+ description: 'Title for the handout widget',
+ },
+ uploadButtonLabel: {
+ id: 'authoring.videoeditor.handout.upload.label',
+ defaultMessage: 'Upload Handout',
+ description: 'Label for upload button',
+ },
+ addHandoutMessage: {
+ id: 'authoring.videoeditor.handout.upload.addHandoutMessage',
+ defaultMessage: `Add a handout to accompany this video. Learners can download
+ this file by clicking "Download Handout" below the video.`,
+ description: 'Message displayed when uploading a handout',
+ },
+ uploadHandoutError: {
+ id: 'authoring.videoeditor.handout.error.uploadHandoutError',
+ defaultMessage: 'Failed to upload handout. Please try again.',
+ description: 'Message presented to user when handout fails to upload',
+ },
+ fileSizeError: {
+ id: 'authoring.videoeditor.handout.error.fileSizeError',
+ defaultMessage: 'Handout files must be 20 MB or less. Please resize the file and try again.',
+ description: 'Message presented to user when handout file size is larger than 20 MB',
+ },
+ handoutHelpMessage: {
+ id: 'authoring.videoeditor.handout.handoutHelpMessage',
+ defaultMessage: 'Learners can download this file by clicking "Download Handout" below the video.',
+ description: 'Message presented to user when a handout is present',
+ },
+ deleteHandout: {
+ id: 'authoring.videoeditor.handout.deleteHandout',
+ defaultMessage: 'Delete',
+ description: 'Message Presented To user for action to delete handout',
+ },
+ replaceHandout: {
+ id: 'authoring.videoeditor.handout.replaceHandout',
+ defaultMessage: 'Replace',
+ description: 'Message Presented To user for action to replace handout',
+ },
+ downloadHandout: {
+ id: 'authoring.videoeditor.handout.downloadHandout',
+ defaultMessage: 'Download',
+ description: 'Message Presented To user for action to download handout',
+ },
+});
+
+export default messages;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseBlurb.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseBlurb.jsx
new file mode 100644
index 0000000000..236d496311
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseBlurb.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+ FormattedMessage,
+ injectIntl,
+} from '@edx/frontend-platform/i18n';
+import { Icon } from '@openedx/paragon';
+import {
+ Attribution,
+ Copyright,
+ Cc,
+ Nd,
+ Nc,
+ Sa,
+} from '@openedx/paragon/icons';
+
+import messages from './messages';
+import { LicenseTypes } from '../../../../../../data/constants/licenses';
+
+const LicenseBlurb = ({
+ license,
+ details,
+}) => (
+
+ {/* not sure how to handle the edge cases when some of the icons are not displayed */}
+ {license === LicenseTypes.allRightsReserved ?
: null}
+ {license === LicenseTypes.creativeCommons ?
: null}
+ {details.attribution ?
: null}
+ {details.noncommercial ?
: null}
+ {details.noDerivatives ?
: null}
+ {details.shareAlike ?
: null}
+ {license === LicenseTypes.allRightsReserved
+ ?
+ : null}
+ {license === LicenseTypes.creativeCommons
+ ?
+ : null}
+
+);
+
+LicenseBlurb.propTypes = {
+ license: PropTypes.string.isRequired,
+ details: PropTypes.shape({
+ attribution: PropTypes.bool.isRequired,
+ noncommercial: PropTypes.bool.isRequired,
+ noDerivatives: PropTypes.bool.isRequired,
+ shareAlike: PropTypes.bool.isRequired,
+ }).isRequired,
+};
+
+export const LicenseBlurbInternal = LicenseBlurb; // For testing only
+export default injectIntl(LicenseBlurb);
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseBlurb.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseBlurb.test.jsx
new file mode 100644
index 0000000000..f55458d859
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseBlurb.test.jsx
@@ -0,0 +1,45 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { LicenseBlurbInternal as LicenseBlurb } from './LicenseBlurb';
+
+describe('LicenseBlurb', () => {
+ const props = {
+ license: 'all-rights-reserved',
+ details: {},
+ };
+
+ describe('snapshots', () => {
+ test('snapshots: renders as expected with default props', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with license equal to Creative Commons', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected when details.attribution equal true', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected when details.attribution and details.noncommercial equal true', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected when details.attribution and details.noDerivatives equal true', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected when details.attribution and details.shareAlike equal true', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseDetails.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseDetails.jsx
new file mode 100644
index 0000000000..375c287b02
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseDetails.jsx
@@ -0,0 +1,170 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import {
+ FormattedMessage,
+ injectIntl,
+} from '@edx/frontend-platform/i18n';
+import {
+ ActionRow,
+ CheckboxControl,
+ Form,
+ Icon,
+ Stack,
+} from '@openedx/paragon';
+import {
+ Attribution,
+ Nd,
+ Sa,
+ Nc,
+} from '@openedx/paragon/icons';
+
+import { actions } from '../../../../../../data/redux';
+import { LicenseLevel, LicenseTypes } from '../../../../../../data/constants/licenses';
+import messages from './messages';
+
+const LicenseDetails = ({
+ license,
+ details,
+ level,
+ // redux
+ updateField,
+}) => (
+ level !== LicenseLevel.course && details && license !== 'select' ? (
+
+
+
+
+
+ {license === LicenseTypes.allRightsReserved ? (
+
+
+
+ ) : null}
+ {license === LicenseTypes.creativeCommons
+ ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ updateField({
+ licenseDetails: {
+ ...details,
+ noncommercial: e.target.checked,
+ },
+ })}
+ aria-label="Checkbox"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ updateField({
+ licenseDetails: {
+ ...details,
+ noDerivatives: e.target.checked,
+ shareAlike: e.target.checked ? false : details.shareAlike,
+ },
+ })}
+ aria-label="Checkbox"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ updateField({
+ licenseDetails: {
+ ...details,
+ shareAlike: e.target.checked,
+ noDerivatives: e.target.checked ? false : details.noDerivatives,
+ },
+ })}
+ aria-label="Checkbox"
+ />
+
+
+
+
+
+
+
+ ) : null}
+
+
+ ) : null
+);
+
+LicenseDetails.propTypes = {
+ license: PropTypes.string.isRequired,
+ details: PropTypes.shape({
+ attribution: PropTypes.bool.isRequired,
+ noncommercial: PropTypes.bool.isRequired,
+ noDerivatives: PropTypes.bool.isRequired,
+ shareAlike: PropTypes.bool.isRequired,
+ }).isRequired,
+ level: PropTypes.string.isRequired,
+ // redux
+ updateField: PropTypes.func.isRequired,
+};
+
+export const mapStateToProps = () => ({});
+
+export const mapDispatchToProps = (dispatch) => ({
+ updateField: (stateUpdate) => dispatch(actions.video.updateField(stateUpdate)),
+});
+
+export const LicenseDetailsInternal = LicenseDetails; // For testing only
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(LicenseDetails));
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseDetails.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseDetails.test.jsx
new file mode 100644
index 0000000000..48e4abdc38
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseDetails.test.jsx
@@ -0,0 +1,72 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { actions } from '../../../../../../data/redux';
+import { LicenseDetailsInternal as LicenseDetails, mapStateToProps, mapDispatchToProps } from './LicenseDetails';
+
+jest.mock('react', () => {
+ const updateState = jest.fn();
+ return {
+ ...jest.requireActual('react'),
+ updateState,
+ useContext: jest.fn(() => ({ license: ['error.license', jest.fn().mockName('error.setLicense')] })),
+ };
+});
+
+jest.mock('../../../../../../data/redux', () => ({
+ actions: {
+ video: {
+ updateField: jest.fn().mockName('actions.video.updateField'),
+ },
+ },
+}));
+
+describe('LicenseDetails', () => {
+ const props = {
+ license: null,
+ details: {},
+ level: 'course',
+ updateField: jest.fn().mockName('args.updateField'),
+ };
+
+ describe('snapshots', () => {
+ test('snapshots: renders as expected with default props', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with level set to library', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with level set to block and license set to select', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with level set to block and license set to all rights reserved', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with level set to block and license set to Creative Commons', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ });
+ describe('mapStateToProps', () => {
+ const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
+ test('mapStateToProps equals an empty object', () => {
+ expect(mapStateToProps(testState)).toEqual({});
+ });
+ });
+ describe('mapDispatchToProps', () => {
+ const dispatch = jest.fn();
+ test('updateField from actions.video.updateField', () => {
+ expect(mapDispatchToProps.updateField).toEqual(dispatch(actions.video.updateField));
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseDisplay.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseDisplay.jsx
new file mode 100644
index 0000000000..a67d406079
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseDisplay.jsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import {
+ FormattedMessage,
+ injectIntl,
+} from '@edx/frontend-platform/i18n';
+import {
+ Stack,
+ Hyperlink,
+} from '@openedx/paragon';
+
+import { LicenseTypes } from '../../../../../../data/constants/licenses';
+
+import LicenseBlurb from './LicenseBlurb';
+import messages from './messages';
+
+const LicenseDisplay = ({
+ license,
+ details,
+ licenseDescription,
+}) => {
+ if (license !== LicenseTypes.select) {
+ return (
+
+
+
+
+
{licenseDescription}
+
+ {license === LicenseTypes.creativeCommons && (
+
+
+
+ )}
+
+ );
+ }
+ return null;
+};
+
+LicenseDisplay.propTypes = {
+ license: PropTypes.string.isRequired,
+ details: PropTypes.shape({
+ attribution: PropTypes.bool.isRequired,
+ noncommercial: PropTypes.bool.isRequired,
+ noDerivatives: PropTypes.bool.isRequired,
+ shareAlike: PropTypes.bool.isRequired,
+ }).isRequired,
+ level: PropTypes.string.isRequired,
+ licenseDescription: PropTypes.string.isRequired,
+};
+
+export const LicenseDisplayInternal = LicenseDisplay; // For testing only
+export default injectIntl(LicenseDisplay);
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseDisplay.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseDisplay.test.jsx
new file mode 100644
index 0000000000..1111694998
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseDisplay.test.jsx
@@ -0,0 +1,47 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { LicenseDisplayInternal as LicenseDisplay } from './LicenseDisplay';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useContext: jest.fn(() => ({ license: ['error.license', jest.fn().mockName('error.setLicense')] })),
+}));
+
+describe('LicenseDisplay', () => {
+ const props = {
+ license: 'all-rights-reserved',
+ details: {},
+ licenseDescription: 'FormattedMessage component with license description',
+ level: 'course',
+ };
+
+ describe('snapshots', () => {
+ test('snapshots: renders as expected with default props', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with level set to library', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with level set to block', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with level set to block and license set to select', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with level set to block and license set to Creative Commons', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseSelector.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseSelector.jsx
new file mode 100644
index 0000000000..7e468982b9
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseSelector.jsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import { connect, useDispatch } from 'react-redux';
+import PropTypes from 'prop-types';
+import {
+ FormattedMessage,
+ injectIntl,
+ intlShape,
+} from '@edx/frontend-platform/i18n';
+import {
+ ActionRow,
+ Form,
+ Icon,
+ IconButtonWithTooltip,
+} from '@openedx/paragon';
+import { DeleteOutline } from '@openedx/paragon/icons';
+
+import { actions, selectors } from '../../../../../../data/redux';
+import * as hooks from './hooks';
+import messages from './messages';
+import { LicenseLevel, LicenseNames, LicenseTypes } from '../../../../../../data/constants/licenses';
+
+const LicenseSelector = ({
+ license,
+ level,
+ // injected
+ intl,
+ // redux
+ courseLicenseType,
+ updateField,
+}) => {
+ const { levelDescription } = hooks.determineText({ level });
+ const onLicenseChange = hooks.onSelectLicense({ dispatch: useDispatch() });
+ const ref = React.useRef();
+ return (
+ <>
+
+ onLicenseChange(e.target.value)}
+ >
+ {Object.entries(LicenseNames).map(([key, text]) => {
+ if (license === key) { return ({text} ); }
+ if (key === LicenseTypes.select) { return ({text} ); }
+ return ({text} );
+ })}
+
+ {level !== LicenseLevel.course ? (
+ <>
+
+ {
+ ref.current.value = courseLicenseType;
+ updateField({ licenseType: '', licenseDetails: {} });
+ }}
+ tooltipPlacement="top"
+ tooltipContent={ }
+ />
+ >
+ ) : null }
+
+ {levelDescription}
+ {license === LicenseTypes.select ? null :
}
+ >
+ );
+};
+
+LicenseSelector.propTypes = {
+ license: PropTypes.string.isRequired,
+ level: PropTypes.string.isRequired,
+ // injected
+ intl: intlShape.isRequired,
+ // redux
+ courseLicenseType: PropTypes.string.isRequired,
+ updateField: PropTypes.func.isRequired,
+};
+
+export const mapStateToProps = (state) => ({
+ courseLicenseType: selectors.video.courseLicenseType(state),
+});
+
+export const mapDispatchToProps = (dispatch) => ({
+ updateField: (stateUpdate) => dispatch(actions.video.updateField(stateUpdate)),
+});
+
+export const LicenseSelectorInternal = LicenseSelector; // For testing only
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(LicenseSelector));
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseSelector.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseSelector.test.jsx
new file mode 100644
index 0000000000..2b6ee9a0ed
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/LicenseSelector.test.jsx
@@ -0,0 +1,84 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { formatMessage } from '../../../../../../testUtils';
+import { actions, selectors } from '../../../../../../data/redux';
+import { LicenseSelectorInternal as LicenseSelector, mapStateToProps, mapDispatchToProps } from './LicenseSelector';
+
+jest.mock('react', () => {
+ const updateState = jest.fn();
+ return {
+ ...jest.requireActual('react'),
+ updateState,
+ useContext: jest.fn(() => ({ license: ['error.license', jest.fn().mockName('error.setLicense')] })),
+ };
+});
+
+jest.mock('react-redux', () => {
+ const dispatchFn = jest.fn();
+ return {
+ ...jest.requireActual('react-redux'),
+ dispatch: dispatchFn,
+ useDispatch: jest.fn(() => dispatchFn),
+ };
+});
+
+jest.mock('../../../../../../data/redux', () => ({
+ actions: {
+ video: {
+ updateField: jest.fn().mockName('actions.video.updateField'),
+ },
+ },
+ selectors: {
+ video: {
+ courseLicenseType: jest.fn(state => ({ courseLicenseType: state })),
+ },
+ },
+}));
+
+describe('LicenseSelector', () => {
+ const props = {
+ intl: { formatMessage },
+ license: 'all-rights-reserved',
+ level: 'course',
+ courseLicenseType: 'all-rights-reserved',
+ updateField: jest.fn().mockName('args.updateField'),
+ };
+ describe('snapshots', () => {
+ test('snapshots: renders as expected with default props', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with library level', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with block level', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with no license', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ });
+ describe('mapStateToProps', () => {
+ const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
+ test('courseLicenseType from video.courseLicenseType', () => {
+ expect(
+ mapStateToProps(testState).courseLicenseType,
+ ).toEqual(selectors.video.courseLicenseType(testState));
+ });
+ });
+ describe('mapDispatchToProps', () => {
+ const dispatch = jest.fn();
+ test('updateField from actions.video.updateField', () => {
+ expect(mapDispatchToProps.updateField).toEqual(dispatch(actions.video.updateField));
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/__snapshots__/LicenseBlurb.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/__snapshots__/LicenseBlurb.test.jsx.snap
new file mode 100644
index 0000000000..03a8277aff
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/__snapshots__/LicenseBlurb.test.jsx.snap
@@ -0,0 +1,214 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`LicenseBlurb snapshots snapshots: renders as expected when details.attribution and details.noDerivatives equal true 1`] = `
+
+`;
+
+exports[`LicenseBlurb snapshots snapshots: renders as expected when details.attribution and details.noncommercial equal true 1`] = `
+
+`;
+
+exports[`LicenseBlurb snapshots snapshots: renders as expected when details.attribution and details.shareAlike equal true 1`] = `
+
+`;
+
+exports[`LicenseBlurb snapshots snapshots: renders as expected when details.attribution equal true 1`] = `
+
+`;
+
+exports[`LicenseBlurb snapshots snapshots: renders as expected with default props 1`] = `
+
+`;
+
+exports[`LicenseBlurb snapshots snapshots: renders as expected with license equal to Creative Commons 1`] = `
+
+`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/__snapshots__/LicenseDetails.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/__snapshots__/LicenseDetails.test.jsx.snap
new file mode 100644
index 0000000000..046cc017d3
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/__snapshots__/LicenseDetails.test.jsx.snap
@@ -0,0 +1,202 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`LicenseDetails snapshots snapshots: renders as expected with default props 1`] = `null`;
+
+exports[`LicenseDetails snapshots snapshots: renders as expected with level set to block and license set to Creative Commons 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`LicenseDetails snapshots snapshots: renders as expected with level set to block and license set to all rights reserved 1`] = `
+
+`;
+
+exports[`LicenseDetails snapshots snapshots: renders as expected with level set to block and license set to select 1`] = `null`;
+
+exports[`LicenseDetails snapshots snapshots: renders as expected with level set to library 1`] = `
+
+`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/__snapshots__/LicenseDisplay.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/__snapshots__/LicenseDisplay.test.jsx.snap
new file mode 100644
index 0000000000..655802d54c
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/__snapshots__/LicenseDisplay.test.jsx.snap
@@ -0,0 +1,130 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`LicenseDisplay snapshots snapshots: renders as expected with default props 1`] = `
+
+
+
+
+
+
+
+ FormattedMessage component with license description
+
+
+
+`;
+
+exports[`LicenseDisplay snapshots snapshots: renders as expected with level set to block 1`] = `
+
+
+
+
+
+
+
+ FormattedMessage component with license description
+
+
+
+`;
+
+exports[`LicenseDisplay snapshots snapshots: renders as expected with level set to block and license set to Creative Commons 1`] = `
+
+
+
+
+
+
+
+ FormattedMessage component with license description
+
+
+
+
+
+
+`;
+
+exports[`LicenseDisplay snapshots snapshots: renders as expected with level set to block and license set to select 1`] = `null`;
+
+exports[`LicenseDisplay snapshots snapshots: renders as expected with level set to library 1`] = `
+
+
+
+
+
+
+
+ FormattedMessage component with license description
+
+
+
+`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/__snapshots__/LicenseSelector.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/__snapshots__/LicenseSelector.test.jsx.snap
new file mode 100644
index 0000000000..558ffc81da
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/__snapshots__/LicenseSelector.test.jsx.snap
@@ -0,0 +1,203 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`LicenseSelector snapshots snapshots: renders as expected with block level 1`] = `
+
+
+
+
+ Select
+
+
+ All Rights Reserved
+
+
+ Creative Commons
+
+
+
+
+
+ }
+ tooltipPlacement="top"
+ />
+
+
+
+
+
+
+
+`;
+
+exports[`LicenseSelector snapshots snapshots: renders as expected with default props 1`] = `
+
+
+
+
+ Select
+
+
+ All Rights Reserved
+
+
+ Creative Commons
+
+
+
+
+
+
+
+
+`;
+
+exports[`LicenseSelector snapshots snapshots: renders as expected with library level 1`] = `
+
+
+
+
+ Select
+
+
+ All Rights Reserved
+
+
+ Creative Commons
+
+
+
+
+
+ }
+ tooltipPlacement="top"
+ />
+
+
+
+
+
+
+
+`;
+
+exports[`LicenseSelector snapshots snapshots: renders as expected with no license 1`] = `
+
+
+
+
+ Select
+
+
+ All Rights Reserved
+
+
+ Creative Commons
+
+
+
+
+
+
+
+
+`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..cfc25e06ae
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,168 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`LicenseWidget snapshots snapshots: renders as expected with default props 1`] = `
+
+
+
+
+
+
+ }
+ title="License"
+>
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`LicenseWidget snapshots snapshots: renders as expected with isLibrary true 1`] = `
+
+
+
+
+
+
+ }
+ title="License"
+>
+
+
+
+
+
+ }
+ />
+
+
+
+`;
+
+exports[`LicenseWidget snapshots snapshots: renders as expected with licenseType defined 1`] = `
+
+
+
+
+
+
+ }
+ title="License"
+>
+
+
+
+
+
+ }
+ />
+
+
+
+`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/hooks.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/hooks.jsx
new file mode 100644
index 0000000000..87f64fd543
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/hooks.jsx
@@ -0,0 +1,84 @@
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import messages from './messages';
+import { actions } from '../../../../../../data/redux';
+import { LicenseLevel, LicenseTypes } from '../../../../../../data/constants/licenses';
+
+export const determineLicense = ({
+ isLibrary,
+ licenseType,
+ licenseDetails,
+ courseLicenseType,
+ courseLicenseDetails,
+}) => {
+ let level = LicenseLevel.course;
+ if (licenseType) {
+ if (isLibrary) {
+ level = LicenseLevel.library;
+ } else {
+ level = LicenseLevel.block;
+ }
+ }
+
+ return {
+ license: licenseType || courseLicenseType,
+ details: licenseType ? licenseDetails : courseLicenseDetails,
+ level,
+ };
+};
+
+export const determineText = ({ level }) => {
+ let levelDescription = '';
+ let licenseDescription = '';
+ switch (level) {
+ case LicenseLevel.course:
+ levelDescription = ;
+ licenseDescription = ;
+ break;
+ case LicenseLevel.library:
+ levelDescription = ;
+ licenseDescription = ;
+ break;
+ default: // default to block
+ levelDescription = ;
+ licenseDescription = ;
+ break;
+ }
+
+ return {
+ levelDescription,
+ licenseDescription,
+ };
+};
+
+export const onSelectLicense = ({
+ dispatch,
+}) => (license) => {
+ switch (license) {
+ case LicenseTypes.allRightsReserved:
+ dispatch(actions.video.updateField({
+ licenseType: LicenseTypes.allRightsReserved,
+ licenseDetails: {},
+ }));
+ break;
+ case LicenseTypes.creativeCommons:
+ dispatch(actions.video.updateField({
+ licenseType: LicenseTypes.creativeCommons,
+ licenseDetails: {
+ attribution: true,
+ noncommercial: true,
+ noDerivatives: true,
+ shareAlike: false,
+ },
+ }));
+ break;
+ default:
+ dispatch(actions.video.updateField({ licenseType: LicenseTypes.select }));
+ break;
+ }
+};
+
+export default {
+ determineLicense,
+ determineText,
+ onSelectLicense,
+};
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/hooks.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/hooks.test.jsx
new file mode 100644
index 0000000000..6da244c8f4
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/hooks.test.jsx
@@ -0,0 +1,135 @@
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import { actions } from '../../../../../../data/redux';
+import { LicenseTypes } from '../../../../../../data/constants/licenses';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './hooks';
+import messages from './messages';
+
+jest.mock('../../../../../../data/redux', () => ({
+ actions: {
+ video: {
+ updateField: jest.fn(args => ({ updateField: args })).mockName('actions.video.updateField'),
+ },
+ },
+}));
+
+describe('VideoEditorTranscript hooks', () => {
+ describe('determineLicense', () => {
+ const courseLicenseProps = {
+ isLibrary: false,
+ licenseType: '',
+ licenseDetails: {},
+ courseLicenseType: 'sOMEliCENse',
+ courseLicenseDetails: {},
+ };
+ const libraryLicenseProps = {
+ isLibrary: true,
+ licenseType: 'sOMEliCENse',
+ licenseDetails: {},
+ courseLicenseType: '',
+ courseLicenseDetails: {},
+ };
+ const blockLicenseProps = {
+ isLibrary: false,
+ licenseType: 'sOMEliCENse',
+ licenseDetails: {},
+ courseLicenseType: '',
+ courseLicenseDetails: {},
+ };
+ it('returns expected license, details and level for course set license', () => {
+ expect(module.determineLicense(courseLicenseProps)).toEqual({
+ license: 'sOMEliCENse',
+ details: {},
+ level: 'course',
+ });
+ });
+ it('returns expected license, details and level for library set license', () => {
+ expect(module.determineLicense(libraryLicenseProps)).toEqual({
+ license: 'sOMEliCENse',
+ details: {},
+ level: 'library',
+ });
+ });
+ it('returns expected license, details and level for block set license', () => {
+ expect(module.determineLicense(blockLicenseProps)).toEqual({
+ license: 'sOMEliCENse',
+ details: {},
+ level: 'block',
+ });
+ });
+ });
+ describe('determineText', () => {
+ it('returns expected level and license description for course level', () => {
+ expect(module.determineText({ level: 'course' })).toEqual({
+ levelDescription: ,
+ licenseDescription: ,
+ });
+ });
+ it('returns expected level and license description for library level', () => {
+ expect(module.determineText({ level: 'library' })).toEqual({
+ levelDescription: ,
+ licenseDescription: ,
+ });
+ });
+ it('returns expected level and license description for library level', () => {
+ expect(module.determineText({ level: 'default' })).toEqual({
+ levelDescription: ,
+ licenseDescription: ,
+ });
+ });
+ });
+ describe('onSelectLicense', () => {
+ // const mockEvent = { target: { value: mockLangValue } };
+ const mockDispatch = jest.fn();
+ test('it dispatches the correct thunk for all rights reserved', () => {
+ const mockLicenseValue = 'all-rights-reserved';
+ const callBack = module.onSelectLicense({ dispatch: mockDispatch });
+ callBack(mockLicenseValue);
+ expect(actions.video.updateField).toHaveBeenCalledWith({
+ licenseType: LicenseTypes.allRightsReserved,
+ licenseDetails: {},
+ });
+ expect(mockDispatch).toHaveBeenCalledWith({
+ updateField: {
+ licenseType: LicenseTypes.allRightsReserved,
+ licenseDetails: {},
+ },
+ });
+ });
+ test('it dispatches the correct thunk for creative commons', () => {
+ const mockLicenseValue = 'creative-commons';
+ const callBack = module.onSelectLicense({ dispatch: mockDispatch });
+ callBack(mockLicenseValue);
+ expect(actions.video.updateField).toHaveBeenCalledWith({
+ licenseType: LicenseTypes.creativeCommons,
+ licenseDetails: {
+ attribution: true,
+ noncommercial: true,
+ noDerivatives: true,
+ shareAlike: false,
+ },
+ });
+ expect(mockDispatch).toHaveBeenCalledWith({
+ updateField: {
+ licenseType: LicenseTypes.creativeCommons,
+ licenseDetails: {
+ attribution: true,
+ noncommercial: true,
+ noDerivatives: true,
+ shareAlike: false,
+ },
+ },
+ });
+ });
+ test('it dispatches the correct thunk for no license type', () => {
+ const mockLicenseValue = 'sOMEliCENse';
+ const callBack = module.onSelectLicense({ dispatch: mockDispatch });
+ callBack(mockLicenseValue);
+ expect(actions.video.updateField).toHaveBeenCalledWith({ licenseType: LicenseTypes.select });
+ expect(mockDispatch).toHaveBeenCalledWith({ updateField: { licenseType: LicenseTypes.select } });
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/index.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/index.jsx
new file mode 100644
index 0000000000..5591abbdf4
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/index.jsx
@@ -0,0 +1,112 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import {
+ FormattedMessage,
+ injectIntl,
+ intlShape,
+} from '@edx/frontend-platform/i18n';
+import {
+ Button,
+ Stack,
+} from '@openedx/paragon';
+import { Add } from '@openedx/paragon/icons';
+
+import { actions, selectors } from '../../../../../../data/redux';
+import * as hooks from './hooks';
+import messages from './messages';
+import CollapsibleFormWidget from '../CollapsibleFormWidget';
+import LicenseBlurb from './LicenseBlurb';
+import LicenseSelector from './LicenseSelector';
+import LicenseDetails from './LicenseDetails';
+import LicenseDisplay from './LicenseDisplay';
+
+/**
+ * Collapsible Form widget controlling video license type and details
+ */
+const LicenseWidget = ({
+ // injected
+ intl,
+ // redux
+ isLibrary,
+ licenseType,
+ licenseDetails,
+ courseLicenseType,
+ courseLicenseDetails,
+ updateField,
+}) => {
+ const { license, details, level } = hooks.determineLicense({
+ isLibrary,
+ licenseType,
+ licenseDetails,
+ courseLicenseType,
+ courseLicenseDetails,
+ });
+ const { licenseDescription, levelDescription } = hooks.determineText({ level });
+ return (
+
+
+ {levelDescription}
+
+ )}
+ title={intl.formatMessage(messages.title)}
+ >
+
+ {license ? (
+ <>
+
+
+
+ >
+ ) : null }
+ {!licenseType ? (
+ <>
+
+ updateField({ licenseType: 'select', licenseDetails: {} })}
+ >
+
+
+ >
+ ) : null }
+
+
+ );
+};
+
+LicenseWidget.propTypes = {
+ // injected
+ intl: intlShape.isRequired,
+ // redux
+ isLibrary: PropTypes.bool.isRequired,
+ licenseType: PropTypes.string.isRequired,
+ licenseDetails: PropTypes.shape({}).isRequired,
+ courseLicenseType: PropTypes.string.isRequired,
+ courseLicenseDetails: PropTypes.shape({}).isRequired,
+ updateField: PropTypes.func.isRequired,
+};
+
+export const mapStateToProps = (state) => ({
+ isLibrary: selectors.app.isLibrary(state),
+ licenseType: selectors.video.licenseType(state),
+ licenseDetails: selectors.video.licenseDetails(state),
+ courseLicenseType: selectors.video.courseLicenseType(state),
+ courseLicenseDetails: selectors.video.courseLicenseDetails(state),
+});
+
+export const mapDispatchToProps = (dispatch) => ({
+ updateField: (stateUpdate) => dispatch(actions.video.updateField(stateUpdate)),
+});
+
+export const LicenseWidgetInternal = LicenseWidget; // For testing only
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(LicenseWidget));
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/index.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/index.test.jsx
new file mode 100644
index 0000000000..e7f1836fcc
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/index.test.jsx
@@ -0,0 +1,112 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { formatMessage } from '../../../../../../testUtils';
+import { actions, selectors } from '../../../../../../data/redux';
+import { LicenseWidgetInternal as LicenseWidget, mapStateToProps, mapDispatchToProps } from '.';
+
+jest.mock('react', () => {
+ const updateState = jest.fn();
+ return {
+ ...jest.requireActual('react'),
+ updateState,
+ useContext: jest.fn(() => ({ license: ['error.license', jest.fn().mockName('error.setLicense')] })),
+ };
+});
+
+jest.mock('../../../../../../data/redux', () => ({
+ actions: {
+ video: {
+ updateField: jest.fn().mockName('actions.video.updateField'),
+ },
+ },
+ selectors: {
+ app: {
+ isLibrary: jest.fn(state => ({ isLibrary: state })),
+ },
+ video: {
+ licenseType: jest.fn(state => ({ licenseType: state })),
+ licenseDetails: jest.fn(state => ({ licenseDetails: state })),
+ courseLicenseType: jest.fn(state => ({ courseLicenseType: state })),
+ courseLicenseDetails: jest.fn(state => ({ courseLicenseDetails: state })),
+ },
+ },
+}));
+
+describe('LicenseWidget', () => {
+ const props = {
+ error: {},
+ subtitle: 'SuBTItle',
+ title: 'tiTLE',
+ intl: { formatMessage },
+ isLibrary: false,
+ licenseType: null,
+ licenseDetails: {},
+ courseLicenseType: 'all-rights-reserved',
+ courseLicenseDetails: {},
+ updateField: jest.fn().mockName('args.updateField'),
+ };
+
+ describe('snapshots', () => {
+ // determineLicense.mockReturnValue({
+ // license: false,
+ // details: jest.fn().mockName('modal.openModal'),
+ // level: 'course',
+ // });
+ // determineText.mockReturnValue({
+ // isSourceCodeOpen: false,
+ // openSourceCodeModal: jest.fn().mockName('modal.openModal'),
+ // closeSourceCodeModal: jest.fn().mockName('modal.closeModal'),
+ // });
+ test('snapshots: renders as expected with default props', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with isLibrary true', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with licenseType defined', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ });
+ describe('mapStateToProps', () => {
+ const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
+ test('isLibrary from app.isLibrary', () => {
+ expect(
+ mapStateToProps(testState).isLibrary,
+ ).toEqual(selectors.app.isLibrary(testState));
+ });
+ test('licenseType from video.licenseType', () => {
+ expect(
+ mapStateToProps(testState).licenseType,
+ ).toEqual(selectors.video.licenseType(testState));
+ });
+ test('licenseDetails from video.licenseDetails', () => {
+ expect(
+ mapStateToProps(testState).licenseDetails,
+ ).toEqual(selectors.video.licenseDetails(testState));
+ });
+ test('courseLicenseType from video.courseLicenseType', () => {
+ expect(
+ mapStateToProps(testState).courseLicenseType,
+ ).toEqual(selectors.video.courseLicenseType(testState));
+ });
+ test('courseLicenseDetails from video.courseLicenseDetails', () => {
+ expect(
+ mapStateToProps(testState).courseLicenseDetails,
+ ).toEqual(selectors.video.courseLicenseDetails(testState));
+ });
+ });
+ describe('mapDispatchToProps', () => {
+ const dispatch = jest.fn();
+ test('updateField from actions.video.updateField', () => {
+ expect(mapDispatchToProps.updateField).toEqual(dispatch(actions.video.updateField));
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/messages.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/messages.js
new file mode 100644
index 0000000000..9c388caa27
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget/messages.js
@@ -0,0 +1,127 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+
+ title: {
+ id: 'authoring.videoeditor.license.title',
+ defaultMessage: 'License',
+ description: 'Title for license widget',
+ },
+ licenseTypeLabel: {
+ id: 'authoring.videoeditor.license.licenseType.label',
+ defaultMessage: 'License Type',
+ description: 'Label for license type selection field',
+ },
+ detailsSubsectionTitle: {
+ id: 'authoring.videoeditor.license.detailsSubsection.title',
+ defaultMessage: 'License Details',
+ description: 'Title for license detatils subsection',
+ },
+ displaySubsectionTitle: {
+ id: 'authoring.videoeditor.license.displaySubsection.title',
+ defaultMessage: 'License Display',
+ description: 'Title for license display subsection',
+ },
+ addLicenseButtonLabel: {
+ id: 'authoring.videoeditor.license.add.label',
+ defaultMessage: 'Add a license for this video',
+ description: 'Label for add license button',
+ },
+ deleteLicenseSelection: {
+ id: 'authoring.videoeditor.license.deleteLicenseSelection',
+ defaultMessage: 'Clear and apply the course-level license',
+ description: 'Message presented to user for action to delete license selection',
+ },
+ allRightsReservedIconsLabel: {
+ id: 'authoring.videoeditor.license.allRightsReservedIcons.label',
+ defaultMessage: 'All Rights Reserved',
+ description: 'Label for row of all rights reserved icons',
+ },
+ creativeCommonsIconsLabel: {
+ id: 'authoring.videoeditor.license.creativeCommonsIcons.label',
+ defaultMessage: 'Some Rights Reserved',
+ description: 'Label for row of creative common icons',
+ },
+ viewLicenseDetailsLabel: {
+ id: 'authoring.videoeditor.license.viewLicenseDetailsLabel.label',
+ defaultMessage: 'View license details',
+ description: 'Label for view license details button',
+ },
+ courseLevelDescription: {
+ id: 'authoring.videoeditor.license.courseLevelDescription.helperText',
+ defaultMessage: 'This license currently set at the course level',
+ description: 'Helper text for license type when using course license',
+ },
+ courseLicenseDescription: {
+ id: 'authoring.videoeditor.license.courseLicenseDescription.message',
+ defaultMessage: 'Licenses set at the course level appear at the bottom of courseware pages within your course.',
+ description: 'Message explaining where course level licenses are set',
+ },
+ libraryLevelDescription: {
+ id: 'authoring.videoeditor.license.libraryLevelDescription.helperText',
+ defaultMessage: 'This license currently set at the library level',
+ description: 'Helper text for license type when using library license',
+ },
+ libraryLicenseDescription: {
+ id: 'authoring.videoeditor.license.libraryLicenseDescription.message',
+ defaultMessage: 'Licenses set at the library level appear at the specific library video.',
+ description: 'Message explaining where library level licenses are set',
+ },
+ defaultLevelDescription: {
+ id: 'authoring.videoeditor.license.defaultLevelDescription.helperText',
+ defaultMessage: 'This license is set specifically for this video',
+ description: 'Helper text for license type when choosing for a spcific video',
+ },
+ defaultLicenseDescription: {
+ id: 'authoring.videoeditor.license.defaultLicenseDescription.message',
+ defaultMessage: 'When a video has a different license than the course as a whole, learners see the license at the bottom right of the video player.',
+ description: 'Message explaining where video specific licenses are seen by users',
+ },
+ attributionCheckboxLabel: {
+ id: 'authoring.videoeditor.license.attributionCheckboxLabel',
+ defaultMessage: 'Attribution',
+ description: 'Label for attribution checkbox',
+ },
+ attributionSectionDescription: {
+ id: 'authoring.videoeditor.license.attributionSectionDescription',
+ defaultMessage: 'Allow others to copy, distribute, display and perform your copyrighted work but only if they give credit the way you request. Currently, this option is required.',
+ description: 'Attribution card section defining attribution license',
+ },
+ noncommercialCheckboxLabel: {
+ id: 'authoring.videoeditor.license.noncommercialCheckboxLabel',
+ defaultMessage: 'Noncommercial',
+ description: 'Label for noncommercial checkbox',
+ },
+ noncommercialSectionDescription: {
+ id: 'authoring.videoeditor.license.noncommercialSectionDescription',
+ defaultMessage: 'Allow others to copy, distribute, display and perform your work - and derivative works based upon it - but for noncommercial purposes only.',
+ description: 'Noncommercial card section defining noncommercial license',
+ },
+ noDerivativesCheckboxLabel: {
+ id: 'authoring.videoeditor.license.noDerivativesCheckboxLabel',
+ defaultMessage: 'No Derivatives',
+ description: 'Label for No Derivatives checkbox',
+ },
+ noDerivativesSectionDescription: {
+ id: 'authoring.videoeditor.license.noDerivativesSectionDescription',
+ defaultMessage: 'Allow others to copy, distribute, display and perform only verbatim copies of your work, not derivative works based upon it. This option is incompatible with "Share Alike".',
+ description: 'No Derivatives card section defining no derivatives license',
+ },
+ shareAlikeCheckboxLabel: {
+ id: 'authoring.videoeditor.license.shareAlikeCheckboxLabel',
+ defaultMessage: 'Share Alike',
+ description: 'Label for Share Alike checkbox',
+ },
+ shareAlikeSectionDescription: {
+ id: 'authoring.videoeditor.license.shareAlikeSectionDescription',
+ defaultMessage: 'Allow others to distribute derivative works only under a license identical to the license that governs your work. This option is incompatible with "No Derivatives".',
+ description: 'Share Alike card section defining no derivatives license',
+ },
+ allRightsReservedSectionMessage: {
+ id: 'authoring.videoeditor.license.allRightsReservedSectionMessage',
+ defaultMessage: 'You reserve all rights for your work.',
+ description: 'All Rights Reserved section message',
+ },
+});
+
+export default messages;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..b654d77b07
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,85 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SocialShareWidget rendered with videoSharingEnabled false with default props should return null 1`] = `null`;
+
+exports[`SocialShareWidget rendered with videoSharingEnabled false with videoSharingEnabledForAll false and isLibrary true should return null 1`] = `null`;
+
+exports[`SocialShareWidget rendered with videoSharingEnabled false with videoSharingEnabledForCourse and isLibrary false and videoSharingEnabledForAll true should return null 1`] = `null`;
+
+exports[`SocialShareWidget rendered with videoSharingEnabled true and allowVideoSharing value equals false should have subtitle with text that reads Enabled 1`] = `
+
+
+
+
+
+
+ This video is shareable to social media
+
+
+
+
+ Learn more about social sharing
+
+
+
+`;
+
+exports[`SocialShareWidget rendered with videoSharingEnabled true and allowVideoSharing value equals true should have subtitle with text that reads Enabled 1`] = `
+
+
+
+
+
+
+ This video is shareable to social media
+
+
+
+
+ Learn more about social sharing
+
+
+
+`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/constants.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/constants.js
new file mode 100644
index 0000000000..5c382f9bbe
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/constants.js
@@ -0,0 +1,4 @@
+// eslint-disable-next-line import/prefer-default-export
+export const analyticsEvents = {
+ socialSharingSettingChanged: 'edx.social.video_sharing_setting.changed',
+};
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/hooks.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/hooks.jsx
new file mode 100644
index 0000000000..4b357af3b5
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/hooks.jsx
@@ -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;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/hooks.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/hooks.test.jsx
new file mode 100644
index 0000000000..a70d8ea838
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/hooks.test.jsx
@@ -0,0 +1,44 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+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 },
+ );
+ });
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/index.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/index.jsx
new file mode 100644
index 0000000000..57b6e25ac2
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/index.jsx
@@ -0,0 +1,120 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import {
+ FormattedMessage,
+ injectIntl,
+ intlShape,
+} from '@edx/frontend-platform/i18n';
+import {
+ Hyperlink,
+ Form,
+} from '@openedx/paragon';
+
+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
+ */
+const SocialShareWidget = ({
+ // injected
+ intl,
+ // redux
+ allowVideoSharing,
+ isLibrary,
+ videoSharingEnabledForAll,
+ videoSharingEnabledForCourse,
+ videoSharingLearnMoreLink,
+ updateField,
+}) => {
+ 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);
+ }
+ return intl.formatMessage(messages.disabledSubtitle);
+ };
+
+ return (videoSharingEnabled ? (
+
+
+
+
+
+
+ {intl.formatMessage(messages.socialSharingCheckboxLabel)}
+
+
+ {isSetByCourse && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+ {intl.formatMessage(messages.learnMoreLinkLabel)}
+
+
+
+ ) : null);
+};
+
+SocialShareWidget.defaultProps = {
+ allowVideoSharing: {
+ level: 'block',
+ value: false,
+ },
+ videoSharingEnabledForCourse: false,
+ videoSharingEnabledForAll: false,
+};
+
+SocialShareWidget.propTypes = {
+ // injected
+ intl: intlShape.isRequired,
+ // redux
+ allowVideoSharing: PropTypes.shape({
+ level: PropTypes.string.isRequired,
+ value: PropTypes.bool.isRequired,
+ }),
+ isLibrary: PropTypes.bool.isRequired,
+ videoSharingEnabledForAll: PropTypes.bool,
+ videoSharingEnabledForCourse: PropTypes.bool,
+ videoSharingLearnMoreLink: PropTypes.string.isRequired,
+ updateField: PropTypes.func.isRequired,
+};
+
+export const mapStateToProps = (state) => ({
+ allowVideoSharing: selectors.video.allowVideoSharing(state),
+ isLibrary: selectors.app.isLibrary(state),
+ videoSharingLearnMoreLink: selectors.video.videoSharingLearnMoreLink(state),
+ videoSharingEnabledForAll: selectors.video.videoSharingEnabledForAll(state),
+ videoSharingEnabledForCourse: selectors.video.videoSharingEnabledForCourse(state),
+});
+
+export const mapDispatchToProps = (dispatch) => ({
+ updateField: (stateUpdate) => dispatch(actions.video.updateField(stateUpdate)),
+});
+
+export const SocialShareWidgetInternal = SocialShareWidget; // For testing only
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(SocialShareWidget));
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/index.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/index.test.jsx
new file mode 100644
index 0000000000..2d7fccd76c
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/index.test.jsx
@@ -0,0 +1,274 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { formatMessage } from '../../../../../../testUtils';
+import { actions, selectors } from '../../../../../../data/redux';
+import { SocialShareWidgetInternal as SocialShareWidget, mapStateToProps, mapDispatchToProps } from '.';
+import messages from './messages';
+
+jest.mock('react', () => {
+ const updateState = jest.fn();
+ return {
+ ...jest.requireActual('react'),
+ updateState,
+ useContext: jest.fn(() => ({ license: ['error.license', jest.fn().mockName('error.setLicense')] })),
+ };
+});
+
+jest.mock('../../../../../../data/redux', () => ({
+ actions: {
+ video: {
+ updateField: jest.fn().mockName('actions.video.updateField'),
+ },
+ },
+ selectors: {
+ app: {
+ isLibrary: jest.fn(state => ({ isLibrary: state })),
+ },
+ video: {
+ allowVideoSharing: jest.fn(state => ({ allowVideoSharing: state })),
+ videoSharingEnabledForAll: jest.fn(state => ({ videoSharingEnabledForAll: state })),
+ videoSharingEnabledForCourse: jest.fn(state => ({ videoSharingEnabledForCourse: state })),
+ videoSharingLearnMoreLink: jest.fn(state => ({ videoSharingLearnMoreLink: state })),
+ },
+ },
+}));
+
+describe('SocialShareWidget', () => {
+ const props = {
+ title: 'tiTLE',
+ intl: { formatMessage },
+ videoSharingEnabledForCourse: false,
+ videoSharingEnabledForAll: false,
+ isLibrary: false,
+ allowVideoSharing: {
+ level: 'block',
+ value: false,
+ },
+ videoSharingLearnMoreLink: 'sOMeURl.cOM',
+ updateField: jest.fn().mockName('args.updateField'),
+ };
+
+ describe('rendered with videoSharingEnabled false', () => {
+ describe('with default props', () => {
+ it('should return null', () => {
+ const wrapper = shallow( );
+ expect(wrapper.snapshot).toMatchSnapshot();
+ });
+ });
+ describe('with videoSharingEnabledForAll false and isLibrary true', () => {
+ it('should return null', () => {
+ const wrapper = shallow( );
+ expect(wrapper.snapshot).toMatchSnapshot();
+ });
+ });
+ describe('with videoSharingEnabledForCourse and isLibrary false and videoSharingEnabledForAll true', () => {
+ it('should return null', () => {
+ const wrapper = shallow( );
+ expect(wrapper.snapshot).toMatchSnapshot();
+ });
+ });
+ });
+
+ describe('rendered with videoSharingEnabled true', () => {
+ describe('and allowVideoSharing value equals true', () => {
+ describe(' with level equal to course', () => {
+ const wrapper = shallow( );
+ it('should have setting location message', () => {
+ const settingLocationDisclaimer = wrapper.instance.findByType('FormattedMessage')[2].props.defaultMessage;
+ expect(settingLocationDisclaimer).toEqual(messages.disclaimerSettingLocation.defaultMessage);
+ });
+ it('should have checkbox disabled prop equal true', () => {
+ const disabledCheckbox = wrapper.shallowWrapper.props.children[1].props.disabled;
+ expect(disabledCheckbox).toEqual(true);
+ });
+ });
+ describe(' with level equal to block', () => {
+ const wrapper = shallow( );
+ it('should not have setting location message', () => {
+ const formattedMessages = wrapper.instance.findByType('FormattedMessage');
+ expect(formattedMessages.length).toEqual(1);
+ expect(formattedMessages[0]).not.toEqual(messages.disclaimerSettingLocation.defaultMessage);
+ });
+ it('should not have override note', () => {
+ const formattedMessages = wrapper.instance.findByType('FormattedMessage');
+ expect(formattedMessages.length).toEqual(1);
+ expect(formattedMessages[0]).not.toEqual(messages.overrideSocialSharingNote.defaultMessage);
+ });
+ it('should have checkbox disabled prop equal false', () => {
+ const disabledCheckbox = wrapper.shallowWrapper.props.children[1].props.disabled;
+ expect(disabledCheckbox).toEqual(false);
+ });
+ });
+ describe('isLibrary equals true', () => {
+ const wrapper = shallow( );
+ it('should not have setting location message', () => {
+ const formattedMessages = wrapper.instance.findByType('FormattedMessage');
+ expect(formattedMessages.length).toEqual(1);
+ expect(formattedMessages[0]).not.toEqual(messages.disclaimerSettingLocation.defaultMessage);
+ });
+ it('should not have override note', () => {
+ const formattedMessages = wrapper.instance.findByType('FormattedMessage');
+ expect(formattedMessages.length).toEqual(1);
+ expect(formattedMessages[0]).not.toEqual(messages.overrideSocialSharingNote.defaultMessage);
+ });
+ it('should have checkbox disabled prop equal false', () => {
+ const disabledCheckbox = wrapper.shallowWrapper.props.children[1].props.disabled;
+ expect(disabledCheckbox).toEqual(false);
+ });
+ });
+ it('should have subtitle with text that reads Enabled', () => {
+ const wrapper = shallow( );
+ const { subtitle } = wrapper.shallowWrapper.props;
+ expect(wrapper.snapshot).toMatchSnapshot();
+ expect(subtitle).toEqual('Enabled');
+ });
+ });
+ describe('and allowVideoSharing value equals false', () => {
+ describe(' with level equal to course', () => {
+ const wrapper = shallow( );
+ it('should have setting location message', () => {
+ const settingLocationDisclaimer = wrapper.instance.findByType('FormattedMessage')[2].props.defaultMessage;
+ expect(settingLocationDisclaimer).toEqual(messages.disclaimerSettingLocation.defaultMessage);
+ });
+ it('should have checkbox disabled prop equal true', () => {
+ const disabledCheckbox = wrapper.shallowWrapper.props.children[1].props.disabled;
+ expect(disabledCheckbox).toEqual(true);
+ });
+ });
+ describe(' with level equal to block', () => {
+ const wrapper = shallow( );
+ it('should not have setting location message', () => {
+ const formattedMessages = wrapper.instance.findByType('FormattedMessage');
+ expect(formattedMessages.length).toEqual(1);
+ expect(formattedMessages.at(0)).not.toEqual(messages.disclaimerSettingLocation.defaultMessage);
+ });
+ it('should not have override note', () => {
+ const formattedMessages = wrapper.instance.findByType('FormattedMessage');
+ expect(formattedMessages.length).toEqual(1);
+ expect(formattedMessages.at(0)).not.toEqual(messages.overrideSocialSharingNote.defaultMessage);
+ });
+ it('should have checkbox disabled prop equal false', () => {
+ const disabledCheckbox = wrapper.shallowWrapper.props.children[1].props.disabled;
+ expect(disabledCheckbox).toEqual(false);
+ });
+ });
+ describe('isLibrary equals true', () => {
+ const wrapper = shallow( );
+ it('should not have setting location message', () => {
+ const formattedMessages = wrapper.instance.findByType('FormattedMessage');
+ expect(formattedMessages.length).toEqual(1);
+ expect(formattedMessages.at(0)).not.toEqual(messages.disclaimerSettingLocation.defaultMessage);
+ });
+ it('should not have override note', () => {
+ const formattedMessages = wrapper.instance.findByType('FormattedMessage');
+ expect(formattedMessages.length).toEqual(1);
+ expect(formattedMessages.at(0)).not.toEqual(messages.overrideSocialSharingNote.defaultMessage);
+ });
+ it('should have checkbox disabled prop equal false', () => {
+ const disabledCheckbox = wrapper.shallowWrapper.props.children[1].props.disabled;
+ expect(disabledCheckbox).toEqual(false);
+ });
+ });
+ it('should have subtitle with text that reads Enabled', () => {
+ const wrapper = shallow( );
+ const { subtitle } = wrapper.shallowWrapper.props;
+ expect(wrapper.snapshot).toMatchSnapshot();
+ expect(subtitle).toEqual('Disabled');
+ });
+ });
+ });
+ describe('mapStateToProps', () => {
+ const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
+ test('isLibrary from app.isLibrary', () => {
+ expect(
+ mapStateToProps(testState).isLibrary,
+ ).toEqual(selectors.app.isLibrary(testState));
+ });
+ test('allowVideoSharing from video.allowVideoSharing', () => {
+ expect(
+ mapStateToProps(testState).allowVideoSharing,
+ ).toEqual(selectors.video.allowVideoSharing(testState));
+ });
+ test('videoSharingEnabledForCourse from video.videoSharingEnabledForCourse', () => {
+ expect(
+ mapStateToProps(testState).videoSharingEnabledForCourse,
+ ).toEqual(selectors.video.videoSharingEnabledForCourse(testState));
+ });
+ test('videoSharingEnabledForAll from video.videoSharingEnabledForAll', () => {
+ expect(
+ mapStateToProps(testState).videoSharingEnabledForAll,
+ ).toEqual(selectors.video.videoSharingEnabledForAll(testState));
+ });
+ test('videoSharingLearnMoreLink from video.videoSharingLearnMoreLink', () => {
+ expect(
+ mapStateToProps(testState).videoSharingLearnMoreLink,
+ ).toEqual(selectors.video.videoSharingLearnMoreLink(testState));
+ });
+ });
+ describe('mapDispatchToProps', () => {
+ const dispatch = jest.fn();
+ test('updateField from actions.video.updateField', () => {
+ expect(mapDispatchToProps.updateField).toEqual(dispatch(actions.video.updateField));
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/messages.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/messages.js
new file mode 100644
index 0000000000..7b47f43124
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/SocialShareWidget/messages.js
@@ -0,0 +1,46 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ title: {
+ id: 'authoring.videoeditor.socialShare.title',
+ defaultMessage: 'Social Sharing',
+ description: 'Title for socialShare widget',
+ },
+ disabledSubtitle: {
+ id: 'authoring.videoeditor.socialShare.disabled.subtitle',
+ defaultMessage: 'Disabled',
+ description: 'Subtitle for unavailable socialShare widget',
+ },
+ enabledSubtitle: {
+ id: 'authoring.videoeditor.socialShare.enabled.subtitle',
+ defaultMessage: 'Enabled',
+ description: 'Subtitle for when thumbnail has been uploaded to the widget',
+ },
+ learnMoreLinkLabel: {
+ id: 'authoring.videoeditor.socialShare.learnMore.link',
+ defaultMessage: 'Learn more about social sharing',
+ description: 'Text for link to learn more about social sharing',
+ },
+ socialSharingDescription: {
+ id: 'authoring.videoeditor.socialShare.description',
+ defaultMessage: 'Allow this video to be shareable to social media',
+ description: 'Description for sociail sharing setting',
+ },
+ socialSharingCheckboxLabel: {
+ id: 'authoring.videoeditor.socialShare.checkbox.label',
+ defaultMessage: 'This video is shareable to social media',
+ description: 'Label for checkbox for allowing video to be share',
+ },
+ overrideSocialSharingNote: {
+ id: 'authoring.videoeditor.socialShare.overrideNote',
+ defaultMessage: 'Note: This setting is overridden by the course outline page.',
+ description: 'Message that the setting can be overriden in the course outline',
+ },
+ disclaimerSettingLocation: {
+ id: 'authoring.videoeditor.socialShare.settingsDisclaimer',
+ defaultMessage: 'Change this setting on the course outline page.',
+ description: 'Message for disabled checkbox that notifies user that setting can be modified in course outline',
+ },
+});
+
+export default messages;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..87b43324f1
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,271 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ThumbnailWidget snapshots snapshots: renders as expected where thumbnail uploads are allowed 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`ThumbnailWidget snapshots snapshots: renders as expected where videoId is valid 1`] = `
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`ThumbnailWidget snapshots snapshots: renders as expected where videoId is valid and no thumbnail 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`ThumbnailWidget snapshots snapshots: renders as expected with a thumbnail provided 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`ThumbnailWidget snapshots snapshots: renders as expected with default props 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`ThumbnailWidget snapshots snapshots: renders as expected with isLibrary true 1`] = `null`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/constants.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/constants.js
new file mode 100644
index 0000000000..17ee797924
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/constants.js
@@ -0,0 +1,30 @@
+import { StrictDict } from '../../../../../../utils';
+
+export const acceptedImgKeys = StrictDict({
+ gif: '.gif',
+ jpg: '.jpg',
+ jpeg: '.jpeg',
+ png: '.png',
+ bmp: '.bmp',
+ bmp2: '.bmp2',
+});
+export const MAX_FILE_SIZE_MB = 2000000;
+export const MIN_FILE_SIZE_KB = 2000;
+export const MAX_WIDTH = 1280;
+export const MAX_HEIGHT = 720;
+export const MIN_WIDTH = 640;
+export const MIN_HEIGHT = 360;
+// eslint-disable-next-line no-loss-of-precision, @typescript-eslint/no-loss-of-precision
+export const ASPECT_RATIO = 1.7777777777777777777;
+export const ASPECT_RATIO_ERROR_MARGIN = 0.1;
+export default {
+ acceptedImgKeys,
+ MAX_FILE_SIZE_MB,
+ MIN_FILE_SIZE_KB,
+ MAX_WIDTH,
+ MAX_HEIGHT,
+ MIN_WIDTH,
+ MIN_HEIGHT,
+ ASPECT_RATIO,
+ ASPECT_RATIO_ERROR_MARGIN,
+};
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/hooks.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/hooks.js
new file mode 100644
index 0000000000..6277ac4bd5
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/hooks.js
@@ -0,0 +1,160 @@
+import React from 'react';
+import { useDispatch } from 'react-redux';
+import { actions, thunkActions } from '../../../../../../data/redux';
+import * as constants from './constants';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './hooks';
+
+export const state = {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ showSizeError: (args) => React.useState(args),
+};
+
+/** resampledFile({ canvasUrl, filename, mimeType })
+ * resampledFile takes a canvasUrl, filename, and a valid mimeType. The
+ * canvasUrl is parsed and written to an 8-bit array of unsigned integers. The
+ * new array is saved to a new file with the same filename as the original image.
+ * @param {string} canvasUrl - string of base64 URL for new image canvas
+ * @param {string} filename - string of the original image's filename
+ * @param {string} mimeType - string of mimeType for the canvas
+ * @return {File} new File object
+ */
+export const createResampledFile = ({ canvasUrl, filename, mimeType }) => {
+ const arr = canvasUrl.split(',');
+ const bstr = atob(arr[1]);
+ let n = bstr.length;
+ const u8arr = new Uint8Array(n);
+ while (n--) {
+ u8arr[n] = bstr.charCodeAt(n);
+ }
+ return new File([u8arr], filename, { type: mimeType });
+};
+
+/** resampleImage({ image, filename })
+ * resampledImage takes a canvasUrl, filename, and a valid mimeType. The
+ * canvasUrl is parsed and written to an 8-bit array of unsigned integers. The
+ * new array is saved to a new file with the same filename as the original image.
+ * @param {File} canvasUrl - string of base64 URL for new image canvas
+ * @param {string} filename - string of the image's filename
+ * @return {array} array containing the base64 URL for the resampled image and the file containing the resampled image
+ */
+export const resampleImage = ({ image, filename }) => {
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+
+ // Determine new dimensions for image
+ if (image.naturalWidth > constants.MAX_WIDTH) {
+ // Set dimensions to the maximum size
+ canvas.width = constants.MAX_WIDTH;
+ canvas.height = constants.MAX_HEIGHT;
+ } else if (image.naturalWidth < constants.MIN_WIDTH) {
+ // Set dimensions to the minimum size
+ canvas.width = constants.MIN_WIDTH;
+ canvas.height = constants.MIN_HEIGHT;
+ } else {
+ // Set dimensions to the closest 16:9 ratio
+ const heightRatio = 9 / 16;
+ canvas.width = image.naturalWidth;
+ canvas.height = image.naturalWidth * heightRatio;
+ }
+ const cropLeft = (image.naturalWidth - canvas.width) / 2;
+ const cropTop = (image.naturalHeight - canvas.height) / 2;
+
+ ctx.drawImage(image, cropLeft, cropTop, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);
+
+ const resampledFile = module.createResampledFile({ canvasUrl: canvas.toDataURL(), filename, mimeType: 'image/png' });
+ return [canvas.toDataURL(), resampledFile];
+};
+
+export const checkValidDimensions = ({ width, height }) => {
+ if (width < constants.MIN_WIDTH || height < height.MIN_WIDTH) {
+ return false;
+ }
+ const imageAspectRatio = Math.abs((width / height) - constants.ASPECT_RATIO);
+ if (imageAspectRatio >= constants.ASPECT_RATIO_ERROR_MARGIN) {
+ return false;
+ }
+ return true;
+};
+export const checkValidSize = ({ file, onSizeFail }) => {
+ // Check if the file size is greater than 2 MB, upload size maximum, or
+ // if the file size is greater than 2 KB, upload size minimum
+ if (file.size > constants.MAX_FILE_SIZE_MB || file.size < constants.MIN_FILE_SIZE_KB) {
+ onSizeFail();
+ return false;
+ }
+ return true;
+};
+
+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) => {
+ const file = e.target.files[0];
+ const reader = new FileReader();
+ if (file && module.checkValidSize({
+ file,
+ onSizeFail: () => {
+ fileSizeError.set();
+ },
+ })) {
+ reader.onload = () => {
+ setThumbnailSrc(reader.result);
+ const image = imgRef.current;
+ image.onload = () => {
+ if (!module.checkValidDimensions({ width: image.naturalWidth, height: image.naturalHeight })) {
+ const [resampledUrl, resampledFile] = module.resampleImage({ image, filename: file.name });
+ setThumbnailSrc(resampledUrl);
+ dispatch(thunkActions.video.uploadThumbnail({ thumbnail: resampledFile }));
+ dispatch(actions.video.updateField({ thumbnail: resampledUrl }));
+ return;
+ }
+ dispatch(thunkActions.video.uploadThumbnail({ thumbnail: file }));
+ dispatch(actions.video.updateField({ thumbnail: reader.result }));
+ };
+ };
+ dispatch(actions.video.updateField({ thumbnail: ' ' }));
+ reader.readAsDataURL(file);
+ }
+ };
+ return {
+ click,
+ addFile,
+ ref,
+ };
+};
+
+export const fileSizeError = () => {
+ const [showSizeError, setShowSizeError] = module.state.showSizeError(false);
+ return {
+ fileSizeError: {
+ show: showSizeError,
+ set: () => setShowSizeError(true),
+ dismiss: () => setShowSizeError(false),
+ },
+ };
+};
+
+export const deleteThumbnail = ({ dispatch }) => () => {
+ dispatch(actions.video.updateField({ thumbnail: null }));
+ const emptyCanvas = document.createElement('canvas');
+ const ctx = emptyCanvas.getContext('2d');
+ emptyCanvas.width = constants.MAX_WIDTH;
+ emptyCanvas.height = constants.MAX_HEIGHT;
+ ctx.fillStyle = 'black';
+ ctx.fillRect(0, 0, emptyCanvas.width, emptyCanvas.height);
+ const file = createResampledFile({ canvasUrl: emptyCanvas.toDataURL(), filename: 'blankThumbnail.png', mimeType: 'image/png' });
+ dispatch(thunkActions.video.uploadThumbnail({ thumbnail: file, emptyCanvas }));
+};
+
+export default {
+ fileInput,
+ fileSizeError,
+ deleteThumbnail,
+};
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/hooks.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/hooks.test.jsx
new file mode 100644
index 0000000000..d59b541fad
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/hooks.test.jsx
@@ -0,0 +1,147 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { useDispatch } from 'react-redux';
+import { actions, thunkActions } from '../../../../../../data/redux';
+import { MockUseState } from '../../../../../../testUtils';
+import { keyStore } from '../../../../../../utils';
+import * as hooks from './hooks';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useRef: jest.fn(val => ({ current: val })),
+ useEffect: jest.fn(),
+ useCallback: (cb, prereqs) => ({ cb, prereqs }),
+}));
+
+jest.mock('../../../../../../data/redux', () => ({
+ actions: {
+ video: {
+ updateField: jest.fn(),
+ },
+ },
+ thunkActions: {
+ video: {
+ uploadThumbnail: jest.fn(),
+ },
+ },
+}));
+
+const state = new MockUseState(hooks);
+const hookKeys = keyStore(hooks);
+let hook;
+const setThumbnailSrc = jest.fn();
+const testValue = 'testVALUEVALIDIMAGE';
+const selectedFileSuccess = { name: testValue, size: 20000 };
+const maxFileFail = { name: testValue, size: 20000000 };
+const minFileFail = { name: testValue, size: 200 };
+const resampledFile = new File([selectedFileSuccess], testValue);
+const imgRef = React.useRef.mockReturnValueOnce({ current: undefined });
+
+describe('state hooks', () => {
+ state.testGetter(state.keys.showSizeError);
+});
+
+describe('createResampledFile', () => {
+ it('should return resampled file object', () => {
+ hook = hooks.createResampledFile({ canvasUrl: 'data:MimETYpe,sOMEUrl', filename: testValue, mimeType: 'sOmEuiMAge' });
+ expect(hook).toEqual(resampledFile);
+ });
+});
+describe('resampleImage', () => {
+ it('should return filename and file', () => {
+ const spy = jest.spyOn(hooks, hookKeys.createResampledFile)
+ .mockReturnValueOnce(resampledFile);
+ const image = document.createElement('img');
+ image.height = '800';
+ image.width = '800';
+ hook = hooks.resampleImage({ image, filename: testValue });
+ expect(spy).toHaveBeenCalledWith({ canvasUrl: 'data:image/png;base64,00', filename: testValue, mimeType: 'image/png' });
+ expect(spy.mock.calls.length).toEqual(1);
+ expect(spy).toHaveReturnedWith(resampledFile);
+ expect(hook).toEqual(['data:image/png;base64,00', resampledFile]);
+ });
+});
+describe('checkValidDimensions', () => {
+ it('returns false for images less than min width and min height', () => {
+ hook = hooks.checkValidDimensions({ width: 500, height: 281 });
+ expect(hook).toEqual(false);
+ });
+ it('returns false for images that do not have a 16:9 aspect ratio', () => {
+ hook = hooks.checkValidDimensions({ width: 800, height: 800 });
+ expect(hook).toEqual(false);
+ });
+ it('returns true for images that have a 16:9 aspect ratio and larger than min width/height', () => {
+ hook = hooks.checkValidDimensions({ width: 1280, height: 720 });
+ expect(hook).toEqual(true);
+ });
+});
+describe('checkValidSize', () => {
+ const onSizeFail = jest.fn();
+ it('returns false for valid max file size', () => {
+ hook = hooks.checkValidSize({ file: maxFileFail, onSizeFail });
+ expect(onSizeFail).toHaveBeenCalled();
+ expect(hook).toEqual(false);
+ });
+ it('returns false for valid max file size', () => {
+ hook = hooks.checkValidSize({ file: minFileFail, onSizeFail });
+ expect(onSizeFail).toHaveBeenCalled();
+ expect(hook).toEqual(false);
+ });
+ it('returns true for valid file size', () => {
+ hook = hooks.checkValidSize({ file: selectedFileSuccess, onSizeFail });
+ expect(hook).toEqual(true);
+ });
+});
+describe('fileInput', () => {
+ const spies = {};
+ const fileSizeError = { set: jest.fn() };
+ beforeEach(() => {
+ hook = hooks.fileInput({ setThumbnailSrc, imgRef, fileSizeError });
+ });
+ it('returns a ref for the file input', () => {
+ expect(hook.ref).toEqual({ current: undefined });
+ });
+ test('click calls current.click on the ref', () => {
+ const click = jest.fn();
+ React.useRef.mockReturnValueOnce({ current: { click } });
+ hook = hooks.fileInput({ setThumbnailSrc, imgRef, fileSizeError });
+ hook.click();
+ expect(click).toHaveBeenCalled();
+ });
+ describe('addFile', () => {
+ const eventSuccess = { target: { files: [new File([selectedFileSuccess], 'sOMEUrl.jpg')] } };
+ const eventFailure = { target: { files: [maxFileFail] } };
+ it('image fails to upload if file size is greater than 1000000', () => {
+ const checkValidSize = false;
+ spies.checkValidSize = jest.spyOn(hooks, hookKeys.checkValidSize)
+ .mockReturnValueOnce(checkValidSize);
+ hook.addFile(eventFailure);
+ expect(spies.checkValidSize.mock.calls.length).toEqual(1);
+ expect(spies.checkValidSize).toHaveReturnedWith(false);
+ });
+ it('dispatches updateField action with the first target file', () => {
+ const dispatch = useDispatch(); // Access the mock 'dispatch()' set up in setupEditorTest
+ const checkValidSize = true;
+ spies.checkValidSize = jest.spyOn(hooks, hookKeys.checkValidSize)
+ .mockReturnValueOnce(checkValidSize);
+ hook.addFile(eventSuccess);
+ expect(spies.checkValidSize).toHaveReturnedWith(true);
+ expect(dispatch).toHaveBeenCalledWith(actions.video.updateField({ thumbnail: ' ' }));
+ expect(dispatch).toHaveBeenCalledWith(
+ thunkActions.video.uploadThumbnail({
+ thumbnail: eventSuccess.target.files[0],
+ }),
+ );
+ });
+ });
+ describe('deleteThumbnail', () => {
+ const dispatch = useDispatch(); // Access the mock 'dispatch()' set up in setupEditorTest
+ const testFile = new File([selectedFileSuccess], 'sOMEUrl.jpg');
+ hooks.deleteThumbnail({ dispatch })();
+ expect(dispatch).toHaveBeenNthCalledWith(1, actions.video.updateField({ thumbnail: null }));
+ expect(dispatch).toHaveBeenNthCalledWith(2, thunkActions.video.uploadThumbnail({
+ thumbnail: testFile,
+ emptyCanvas: true,
+ }));
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/index.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/index.jsx
new file mode 100644
index 0000000000..72226c50cf
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/index.jsx
@@ -0,0 +1,147 @@
+import React from 'react';
+import { connect, useDispatch } from 'react-redux';
+import PropTypes from 'prop-types';
+import {
+ FormattedMessage,
+ injectIntl,
+ intlShape,
+} from '@edx/frontend-platform/i18n';
+import {
+ Image,
+ Stack,
+ Button,
+ Icon,
+ IconButtonWithTooltip,
+ Alert,
+} from '@openedx/paragon';
+import { DeleteOutline, FileUpload } from '@openedx/paragon/icons';
+
+import { selectors } from '../../../../../../data/redux';
+import { isEdxVideo } from '../../../../../../data/services/cms/api';
+
+import { acceptedImgKeys } from './constants';
+import * as hooks from './hooks';
+import messages from './messages';
+
+import CollapsibleFormWidget from '../CollapsibleFormWidget';
+import { FileInput } from '../../../../../../sharedComponents/FileInput';
+import ErrorAlert from '../../../../../../sharedComponents/ErrorAlerts/ErrorAlert';
+import { ErrorContext } from '../../../../hooks';
+
+/**
+ * Collapsible Form widget controlling video thumbnail
+ */
+const ThumbnailWidget = ({
+ // injected
+ intl,
+ // redux
+ isLibrary,
+ allowThumbnailUpload,
+ thumbnail,
+ videoId,
+}) => {
+ const dispatch = useDispatch();
+ const [error] = React.useContext(ErrorContext).thumbnail;
+ const imgRef = React.useRef();
+ const [thumbnailSrc, setThumbnailSrc] = React.useState(thumbnail);
+ const { fileSizeError } = hooks.fileSizeError();
+ const fileInput = hooks.fileInput({
+ setThumbnailSrc,
+ imgRef,
+ fileSizeError,
+ });
+ const edxVideo = isEdxVideo(videoId);
+ const deleteThumbnail = hooks.deleteThumbnail({ dispatch });
+ const getSubtitle = () => {
+ if (edxVideo) {
+ if (thumbnail) {
+ return intl.formatMessage(messages.yesSubtitle);
+ }
+ return intl.formatMessage(messages.noneSubtitle);
+ }
+ return intl.formatMessage(messages.unavailableSubtitle);
+ };
+ return (!isLibrary ? (
+
+
+
+
+ {(allowThumbnailUpload && edxVideo) ? null : (
+
+
+
+ )}
+ {thumbnail ? (
+
+
+ {(allowThumbnailUpload && edxVideo) ? (
+
+ ) : null }
+
+ ) : (
+
+
+
+
+
+
+
+ )}
+
+ ) : null);
+};
+
+ThumbnailWidget.propTypes = {
+ // injected
+ intl: intlShape.isRequired,
+ // redux
+ isLibrary: PropTypes.bool.isRequired,
+ allowThumbnailUpload: PropTypes.bool.isRequired,
+ thumbnail: PropTypes.string.isRequired,
+ videoId: PropTypes.string.isRequired,
+};
+export const mapStateToProps = (state) => ({
+ isLibrary: selectors.app.isLibrary(state),
+ allowThumbnailUpload: selectors.video.allowThumbnailUpload(state),
+ thumbnail: selectors.video.thumbnail(state),
+ videoId: selectors.video.videoId(state),
+});
+
+export const mapDispatchToProps = {};
+
+export const ThumbnailWidgetInternal = ThumbnailWidget; // For testing only
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ThumbnailWidget));
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/index.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/index.test.jsx
new file mode 100644
index 0000000000..0b761701c6
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/index.test.jsx
@@ -0,0 +1,106 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { formatMessage } from '../../../../../../testUtils';
+import { selectors } from '../../../../../../data/redux';
+import { ThumbnailWidgetInternal as ThumbnailWidget, mapStateToProps, mapDispatchToProps } from '.';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useContext: jest.fn(() => ({ thumbnail: ['error.thumbnail', jest.fn().mockName('error.setThumbnail')] })),
+}));
+jest.mock('../../../../../../data/redux', () => ({
+ actions: {
+ video: {
+ updateField: jest.fn().mockName('actions.video.updateField'),
+ },
+ },
+ selectors: {
+ video: {
+ allowThumbnailUpload: jest.fn(state => ({ allowThumbnailUpload: state })),
+ thumbnail: jest.fn(state => ({ thumbnail: state })),
+ videoId: jest.fn(state => ({ videoId: state })),
+ },
+ app: {
+ isLibrary: jest.fn(state => ({ isLibrary: state })),
+ },
+ },
+}));
+
+jest.mock('../../../../../../data/services/cms/api', () => ({
+ isEdxVideo: (args) => (args),
+}));
+
+describe('ThumbnailWidget', () => {
+ const props = {
+ error: {},
+ title: 'tiTLE',
+ intl: { formatMessage },
+ isLibrary: false,
+ allowThumbnailUpload: false,
+ thumbnail: null,
+ videoId: '',
+ updateField: jest.fn().mockName('args.updateField'),
+ };
+ describe('snapshots', () => {
+ test('snapshots: renders as expected with default props', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with isLibrary true', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with a thumbnail provided', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected where thumbnail uploads are allowed', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected where videoId is valid', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected where videoId is valid and no thumbnail', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ });
+ describe('mapStateToProps', () => {
+ const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
+ test('isLibrary from app.isLibrary', () => {
+ expect(
+ mapStateToProps(testState).isLibrary,
+ ).toEqual(selectors.app.isLibrary(testState));
+ });
+ test('allowThumbnailUpload from video.allowThumbnailUpload', () => {
+ expect(
+ mapStateToProps(testState).allowThumbnailUpload,
+ ).toEqual(selectors.video.allowThumbnailUpload(testState));
+ });
+ test('thumbnail from video.thumbnail', () => {
+ expect(
+ mapStateToProps(testState).thumbnail,
+ ).toEqual(selectors.video.thumbnail(testState));
+ });
+ test('videoId from video.videoId', () => {
+ expect(
+ mapStateToProps(testState).videoId,
+ ).toEqual(selectors.video.videoId(testState));
+ });
+ });
+ describe('mapDispatchToProps', () => {
+ test('mapDispatchToProps to equal an empty object', () => {
+ expect(mapDispatchToProps).toEqual({});
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/messages.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/messages.js
new file mode 100644
index 0000000000..e930448cbe
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/messages.js
@@ -0,0 +1,65 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+
+ title: {
+ id: 'authoring.videoeditor.thumbnail.title',
+ defaultMessage: 'Thumbnail',
+ description: 'Title for thumbnail widget',
+ },
+ unavailableSubtitle: {
+ id: 'authoring.videoeditor.thumbnail.unavailable.subtitle',
+ defaultMessage: 'Unavailable',
+ description: 'Subtitle for unavailable thumbnail widget',
+ },
+ noneSubtitle: {
+ id: 'authoring.videoeditor.thumbnail.none.subtitle',
+ defaultMessage: 'None',
+ description: 'Subtitle for when no thumbnail has been uploaded to the widget',
+ },
+ yesSubtitle: {
+ id: 'authoring.videoeditor.thumbnail.yes.subtitle',
+ defaultMessage: 'Yes',
+ description: 'Subtitle for when thumbnail has been uploaded to the widget',
+ },
+ unavailableMessage: {
+ id: 'authoring.videoeditor.thumbnail.unavailable.message',
+ defaultMessage:
+ 'Select a video from your library to enable this feature (applies only to courses that run on the edx.org site).',
+ description: 'Message for unavailable thumbnail widget',
+ },
+ uploadButtonLabel: {
+ id: 'authoring.videoeditor.thumbnail.upload.label',
+ defaultMessage: 'Upload thumbnail',
+ description: 'Label for upload button',
+ },
+ addThumbnail: {
+ id: 'authoring.videoeditor.thumbnail.upload.message',
+ defaultMessage: 'Upload an image for learners to see before playing the video.',
+ description: 'Message for adding thumbnail',
+ },
+ aspectRequirements: {
+ id: 'authoring.videoeditor.thumbnail.upload.aspectRequirements',
+ defaultMessage: 'Images must have an aspect ratio of 16:9 (1280x720 px recommended)',
+ description: 'Message for thumbnail aspectRequirements',
+ },
+ thumbnailAltText: {
+ id: 'authoring.videoeditor.thumbnail.altText',
+ defaultMessage: 'Image used as thumbnail for video',
+ description: 'Alternative test for thumbnail',
+ },
+ deleteThumbnail: {
+ id: 'authoring.videoeditor.thumbnail.deleteThumbnail',
+ defaultMessage: 'Delete',
+ description: 'Message presented to user for action to delete thumbnail',
+ },
+ fileSizeError: {
+ id: 'authoring.videoeditor.thumbnail.error.fileSizeError',
+ defaultMessage:
+ 'The file size for thumbnails must be larger than 2 KB or less than 2 MB. Please resize your image and try again.',
+ description:
+ ' Message presented to user when file size of image is less than 2 KB or larger than 2 MB',
+ },
+});
+
+export default messages;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/utils.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/utils.js
new file mode 100644
index 0000000000..1acb5b31c2
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget/utils.js
@@ -0,0 +1,11 @@
+import { StrictDict } from '../../../../../../utils';
+
+export const acceptedImgKeys = StrictDict({
+ gif: '.gif',
+ jpg: '.jpg',
+ jpeg: '.jpeg',
+ png: '.png',
+ bmp: '.bmp',
+});
+
+export default { acceptedImgKeys };
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/ImportTranscriptCard.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/ImportTranscriptCard.jsx
new file mode 100644
index 0000000000..21983b45f3
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/ImportTranscriptCard.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+
+import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
+import {
+ ActionRow,
+ Button,
+ Icon,
+ IconButton,
+ Stack,
+} from '@openedx/paragon';
+import { Close } from '@openedx/paragon/icons';
+
+import messages from './messages';
+import { thunkActions } from '../../../../../../data/redux';
+
+const ImportTranscriptCard = ({
+ setOpen,
+ // redux
+ importTranscript,
+}) => (
+
+
+
+
+ setOpen(false)}
+ />
+
+
+
+
+
+
+);
+
+ImportTranscriptCard.defaultProps = {
+ setOpen: true,
+};
+
+ImportTranscriptCard.propTypes = {
+ setOpen: PropTypes.func,
+ // redux
+ importTranscript: PropTypes.func.isRequired,
+};
+
+export const mapStateToProps = () => ({});
+
+export const mapDispatchToProps = {
+ importTranscript: thunkActions.video.importTranscript,
+};
+
+export const ImportTranscriptCardInternal = ImportTranscriptCard; // For testing only
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ImportTranscriptCard));
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/ImportTranscriptCard.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/ImportTranscriptCard.test.jsx
new file mode 100644
index 0000000000..1a55bc98ca
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/ImportTranscriptCard.test.jsx
@@ -0,0 +1,58 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+import { Button, IconButton } from '@openedx/paragon';
+
+import { thunkActions } from '../../../../../../data/redux';
+import { ImportTranscriptCardInternal as ImportTranscriptCard, mapDispatchToProps, mapStateToProps } from './ImportTranscriptCard';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useContext: jest.fn(() => ({ transcripts: ['error.transcripts', jest.fn().mockName('error.setTranscripts')] })),
+}));
+
+jest.mock('../../../../../../data/redux', () => ({
+ thunkActions: {
+ video: {
+ importTranscript: jest.fn().mockName('thunkActions.video.importTranscript'),
+ },
+ },
+}));
+
+describe('ImportTranscriptCard', () => {
+ const props = {
+ setOpen: jest.fn().mockName('setOpen'),
+ importTranscript: jest.fn().mockName('args.importTranscript'),
+ };
+ let el;
+ describe('snapshots', () => {
+ test('snapshots: renders as expected with default props', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ });
+ describe('behavior inspection', () => {
+ beforeEach(() => {
+ el = shallow( );
+ });
+ test('close behavior is linked to IconButton', () => {
+ expect(el.instance.findByType(IconButton)[0]
+ .props.onClick).toBeDefined();
+ });
+ test('import behavior is linked to Button onClick', () => {
+ expect(el.instance.findByType(Button)[0]
+ .props.onClick).toEqual(props.importTranscript);
+ });
+ });
+ describe('mapStateToProps', () => {
+ it('returns an empty object', () => {
+ expect(mapStateToProps()).toEqual({});
+ });
+ });
+ describe('mapDispatchToProps', () => {
+ test('updateField from thunkActions.video.importTranscript', () => {
+ expect(mapDispatchToProps.importTranscript).toEqual(thunkActions.video.importTranscript);
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.jsx
new file mode 100644
index 0000000000..90d3ec235b
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.jsx
@@ -0,0 +1,137 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import {
+ ActionRow,
+ Dropdown,
+ Button,
+ Icon,
+} from '@openedx/paragon';
+
+import { Check } from '@openedx/paragon/icons';
+import { connect, useDispatch } from 'react-redux';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { thunkActions, selectors } from '../../../../../../data/redux';
+import { videoTranscriptLanguages } from '../../../../../../data/constants/video';
+import { FileInput, fileInput } from '../../../../../../sharedComponents/FileInput';
+import messages from './messages';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './LanguageSelector';
+
+export const hooks = {
+ onSelectLanguage: ({
+ dispatch, languageBeforeChange, triggerupload, setLocalLang,
+ }) => ({ newLang }) => {
+ // IF Language is unset, set language and begin upload prompt.
+ setLocalLang(newLang);
+ if (languageBeforeChange === '') {
+ triggerupload();
+ return;
+ }
+ // Else: update language
+ dispatch(
+ thunkActions.video.updateTranscriptLanguage({
+ newLanguageCode: newLang, languageBeforeChange,
+ }),
+ );
+ },
+
+ addFileCallback: ({ dispatch, localLang }) => (file) => {
+ dispatch(thunkActions.video.uploadTranscript({
+ file,
+ filename: file.name,
+ language: localLang,
+ }));
+ },
+
+};
+
+const LanguageSelector = ({
+ index, // For a unique id for the form control
+ language,
+ // Redux
+ openLanguages, // Only allow those languages not already associated with a transcript to be selected
+ // intl
+ intl,
+
+}) => {
+ const [localLang, setLocalLang] = React.useState(language);
+ const input = fileInput({ onAddFile: hooks.addFileCallback({ dispatch: useDispatch(), localLang }) });
+ const onLanguageChange = module.hooks.onSelectLanguage({
+ dispatch: useDispatch(), languageBeforeChange: localLang, setLocalLang, triggerupload: input.click,
+ });
+
+ const getTitle = () => {
+ if (Object.prototype.hasOwnProperty.call(videoTranscriptLanguages, language)) {
+ return (
+
+ {videoTranscriptLanguages[language]}
+
+
+
+
+ );
+ }
+ return (
+
+ {intl.formatMessage(messages.languageSelectPlaceholder)}
+
+
+ );
+ };
+
+ return (
+ <>
+
+
+
+ {getTitle()}
+
+
+ {Object.entries(videoTranscriptLanguages).map(([lang, text]) => {
+ if (language === lang) {
+ return ({text} );
+ }
+ if (openLanguages.some(row => row.includes(lang))) {
+ return ( onLanguageChange({ newLang: lang })}>{text} );
+ }
+ return ({text} );
+ })}
+
+
+
+ >
+ );
+};
+
+LanguageSelector.defaultProps = {
+ openLanguages: [],
+};
+
+LanguageSelector.propTypes = {
+ openLanguages: PropTypes.arrayOf(PropTypes.string),
+ index: PropTypes.number.isRequired,
+ language: PropTypes.string.isRequired,
+ intl: intlShape.isRequired,
+};
+
+export const mapStateToProps = (state) => ({
+ openLanguages: selectors.video.openLanguages(state),
+});
+
+export const mapDispatchToProps = {};
+
+export const LanguageSelectorInternal = LanguageSelector; // For testing only
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(LanguageSelector));
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.test.jsx
new file mode 100644
index 0000000000..0eae896c6f
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.test.jsx
@@ -0,0 +1,45 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+import { LanguageSelectorInternal as LanguageSelector } from './LanguageSelector';
+import { formatMessage } from '../../../../../../testUtils';
+
+const lang1 = 'kLinGon';
+const lang1Code = 'kl';
+const lang2 = 'eLvIsh';
+const lang2Code = 'el';
+const lang3 = 'sImLisH';
+const lang3Code = 'sl';
+
+jest.mock('../../../../../../data/constants/video', () => ({
+ videoTranscriptLanguages: {
+ [lang1Code]: lang1,
+ [lang2Code]: lang2,
+ [lang3Code]: lang3,
+ },
+}));
+
+describe('LanguageSelector', () => {
+ const props = {
+ intl: { formatMessage },
+ onSelect: jest.fn().mockName('props.OnSelect'),
+ title: 'tITle',
+ language: lang1Code,
+ openLanguages: [[lang2Code, lang2], [lang3Code, lang3]],
+
+ };
+ describe('snapshot', () => {
+ test('transcript option', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ });
+ describe('snapshots -- no', () => {
+ test('transcripts no Open Languages, all should be disabled', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/Transcript.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/Transcript.jsx
new file mode 100644
index 0000000000..6300fee194
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/Transcript.jsx
@@ -0,0 +1,128 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { connect } from 'react-redux';
+
+import {
+ Card,
+ Button,
+ IconButton,
+ Icon,
+ ActionRow,
+} from '@openedx/paragon';
+import { DeleteOutline } from '@openedx/paragon/icons';
+
+import {
+ FormattedMessage,
+ injectIntl,
+} from '@edx/frontend-platform/i18n';
+import { thunkActions } from '../../../../../../data/redux';
+
+import TranscriptActionMenu from './TranscriptActionMenu';
+import LanguageSelector from './LanguageSelector';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './Transcript';
+import messages from './messages';
+
+export const hooks = {
+ state: {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ inDeleteConfirmation: (args) => React.useState(args),
+ },
+ setUpDeleteConfirmation: () => {
+ const [inDeleteConfirmation, setInDeleteConfirmation] = module.hooks.state.inDeleteConfirmation(false);
+ return {
+ inDeleteConfirmation,
+ launchDeleteConfirmation: () => setInDeleteConfirmation(true),
+ cancelDelete: () => setInDeleteConfirmation(false),
+ };
+ },
+};
+
+const Transcript = ({
+ index,
+ language,
+ transcriptUrl,
+ // redux
+ deleteTranscript,
+}) => {
+ const { inDeleteConfirmation, launchDeleteConfirmation, cancelDelete } = module.hooks.setUpDeleteConfirmation();
+ return (
+ // eslint-disable-next-line react/jsx-no-useless-fragment
+ <>
+ {inDeleteConfirmation
+ ? (
+
+ )} />
+
+
+
+
+
+
+
+
+ {
+ deleteTranscript({ language });
+ // stop showing the card
+ cancelDelete();
+ }}
+ >
+
+
+
+
+
+ )
+ : (
+
+
+
+ { language === '' ? (
+ launchDeleteConfirmation()}
+ />
+ ) : (
+
+ )}
+
+ )}
+ >
+ );
+};
+
+Transcript.defaultProps = {
+ transcriptUrl: undefined,
+};
+
+Transcript.propTypes = {
+ index: PropTypes.number.isRequired,
+ language: PropTypes.string.isRequired,
+ transcriptUrl: PropTypes.string,
+ deleteTranscript: PropTypes.func.isRequired,
+};
+
+export const mapStateToProps = () => ({
+});
+export const mapDispatchToProps = {
+ deleteTranscript: thunkActions.video.deleteTranscript,
+};
+
+export const TranscriptInternal = Transcript; // For testing only
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Transcript));
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/Transcript.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/Transcript.test.jsx
new file mode 100644
index 0000000000..ed16d2f530
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/Transcript.test.jsx
@@ -0,0 +1,98 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import * as module from './Transcript';
+
+import { MockUseState } from '../../../../../../testUtils';
+
+const Transcript = module.TranscriptInternal;
+
+jest.mock('./LanguageSelector', () => 'LanguageSelector');
+jest.mock('./TranscriptActionMenu', () => 'TranscriptActionMenu');
+
+describe('Transcript Component', () => {
+ describe('state hooks', () => {
+ const state = new MockUseState(module.hooks);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ describe('state hooks', () => {
+ state.testGetter(state.keys.inDeleteConfirmation);
+ });
+
+ describe('setUpDeleteConfirmation hook', () => {
+ beforeEach(() => {
+ state.mock();
+ });
+ afterEach(() => {
+ state.restore();
+ });
+ test('inDeleteConfirmation: state values', () => {
+ expect(module.hooks.setUpDeleteConfirmation().inDeleteConfirmation).toEqual(false);
+ });
+ test('inDeleteConfirmation setters: launch', () => {
+ module.hooks.setUpDeleteConfirmation().launchDeleteConfirmation();
+ expect(state.setState[state.keys.inDeleteConfirmation]).toHaveBeenCalledWith(true);
+ });
+ test('inDeleteConfirmation setters: cancel', () => {
+ module.hooks.setUpDeleteConfirmation().cancelDelete();
+ expect(state.setState[state.keys.inDeleteConfirmation]).toHaveBeenCalledWith(false);
+ });
+ });
+ });
+
+ describe('component', () => {
+ describe('component', () => {
+ const props = {
+ index: 'sOmenUmBer',
+ language: 'lAnG',
+ deleteTranscript: jest.fn().mockName('thunkActions.video.deleteTranscript'),
+ };
+ afterAll(() => {
+ jest.clearAllMocks();
+ });
+ test('snapshots: renders as expected with default props: dont show confirm delete', () => {
+ jest.spyOn(module.hooks, 'setUpDeleteConfirmation').mockImplementationOnce(() => ({
+ inDeleteConfirmation: false,
+ launchDeleteConfirmation: jest.fn().mockName('launchDeleteConfirmation'),
+ cancelDelete: jest.fn().mockName('cancelDelete'),
+ }));
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with default props: dont show confirm delete, language is blank so delete is shown instead of action menu', () => {
+ jest.spyOn(module.hooks, 'setUpDeleteConfirmation').mockImplementationOnce(() => ({
+ inDeleteConfirmation: false,
+ launchDeleteConfirmation: jest.fn().mockName('launchDeleteConfirmation'),
+ cancelDelete: jest.fn().mockName('cancelDelete'),
+ }));
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with default props: show confirm delete', () => {
+ jest.spyOn(module.hooks, 'setUpDeleteConfirmation').mockImplementationOnce(() => ({
+ inDeleteConfirmation: true,
+ launchDeleteConfirmation: jest.fn().mockName('launchDeleteConfirmation'),
+ cancelDelete: jest.fn().mockName('cancelDelete'),
+ }));
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with transcriptUrl', () => {
+ jest.spyOn(module.hooks, 'setUpDeleteConfirmation').mockImplementationOnce(() => ({
+ inDeleteConfirmation: false,
+ launchDeleteConfirmation: jest.fn().mockName('launchDeleteConfirmation'),
+ cancelDelete: jest.fn().mockName('cancelDelete'),
+ }));
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.jsx
new file mode 100644
index 0000000000..46f51180bc
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.jsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect, useDispatch } from 'react-redux';
+
+import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
+import { Dropdown, Icon, IconButton } from '@openedx/paragon';
+import { MoreHoriz } from '@openedx/paragon/icons';
+
+import { thunkActions, selectors } from '../../../../../../data/redux';
+
+import { FileInput, fileInput } from '../../../../../../sharedComponents/FileInput';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './TranscriptActionMenu';
+import messages from './messages';
+
+export const hooks = {
+ replaceFileCallback: ({ language, dispatch }) => (file) => {
+ dispatch(thunkActions.video.replaceTranscript({
+ newFile: file,
+ newFilename: file.name,
+ language,
+ }));
+ },
+};
+
+const TranscriptActionMenu = ({
+ index,
+ language,
+ transcriptUrl,
+ launchDeleteConfirmation,
+ // redux
+ getTranscriptDownloadUrl,
+ buildTranscriptUrl,
+}) => {
+ const input = fileInput({ onAddFile: module.hooks.replaceFileCallback({ language, dispatch: useDispatch() }) });
+ const downloadLink = transcriptUrl ? buildTranscriptUrl({ transcriptUrl }) : getTranscriptDownloadUrl({ language });
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+TranscriptActionMenu.defaultProps = {
+ transcriptUrl: undefined,
+};
+
+TranscriptActionMenu.propTypes = {
+ index: PropTypes.number.isRequired,
+ language: PropTypes.string.isRequired,
+ transcriptUrl: PropTypes.string,
+ launchDeleteConfirmation: PropTypes.func.isRequired,
+ // redux
+ getTranscriptDownloadUrl: PropTypes.func.isRequired,
+ buildTranscriptUrl: PropTypes.func.isRequired,
+};
+
+export const mapStateToProps = (state) => ({
+ getTranscriptDownloadUrl: selectors.video.getTranscriptDownloadUrl(state),
+ buildTranscriptUrl: selectors.video.buildTranscriptUrl(state),
+});
+
+export const mapDispatchToProps = {
+ downloadTranscript: thunkActions.video.downloadTranscript,
+};
+
+export const TranscriptActionMenuInternal = TranscriptActionMenu; // For testing only
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TranscriptActionMenu));
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.test.jsx
new file mode 100644
index 0000000000..b23f5040e8
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.test.jsx
@@ -0,0 +1,100 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { thunkActions, selectors } from '../../../../../../data/redux';
+
+import * as module from './TranscriptActionMenu';
+
+const TranscriptActionMenu = module.TranscriptActionMenuInternal;
+
+jest.mock('react-redux', () => {
+ const dispatchFn = jest.fn().mockName('mockUseDispatch');
+ return {
+ ...jest.requireActual('react-redux'),
+ dispatch: dispatchFn,
+ useDispatch: jest.fn(() => dispatchFn),
+ };
+});
+
+jest.mock('../../../../../../data/redux', () => ({
+ thunkActions: {
+ video: {
+ deleteTranscript: jest.fn().mockName('thunkActions.video.deleteTranscript'),
+ replaceTranscript: jest.fn((args) => ({ replaceTranscript: args })).mockName('thunkActions.video.replaceTranscript'),
+ downloadTranscript: jest.fn().mockName('thunkActions.video.downloadTranscript'),
+ },
+ },
+ selectors: {
+ video: {
+ getTranscriptDownloadUrl: jest.fn(args => ({ getTranscriptDownloadUrl: args })).mockName('selectors.video.getTranscriptDownloadUrl'),
+ buildTranscriptUrl: jest.fn(args => ({ buildTranscriptUrl: args })).mockName('selectors.video.buildTranscriptUrl'),
+ },
+ },
+}));
+
+jest.mock('../../../../../../sharedComponents/FileInput', () => ({
+ FileInput: 'FileInput',
+ fileInput: jest.fn((args) => ({ click: jest.fn().mockName('click input'), onAddFile: args.onAddFile })),
+}));
+
+describe('TranscriptActionMenu', () => {
+ describe('hooks', () => {
+ describe('replaceFileCallback', () => {
+ const lang1Code = 'coDe';
+ const mockFile = 'sOmeEbytes';
+ const mockFileName = 'one.srt';
+ const mockEvent = { mockFile, name: mockFileName };
+ const mockDispatch = jest.fn();
+ const result = { newFile: { mockFile, name: mockFileName }, newFilename: mockFileName, language: lang1Code };
+
+ test('it dispatches the correct thunk', () => {
+ const cb = module.hooks.replaceFileCallback({
+ dispatch: mockDispatch, language: lang1Code,
+ });
+ cb(mockEvent);
+ expect(thunkActions.video.replaceTranscript).toHaveBeenCalledWith(result);
+ expect(mockDispatch).toHaveBeenCalledWith({ replaceTranscript: result });
+ });
+ });
+ });
+
+ describe('Snapshots', () => {
+ const props = {
+ index: 'sOmenUmBer',
+ language: 'lAnG',
+ launchDeleteConfirmation: jest.fn().mockName('launchDeleteConfirmation'),
+ // redux
+ getTranscriptDownloadUrl: jest.fn().mockName('selectors.video.getTranscriptDownloadUrl'),
+ buildTranscriptUrl: jest.fn().mockName('selectors.video.buildTranscriptUrl'),
+ };
+ afterAll(() => {
+ jest.clearAllMocks();
+ });
+ test('snapshots: renders as expected with default props: dont show confirm delete', () => {
+ jest.spyOn(module.hooks, 'replaceFileCallback').mockImplementationOnce(() => jest.fn().mockName('module.hooks.replaceFileCallback'));
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with transcriptUrl props: dont show confirm delete', () => {
+ jest.spyOn(module.hooks, 'replaceFileCallback').mockImplementationOnce(() => jest.fn().mockName('module.hooks.replaceFileCallback'));
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ });
+ describe('mapStateToProps', () => {
+ const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
+ test('getTranscriptDownloadUrl from video.getTranscriptDownloadUrl', () => {
+ expect(
+ module.mapStateToProps(testState).getTranscriptDownloadUrl,
+ ).toEqual(selectors.video.getTranscriptDownloadUrl(testState));
+ });
+ });
+ describe('mapDispatchToProps', () => {
+ test('deleteTranscript from thunkActions.video.deleteTranscript', () => {
+ expect(module.mapDispatchToProps.downloadTranscript).toEqual(thunkActions.video.downloadTranscript);
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/ImportTranscriptCard.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/ImportTranscriptCard.test.jsx.snap
new file mode 100644
index 0000000000..e71ea14cbf
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/ImportTranscriptCard.test.jsx.snap
@@ -0,0 +1,40 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ImportTranscriptCard snapshots snapshots: renders as expected with default props 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/LanguageSelector.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/LanguageSelector.test.jsx.snap
new file mode 100644
index 0000000000..281e711849
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/LanguageSelector.test.jsx.snap
@@ -0,0 +1,111 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`LanguageSelector snapshot transcript option 1`] = `
+
+
+
+
+ kLinGon
+
+
+
+
+
+
+ kLinGon
+
+
+
+ eLvIsh
+
+
+ sImLisH
+
+
+
+
+
+`;
+
+exports[`LanguageSelector snapshots -- no transcripts no Open Languages, all should be disabled 1`] = `
+
+
+
+
+ kLinGon
+
+
+
+
+
+
+ kLinGon
+
+
+
+ eLvIsh
+
+
+ sImLisH
+
+
+
+
+
+`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/Transcript.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/Transcript.test.jsx.snap
new file mode 100644
index 0000000000..f7bf72c1a2
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/Transcript.test.jsx.snap
@@ -0,0 +1,103 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Transcript Component component component snapshots: renders as expected with default props: dont show confirm delete 1`] = `
+
+
+
+
+
+
+
+`;
+
+exports[`Transcript Component component component snapshots: renders as expected with default props: dont show confirm delete, language is blank so delete is shown instead of action menu 1`] = `
+
+
+
+
+
+
+
+`;
+
+exports[`Transcript Component component component snapshots: renders as expected with default props: show confirm delete 1`] = `
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Transcript Component component component snapshots: renders as expected with transcriptUrl 1`] = `
+
+
+
+
+
+
+
+`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/TranscriptActionMenu.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/TranscriptActionMenu.test.jsx.snap
new file mode 100644
index 0000000000..f9c0bc5336
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/TranscriptActionMenu.test.jsx.snap
@@ -0,0 +1,109 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TranscriptActionMenu Snapshots snapshots: renders as expected with default props: dont show confirm delete 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`TranscriptActionMenu Snapshots snapshots: renders as expected with transcriptUrl props: dont show confirm delete 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..904d6433b2
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,899 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TranscriptWidget component snapshots snapshot: renders ErrorAlert with delete error message 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ placement="top"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`TranscriptWidget component snapshots snapshot: renders ErrorAlert with upload error message 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ placement="top"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`TranscriptWidget component snapshots snapshots: renders as expected with allowTranscriptDownloads true 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ placement="top"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`TranscriptWidget component snapshots snapshots: renders as expected with allowTranscriptImport true 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`TranscriptWidget component snapshots snapshots: renders as expected with default props 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`TranscriptWidget component snapshots snapshots: renders as expected with showTranscriptByDefault true 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ placement="top"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`TranscriptWidget component snapshots snapshots: renders as expected with transcript urls 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`TranscriptWidget component snapshots snapshots: renders as expected with transcripts 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ placement="top"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`TranscriptWidget component snapshots snapshots: renders as expected with transcripts and urls 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ placement="top"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/index.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/index.jsx
new file mode 100644
index 0000000000..b734b31ff4
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/index.jsx
@@ -0,0 +1,217 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import {
+ FormattedMessage,
+ injectIntl,
+ intlShape,
+} from '@edx/frontend-platform/i18n';
+import {
+ Form,
+ Button,
+ Stack,
+ Icon,
+ OverlayTrigger,
+ Tooltip,
+ ActionRow,
+} from '@openedx/paragon';
+import { Add, InfoOutline } from '@openedx/paragon/icons';
+
+import { actions, selectors } from '../../../../../../data/redux';
+import messages from './messages';
+
+import { RequestKeys } from '../../../../../../data/constants/requests';
+import { in8lTranscriptLanguages } from '../../../../../../data/constants/video';
+
+import ErrorAlert from '../../../../../../sharedComponents/ErrorAlerts/ErrorAlert';
+import CollapsibleFormWidget from '../CollapsibleFormWidget';
+
+import ImportTranscriptCard from './ImportTranscriptCard';
+import Transcript from './Transcript';
+import { ErrorContext } from '../../../../hooks';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+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 });
+ }
+ if (isDeleteError) {
+ setError({ ...error, deleteError: messages.deleteTranscriptError.defaultMessage });
+ }
+ },
+ transcriptLanguages: (transcripts, intl) => {
+ const languages = [];
+ if (transcripts && transcripts.length > 0) {
+ const fullTextTranslatedStrings = in8lTranscriptLanguages(intl);
+ transcripts.forEach(transcript => {
+ if (!(transcript === '')) {
+ languages.push(fullTextTranslatedStrings[transcript]);
+ }
+ });
+
+ return languages.join(', ');
+ }
+ return 'None';
+ },
+ hasTranscripts: (transcripts) => {
+ if (transcripts && transcripts.length > 0) {
+ return true;
+ }
+ return false;
+ },
+ onAddNewTranscript: ({ transcripts, updateField }) => {
+ // keep blank lang code for now, will be updated once lang is selected.
+ if (!transcripts) {
+ updateField({ transcripts: [''] });
+ return;
+ }
+ const newTranscripts = [...transcripts, ''];
+ updateField({ transcripts: newTranscripts });
+ },
+};
+
+/**
+ * Collapsible Form widget controlling video transcripts
+ */
+const TranscriptWidget = ({
+ // redux
+ transcripts,
+ selectedVideoTranscriptUrls,
+ allowTranscriptDownloads,
+ showTranscriptByDefault,
+ allowTranscriptImport,
+ updateField,
+ isUploadError,
+ isDeleteError,
+ // injected
+ intl,
+}) => {
+ const [error] = React.useContext(ErrorContext).transcripts;
+ const [showImportCard, setShowImportCard] = React.useState(true);
+ const fullTextLanguages = module.hooks.transcriptLanguages(transcripts, intl);
+ const hasTranscripts = module.hooks.hasTranscripts(transcripts);
+
+ return (
+
+
+
+
+
+
+
+
+ {hasTranscripts ? (
+
+ {transcripts.map((language, index) => (
+
+ ))}
+
+ updateField({ allowTranscriptDownloads: e.target.checked })}
+ >
+
+
+
+
+
+
+
+ )}
+ >
+
+
+
+
+ updateField({ showTranscriptByDefault: e.target.checked })}
+ >
+
+
+
+
+
+ ) : (
+ <>
+
+ {showImportCard && allowTranscriptImport
+ ?
+ : null}
+ >
+ )}
+
+ module.hooks.onAddNewTranscript({ transcripts, updateField })}
+ >
+
+
+
+
+
+ );
+};
+
+TranscriptWidget.defaultProps = {
+ selectedVideoTranscriptUrls: {},
+};
+TranscriptWidget.propTypes = {
+ // redux
+ transcripts: PropTypes.arrayOf(PropTypes.string).isRequired,
+ selectedVideoTranscriptUrls: PropTypes.shape(),
+ allowTranscriptDownloads: PropTypes.bool.isRequired,
+ showTranscriptByDefault: PropTypes.bool.isRequired,
+ allowTranscriptImport: PropTypes.bool.isRequired,
+ updateField: PropTypes.func.isRequired,
+ isUploadError: PropTypes.bool.isRequired,
+ isDeleteError: PropTypes.bool.isRequired,
+ intl: PropTypes.shape(intlShape).isRequired,
+};
+export const mapStateToProps = (state) => ({
+ transcripts: selectors.video.transcripts(state),
+ selectedVideoTranscriptUrls: selectors.video.selectedVideoTranscriptUrls(state),
+ allowTranscriptDownloads: selectors.video.allowTranscriptDownloads(state),
+ showTranscriptByDefault: selectors.video.showTranscriptByDefault(state),
+ allowTranscriptImport: selectors.video.allowTranscriptImport(state),
+ isUploadError: selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadTranscript }),
+ isDeleteError: selectors.requests.isFailed(state, { requestKey: RequestKeys.deleteTranscript }),
+});
+
+export const mapDispatchToProps = (dispatch) => ({
+ updateField: (stateUpdate) => dispatch(actions.video.updateField(stateUpdate)),
+});
+
+export const TranscriptWidgetInternal = TranscriptWidget; // For testing only
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TranscriptWidget));
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/index.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/index.test.jsx
new file mode 100644
index 0000000000..7e0c54effd
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/index.test.jsx
@@ -0,0 +1,195 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { RequestKeys } from '../../../../../../data/constants/requests';
+
+import { formatMessage } from '../../../../../../testUtils';
+import { actions, selectors } from '../../../../../../data/redux';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './index';
+
+const TranscriptWidget = module.TranscriptWidgetInternal;
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useContext: jest.fn(() => ({ transcripts: ['error.transcripts', jest.fn().mockName('error.setTranscripts')] })),
+}));
+
+jest.mock('../../../../../../data/redux', () => ({
+ actions: {
+ video: {
+ updateField: jest.fn().mockName('actions.video.updateField'),
+ },
+ },
+ thunkActions: {
+ video: {
+ deleteTranscript: jest.fn().mockName('thunkActions.video.deleteTranscript'),
+ },
+ },
+
+ selectors: {
+ video: {
+ transcripts: jest.fn(state => ({ transcripts: state })),
+ selectedVideoTranscriptUrls: jest.fn(state => ({ selectedVideoTranscriptUrls: state })),
+ allowTranscriptDownloads: jest.fn(state => ({ allowTranscriptDownloads: state })),
+ showTranscriptByDefault: jest.fn(state => ({ showTranscriptByDefault: state })),
+ allowTranscriptImport: jest.fn(state => ({ allowTranscriptImport: state })),
+ },
+ requests: {
+ isFailed: jest.fn(state => ({ isFailed: state })),
+ },
+ },
+}));
+jest.mock('../CollapsibleFormWidget', () => 'CollapsibleFormWidget');
+jest.mock('./Transcript', () => 'Transcript');
+
+describe('TranscriptWidget', () => {
+ describe('hooks', () => {
+ describe('transcriptLanguages', () => {
+ test('empty list of transcripts returns ', () => {
+ expect(module.hooks.transcriptLanguages([])).toEqual('None');
+ });
+ test('unset gives none', () => {
+ expect(module.hooks.transcriptLanguages(['', ''])).toEqual('');
+ });
+ test('en gives English', () => {
+ expect(module.hooks.transcriptLanguages(['en'])).toEqual('English');
+ });
+ test('en, FR gives English, French', () => {
+ expect(module.hooks.transcriptLanguages(['en', 'fr'])).toEqual('English, French');
+ });
+ });
+ describe('hasTranscripts', () => {
+ test('null returns false ', () => {
+ expect(module.hooks.hasTranscripts(null)).toEqual(false);
+ });
+ test('empty list returns false', () => {
+ expect(module.hooks.hasTranscripts([])).toEqual(false);
+ });
+ test('content returns true', () => {
+ expect(module.hooks.hasTranscripts(['en'])).toEqual(true);
+ });
+ });
+ describe('onAddNewTranscript', () => {
+ const mockUpdateField = jest.fn();
+ test('null returns [empty string] ', () => {
+ module.hooks.onAddNewTranscript({ transcripts: null, updateField: mockUpdateField });
+ expect(mockUpdateField).toHaveBeenCalledWith({ transcripts: [''] });
+ });
+ test(' transcripts return list with blank added', () => {
+ const mocklist = ['en', 'fr', 3];
+ module.hooks.onAddNewTranscript({ transcripts: mocklist, updateField: mockUpdateField });
+
+ expect(mockUpdateField).toHaveBeenCalledWith({ transcripts: ['en', 'fr', 3, ''] });
+ });
+ });
+ });
+
+ describe('component', () => {
+ const props = {
+ error: {},
+ subtitle: 'SuBTItle',
+ title: 'tiTLE',
+ intl: { formatMessage },
+ transcripts: [],
+ selectedVideoTranscriptUrls: {},
+ allowTranscriptDownloads: false,
+ showTranscriptByDefault: false,
+ allowTranscriptImport: false,
+ updateField: jest.fn().mockName('args.updateField'),
+ isUploadError: false,
+ isDeleteError: false,
+ };
+
+ describe('snapshots', () => {
+ test('snapshots: renders as expected with default props', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with allowTranscriptImport true', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with transcripts', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with transcript urls', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with transcripts and urls', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with allowTranscriptDownloads true', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshots: renders as expected with showTranscriptByDefault true', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshot: renders ErrorAlert with upload error message', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshot: renders ErrorAlert with delete error message', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ });
+ describe('mapStateToProps', () => {
+ const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
+ test('transcripts from video.transcript', () => {
+ expect(
+ module.mapStateToProps(testState).transcripts,
+ ).toEqual(selectors.video.transcripts(testState));
+ });
+ test('allowTranscriptDownloads from video.allowTranscriptDownloads', () => {
+ expect(
+ module.mapStateToProps(testState).allowTranscriptDownloads,
+ ).toEqual(selectors.video.allowTranscriptDownloads(testState));
+ });
+ test('showTranscriptByDefault from video.showTranscriptByDefault', () => {
+ expect(
+ module.mapStateToProps(testState).showTranscriptByDefault,
+ ).toEqual(selectors.video.showTranscriptByDefault(testState));
+ });
+ test('allowTranscriptImport from video.allowTranscriptImport', () => {
+ expect(
+ module.mapStateToProps(testState).allowTranscriptImport,
+ ).toEqual(selectors.video.allowTranscriptImport(testState));
+ });
+ test('isUploadError from requests.isFinished', () => {
+ expect(
+ module.mapStateToProps(testState).isUploadError,
+ ).toEqual(selectors.requests.isFailed(testState, { requestKey: RequestKeys.uploadTranscript }));
+ });
+ test('isDeleteError from requests.isFinished', () => {
+ expect(
+ module.mapStateToProps(testState).isDeleteError,
+ ).toEqual(selectors.requests.isFailed(testState, { requestKey: RequestKeys.deleteTranscript }));
+ });
+ });
+ describe('mapDispatchToProps', () => {
+ const dispatch = jest.fn();
+ test('updateField from actions.video.updateField', () => {
+ expect(module.mapDispatchToProps.updateField).toEqual(dispatch(actions.video.updateField));
+ });
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/messages.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/messages.js
new file mode 100644
index 0000000000..57cfeeec90
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/messages.js
@@ -0,0 +1,122 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+
+ title: {
+ id: 'authoring.videoeditor.transcripts.title',
+ defaultMessage: 'Transcripts',
+ description: 'Title for transcripts widget',
+ },
+ uploadButtonLabel: {
+ id: 'authoring.videoeditor.transcripts.upload.label',
+ defaultMessage: 'Add a transcript',
+ description: 'Label for upload button',
+ },
+ addFirstTranscript: {
+ id: 'authoring.videoeditor.transcripts.upload.firstTranscriptMessage',
+ defaultMessage: 'Add video transcripts (.srt files only) for improved accessibility.',
+ description: 'Message for adding first transcript',
+ },
+ allowDownloadCheckboxLabel: {
+ id: 'authoring.videoeditor.transcripts.allowDownloadCheckboxLabel',
+ defaultMessage: 'Allow transcript downloads',
+ description: 'Label for allow transcript downloads checkbox',
+ },
+ showByDefaultCheckboxLabel: {
+ id: 'authoring.videoeditor.transcripts.upload.showByDefaultCheckboxLabel',
+ defaultMessage: 'Show transcript in the video player by default',
+ description: 'Label for show by default checkbox',
+ },
+ tooltipMessage: {
+ id: 'authoring.videoeditor.transcripts.upload.allowDownloadTooltipMessage',
+ defaultMessage: 'Learners will see a link to download the transcript below the video.',
+ description: 'Message for show by default checkbox',
+ },
+ transcriptTypeError: {
+ id: 'authoring.videoeditor.transcript.error.transcriptTypeError',
+ defaultMessage: 'Only SRT file can be uploaded',
+ description: 'Message presented to user when image fails to upload',
+ },
+ uploadTranscriptError: {
+ id: 'authoring.videoeditor.transcript.error.uploadTranscriptError',
+ defaultMessage: 'Failed to upload transcript. Please try again.',
+ description: 'Message presented to user when transcript fails to upload',
+ },
+ fileSizeError: {
+ id: 'authoring.videoeditor.transcript.error.fileSizeError',
+ defaultMessage: 'Transcript file size exeeds the maximum. Please try again.',
+ description: 'Message presented to user when transcript file size is too large',
+ },
+ deleteTranscript: {
+ id: 'authoring.videoeditor.transcript.deleteTranscript',
+ defaultMessage: 'Delete',
+ description: 'Message Presented To user for action to delete transcript',
+ },
+ deleteTranscriptError: {
+ id: 'authoring.videoeditor.transcript.error.deleteTranscriptError',
+ defaultMessage: 'Failed to delete transcript. Please try again.',
+ description: 'Message presented to user when transcript fails to delete',
+ },
+ replaceTranscript: {
+ id: 'authoring.videoeditor.transcript.replaceTranscript',
+ defaultMessage: 'Replace',
+ description: 'Message Presented To user for action to replace transcript',
+ },
+ downloadTranscript: {
+ id: 'authoring.videoeditor.transcript.downloadTranscript',
+ defaultMessage: 'Download',
+ description: 'Message Presented To user for action to download transcript',
+ },
+ languageSelectLabel: {
+ id: 'authoring.videoeditor.transcripts.languageSelectLabel',
+ defaultMessage: 'Languages',
+ description: 'Label For Dropdown, which allows users to set the language associtated with a transcript',
+ },
+ languageSelectPlaceholder: {
+ id: 'authoring.videoeditor.transcripts.languageSelectPlaceholder',
+ defaultMessage: 'Select Language',
+ description: 'Placeholder For Dropdown, which allows users to set the language associtated with a transcript',
+ },
+ cancelDeleteLabel: {
+ id: 'authoring.videoeditor.transcripts.cancelDeleteLabel',
+ defaultMessage: 'Cancel',
+ description: 'Label For Button, which allows users to stop the process of deleting a transcript',
+ },
+ confirmDeleteLabel: {
+ id: 'authoring.videoeditor.transcripts.confirmDeleteLabel',
+ defaultMessage: 'Delete',
+ description: 'Label For Button, which allows users to confirm the process of deleting a transcript',
+ },
+ deleteConfirmationMessage: {
+ id: 'authoring.videoeditor.transcripts.deleteConfirmationMessage',
+ defaultMessage: 'Are you sure you want to delete this transcript?',
+ description: 'Warning which allows users to select next step in the process of deleting a transcript',
+ },
+ deleteConfirmationHeader: {
+ id: 'authoring.videoeditor.transcripts.deleteConfirmationTitle',
+ defaultMessage: 'Delete this transcript?',
+ description: 'Title for Warning which allows users to select next step in the process of deleting a transcript',
+ },
+ fileTypeWarning: {
+ id: 'authoring.videoeditor.transcripts.fileTypeWarning',
+ defaultMessage: 'Only SRT files can be uploaded. Please select a file ending in .srt to upload.',
+ description: 'Message warning users to only upload .srt files',
+ },
+ importButtonLabel: {
+ id: 'authoring.videoEditor.transcripts.importButton.label',
+ defaultMessage: 'Import Transcript',
+ description: 'Label for youTube import transcript button',
+ },
+ importHeader: {
+ id: 'authoring.videoEditor.transcripts.importCard.header',
+ defaultMessage: 'Import transcript from YouTube?',
+ description: 'Header for import transcript card',
+ },
+ importMessage: {
+ id: 'authoring.videoEditor.transcrtipts.importCard.message',
+ defaultMessage: 'We found transcript for this video on YouTube. Would you like to import it now?',
+ description: 'Message for import transcript card asking user if they want to import transcript',
+ },
+});
+
+export default messages;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoPreviewWidget/LanguageNamesWidget.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoPreviewWidget/LanguageNamesWidget.jsx
new file mode 100644
index 0000000000..2647d07c87
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoPreviewWidget/LanguageNamesWidget.jsx
@@ -0,0 +1,32 @@
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { Icon } from '@openedx/paragon';
+import { ClosedCaptionOff, ClosedCaption } from '@openedx/paragon/icons';
+import PropTypes from 'prop-types';
+import React from 'react';
+import messages from '../messages';
+import { hooks as transcriptHooks } from '../TranscriptWidget';
+
+const LanguageNamesWidget = ({ transcripts, intl }) => {
+ let icon = ClosedCaptionOff;
+ const hasTranscripts = transcriptHooks.hasTranscripts(transcripts);
+ let message = intl.formatMessage(messages.noTranscriptsAdded);
+
+ if (hasTranscripts) {
+ message = transcriptHooks.transcriptLanguages(transcripts, intl);
+ icon = ClosedCaption;
+ }
+
+ return (
+
+
+ {message}
+
+ );
+};
+
+LanguageNamesWidget.propTypes = {
+ intl: intlShape.isRequired,
+ transcripts: PropTypes.arrayOf(PropTypes.string).isRequired,
+};
+
+export default injectIntl(LanguageNamesWidget);
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoPreviewWidget/hooks.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoPreviewWidget/hooks.js
new file mode 100644
index 0000000000..1365df5360
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoPreviewWidget/hooks.js
@@ -0,0 +1,13 @@
+import messages from '../messages';
+import { parseYoutubeId } from '../../../../../../data/services/cms/api';
+
+function getVideoType(videoSource) {
+ if (parseYoutubeId(videoSource) !== null) {
+ return messages.videoTypeYoutube;
+ }
+ return messages.videoTypeOther;
+}
+
+export default {
+ getVideoType,
+};
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoPreviewWidget/index.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoPreviewWidget/index.jsx
new file mode 100644
index 0000000000..0c4a0f089a
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoPreviewWidget/index.jsx
@@ -0,0 +1,81 @@
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import {
+ Collapsible, Image, Stack, Hyperlink,
+} from '@openedx/paragon';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import { selectors } from '../../../../../../data/redux';
+import thumbnailMessages from '../ThumbnailWidget/messages';
+import hooks from './hooks';
+import LanguageNamesWidget from './LanguageNamesWidget';
+import videoThumbnail from '../../../../../../data/images/videoThumbnail.svg';
+
+const VideoPreviewWidget = ({
+ thumbnail,
+ videoSource,
+ transcripts,
+ blockTitle,
+ intl,
+}) => {
+ const imgRef = React.useRef();
+ const videoType = intl.formatMessage(hooks.getVideoType(videoSource));
+ const thumbnailImage = thumbnail || videoThumbnail;
+
+ return (
+
+
+
+
+
+ {blockTitle}
+
+ {videoType && (
+
+ {videoType}
+
+ )}
+
+
+
+
+ );
+};
+
+VideoPreviewWidget.propTypes = {
+ intl: intlShape.isRequired,
+ videoSource: PropTypes.string.isRequired,
+ thumbnail: PropTypes.string.isRequired,
+ transcripts: PropTypes.arrayOf(PropTypes.string).isRequired,
+ blockTitle: PropTypes.string.isRequired,
+};
+
+export const mapStateToProps = (state) => ({
+ transcripts: selectors.video.transcripts(state),
+ videoSource: selectors.video.videoSource(state),
+ thumbnail: selectors.video.thumbnail(state),
+ blockTitle: selectors.app.blockTitle(state),
+});
+
+export default injectIntl(connect(mapStateToProps)(VideoPreviewWidget));
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..04c7bf1202
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,307 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`VideoSourceWidget snapshots snapshots: renders as expected with default props 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ placement="top"
+ >
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`VideoSourceWidget snapshots snapshots: renders as expected with videoSharingEnabledForCourse=true 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ placement="top"
+ >
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/hooks.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/hooks.jsx
new file mode 100644
index 0000000000..fc3100c38f
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/hooks.jsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import { actions } from '../../../../../../data/redux';
+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),
+};
+
+export const sourceHooks = ({ dispatch, previousVideoId, setAlert }) => ({
+ updateVideoURL: (e, videoId) => {
+ const videoUrl = e.target.value;
+ dispatch(actions.video.updateField({ videoSource: videoUrl }));
+
+ const youTubeId = parseYoutubeId(videoUrl);
+ if (youTubeId) {
+ dispatch(requests.checkTranscriptsForImport({
+ videoId,
+ youTubeId,
+ onSuccess: (response) => {
+ if (response.data.command === 'import') {
+ dispatch(actions.video.updateField({
+ allowTranscriptImport: true,
+ }));
+ }
+ },
+ }));
+ }
+ },
+ updateVideoId: (e) => {
+ const updatedVideoId = e.target.value;
+ if (previousVideoId !== updatedVideoId && updatedVideoId) {
+ setAlert();
+ }
+ dispatch(actions.video.updateField({ videoId: updatedVideoId }));
+ },
+});
+
+export const fallbackHooks = ({ fallbackVideos, dispatch }) => ({
+ addFallbackVideo: () => dispatch(actions.video.updateField({ fallbackVideos: [...fallbackVideos, ''] })),
+ deleteFallbackVideo: (videoUrl) => {
+ const updatedFallbackVideos = fallbackVideos.splice(fallbackVideos.indexOf(videoUrl), 1);
+ dispatch(actions.video.updateField({ fallbackVideos: updatedFallbackVideos }));
+ },
+});
+
+export const videoIdChangeAlert = () => {
+ const [showVideoIdChangeAlert, setShowVideoIdChangeAlert] = state.showVideoIdChangeAlert(false);
+ return {
+ videoIdChangeAlert: {
+ show: showVideoIdChangeAlert,
+ set: () => setShowVideoIdChangeAlert(true),
+ dismiss: () => setShowVideoIdChangeAlert(false),
+ },
+ };
+};
+
+export default {
+ videoIdChangeAlert,
+ sourceHooks,
+ fallbackHooks,
+};
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/hooks.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/hooks.test.jsx
new file mode 100644
index 0000000000..096f98010b
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/hooks.test.jsx
@@ -0,0 +1,174 @@
+import { dispatch } from 'react-redux';
+import { actions } from '../../../../../../data/redux';
+import { MockUseState } from '../../../../../../testUtils';
+import * as requests from '../../../../../../data/redux/thunkActions/requests';
+import * as hooks from './hooks';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useRef: jest.fn(val => ({ current: val })),
+ useEffect: jest.fn(),
+ useCallback: (cb, prereqs) => ({ cb, prereqs }),
+}));
+
+jest.mock('react-redux', () => {
+ const dispatchFn = jest.fn();
+ return {
+ ...jest.requireActual('react-redux'),
+ useSelector: jest.fn(),
+ dispatch: dispatchFn,
+ useDispatch: jest.fn(() => dispatchFn),
+ };
+});
+
+jest.mock('../../../../../../data/redux', () => ({
+ actions: {
+ video: {
+ updateField: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('../../../../../../data/redux/thunkActions/requests', () => ({
+ checkTranscriptsForImport: jest.fn(),
+}));
+
+const state = new MockUseState(hooks);
+
+const youtubeId = 'yOuTuBEiD';
+const youtubeUrl = `https://youtu.be/${youtubeId}`;
+
+describe('VideoEditorHandout hooks', () => {
+ let hook;
+ describe('state hooks', () => {
+ state.testGetter(state.keys.showVideoIdChangeAlert);
+ });
+ describe('sourceHooks', () => {
+ const e = { target: { value: 'soMEvALuE' } };
+ beforeEach(() => {
+ hook = hooks.sourceHooks({
+ dispatch,
+ previousVideoId: 'soMEvALuE',
+ setAlert: jest.fn(),
+ });
+ });
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+ describe('updateVideoURL', () => {
+ it('dispatches updateField action with new videoSource', () => {
+ hook.updateVideoURL(e);
+ expect(dispatch).toHaveBeenCalledWith(
+ actions.video.updateField({
+ videoSource: e.target.value,
+ }),
+ );
+ });
+ it('dispatches checkTranscriptsForImport request with new YouTube videoSource', () => {
+ e.target.value = youtubeUrl;
+ hook.updateVideoURL(e, 'video-id');
+ expect(requests.checkTranscriptsForImport).toHaveBeenCalledWith({
+ videoId: 'video-id',
+ youTubeId: youtubeId,
+ onSuccess: expect.anything(),
+ });
+ });
+ it('dispatches updateField video action when checkTranscriptsForImport onSuccess command is import', () => {
+ e.target.value = youtubeUrl;
+ hook.updateVideoURL(e, 'video-id');
+
+ const { onSuccess } = requests.checkTranscriptsForImport.mock.calls[0][0];
+ onSuccess({ data: { command: 'import' } });
+
+ expect(actions.video.updateField).toHaveBeenCalledWith({
+ allowTranscriptImport: true,
+ });
+ });
+ it('does not dispatch updateField video action when checkTranscriptsForImport onSuccess command is not import', () => {
+ e.target.value = youtubeUrl;
+ hook.updateVideoURL(e, 'video-id');
+
+ const { onSuccess } = requests.checkTranscriptsForImport.mock.calls[0][0];
+ onSuccess({ data: { command: 'anything else' } });
+
+ expect(actions.video.updateField).not.toHaveBeenCalledWith({
+ allowTranscriptImport: true,
+ });
+ });
+ });
+ describe('updateVideoId', () => {
+ it('dispatches updateField action with new videoId', () => {
+ hook.updateVideoId({ target: { value: 'newVideoId' } });
+ expect(dispatch).toHaveBeenCalledWith(
+ actions.video.updateField({
+ videoId: e.target.value,
+ }),
+ );
+ });
+ it('dispatches updateField action with empty string', () => {
+ hook.updateVideoId({ target: { value: '' } });
+ expect(dispatch).toHaveBeenCalledWith(
+ actions.video.updateField({
+ videoId: e.target.value,
+ }),
+ );
+ });
+ it('dispatches updateField action with previousVideoId', () => {
+ hook.updateVideoId({ target: { value: 'soMEvALuE' } });
+ expect(dispatch).toHaveBeenCalledWith(
+ actions.video.updateField({
+ videoId: e.target.value,
+ }),
+ );
+ });
+ });
+ });
+
+ describe('fallbackHooks', () => {
+ const videoUrl = 'sOmERAndoMuRl1';
+ const fallbackVideos = ['sOmERAndoMuRl1', 'sOmERAndoMuRl2', 'sOmERAndoMuRl1', ''];
+ beforeEach(() => {
+ hook = hooks.fallbackHooks({ fallbackVideos, dispatch });
+ });
+ describe('addFallbackVideo', () => {
+ it('dispatches updateField action with updated array appended by a new empty element', () => {
+ hook.addFallbackVideo();
+ expect(dispatch).toHaveBeenCalledWith(
+ actions.video.updateField({
+ fallbackVideos: [...fallbackVideos, ''],
+ }),
+ );
+ });
+ });
+ describe('deleteFallbackVideo', () => {
+ it('dispatches updateField action with updated array with videoUrl removed', () => {
+ const updatedFallbackVideos = ['sOmERAndoMuRl2', 'sOmERAndoMuRl1', ''];
+ hook.deleteFallbackVideo(videoUrl);
+ expect(dispatch).toHaveBeenCalledWith(
+ actions.video.updateField({
+ fallbackVideos: updatedFallbackVideos,
+ }),
+ );
+ });
+ });
+ });
+ describe('videoIdChangeAlert', () => {
+ beforeEach(() => {
+ state.mock();
+ });
+ afterEach(() => {
+ state.restore();
+ });
+ test('showVideoIdChangeAlert: state values', () => {
+ expect(hooks.videoIdChangeAlert().videoIdChangeAlert.show).toEqual(false);
+ });
+ test('showVideoIdChangeAlert setters: set', () => {
+ hooks.videoIdChangeAlert().videoIdChangeAlert.set();
+ expect(state.setState[state.keys.showVideoIdChangeAlert]).toHaveBeenCalledWith(true);
+ });
+ test('showVideoIdChangeAlert setters: dismiss', () => {
+ hooks.videoIdChangeAlert().videoIdChangeAlert.dismiss();
+ expect(state.setState[state.keys.showVideoIdChangeAlert]).toHaveBeenCalledWith(false);
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/index.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/index.jsx
new file mode 100644
index 0000000000..85045f0810
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/index.jsx
@@ -0,0 +1,161 @@
+import React from 'react';
+import { useDispatch } from 'react-redux';
+
+import {
+ Form,
+ IconButtonWithTooltip,
+ ActionRow,
+ Icon,
+ Button,
+ Tooltip,
+ OverlayTrigger,
+} from '@openedx/paragon';
+import { DeleteOutline, InfoOutline, Add } from '@openedx/paragon/icons';
+import {
+ FormattedMessage,
+ injectIntl,
+ intlShape,
+} from '@edx/frontend-platform/i18n';
+
+import * as widgetHooks from '../hooks';
+import * as hooks from './hooks';
+import messages from './messages';
+
+import ErrorAlert from '../../../../../../sharedComponents/ErrorAlerts/ErrorAlert';
+import CollapsibleFormWidget from '../CollapsibleFormWidget';
+
+/**
+ * Collapsible Form widget controlling video source as well as fallback sources
+ */
+const VideoSourceWidget = ({
+ // injected
+ intl,
+}) => {
+ const dispatch = useDispatch();
+ const {
+ videoId,
+ videoSource: source,
+ fallbackVideos,
+ allowVideoDownloads: allowDownload,
+ } = widgetHooks.widgetValues({
+ dispatch,
+ fields: {
+ [widgetHooks.selectorKeys.videoSource]: widgetHooks.genericWidget,
+ [widgetHooks.selectorKeys.videoId]: widgetHooks.genericWidget,
+ [widgetHooks.selectorKeys.fallbackVideos]: widgetHooks.arrayWidget,
+ [widgetHooks.selectorKeys.allowVideoDownloads]: widgetHooks.genericWidget,
+ },
+ });
+ const { videoIdChangeAlert } = hooks.videoIdChangeAlert();
+ const { updateVideoId, updateVideoURL } = hooks.sourceHooks({
+ dispatch,
+ previousVideoId: videoId.formValue,
+ setAlert: videoIdChangeAlert.set,
+ });
+ const {
+ addFallbackVideo,
+ deleteFallbackVideo,
+ } = hooks.fallbackHooks({ fallbackVideos: fallbackVideos.formValue, dispatch });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ updateVideoURL(e, videoId.local)}
+ value={source.local}
+ />
+
+
+
+
+
+
+
+
+
+
+ {fallbackVideos.formValue.length > 0 ? fallbackVideos.formValue.map((videoUrl, index) => (
+
+
+ deleteFallbackVideo(videoUrl)}
+ />
+
+ )) : null}
+
+
+
+
+
+
+
+
+
+ )}
+ >
+
+
+
+
+
+
+ addFallbackVideo()}
+ >
+
+
+
+ );
+};
+VideoSourceWidget.propTypes = {
+ // injected
+ intl: intlShape.isRequired,
+};
+
+export const VideoSourceWidgetInternal = VideoSourceWidget; // For testing only
+export default injectIntl(VideoSourceWidget);
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/index.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/index.test.jsx
new file mode 100644
index 0000000000..c472e65129
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/index.test.jsx
@@ -0,0 +1,110 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { dispatch } from 'react-redux';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { formatMessage } from '../../../../../../testUtils';
+import { VideoSourceWidgetInternal as VideoSourceWidget } from '.';
+import * as hooks from './hooks';
+
+jest.mock('react-redux', () => {
+ const dispatchFn = jest.fn();
+ return {
+ ...jest.requireActual('react-redux'),
+ dispatch: dispatchFn,
+ useDispatch: jest.fn(() => dispatchFn),
+ };
+});
+
+jest.mock('../hooks', () => ({
+ selectorKeys: ['soMEkEy'],
+ widgetValues: jest.fn().mockReturnValue({
+ videoId: { onChange: jest.fn(), onBlur: jest.fn(), local: '' },
+ videoSource: { onChange: jest.fn(), onBlur: jest.fn(), local: '' },
+ fallbackVideos: {
+ formValue: ['somEUrL'],
+ onChange: jest.fn(),
+ onBlur: jest.fn(),
+ local: '',
+ },
+ allowVideoDownloads: { local: false, onCheckedChange: jest.fn() },
+ allowVideoSharing: { local: false, onCheckedChange: jest.fn() },
+ }),
+}));
+
+jest.mock('./hooks', () => ({
+ videoIdChangeAlert: jest.fn().mockReturnValue({
+ videoIdChangeAlert: {
+ set: (args) => ({ set: args }),
+ show: false,
+ dismiss: (args) => ({ dismiss: args }),
+ },
+ }),
+ sourceHooks: jest.fn().mockReturnValue({
+ updateVideoId: (args) => ({ updateVideoId: args }),
+ updateVideoURL: jest.fn().mockName('updateVideoURL'),
+ }),
+ fallbackHooks: jest.fn().mockReturnValue({
+ addFallbackVideo: jest.fn().mockName('addFallbackVideo'),
+ deleteFallbackVideo: jest.fn().mockName('deleteFallbackVideo'),
+ }),
+}));
+
+jest.mock('../../../../../../data/redux', () => ({
+ selectors: {
+ video: {
+ allow: jest.fn(state => ({ allowTranscriptImport: state })),
+ },
+ requests: {
+ isFailed: jest.fn(state => ({ isFailed: state })),
+ },
+ },
+}));
+
+describe('VideoSourceWidget', () => {
+ const props = {
+ // inject
+ intl: { formatMessage },
+ // redux
+ videoSharingEnabledForCourse: false,
+ };
+
+ describe('snapshots', () => {
+ describe('snapshots: renders as expected with', () => {
+ it('default props', () => {
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ it('videoSharingEnabledForCourse=true', () => {
+ const newProps = { ...props, videoSharingEnabledForCourse: true };
+ expect(
+ shallow( ).snapshot,
+ ).toMatchSnapshot();
+ });
+ });
+ });
+
+ describe('behavior inspection', () => {
+ let el;
+ let hook;
+ beforeEach(() => {
+ hook = hooks.sourceHooks({ dispatch, previousVideoId: 'someVideoId', setAlert: jest.fn() });
+ el = shallow( );
+ });
+ test('updateVideoId is tied to id field onBlur', () => {
+ const expected = hook.updateVideoId;
+ expect(el
+ // eslint-disable-next-line
+ .shallowWrapper.props.children[1].props.children[0].props.children[0]
+ .props.onBlur).toEqual(expected);
+ });
+ test('updateVideoURL is tied to url field onBlur', () => {
+ const { onBlur } = el
+ // eslint-disable-next-line
+ .shallowWrapper.props.children[1].props.children[0].props.children[2].props;
+ onBlur('onBlur event');
+ expect(hook.updateVideoURL).toHaveBeenCalledWith('onBlur event', '');
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/messages.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/messages.js
new file mode 100644
index 0000000000..61e1d9c5d5
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget/messages.js
@@ -0,0 +1,91 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+
+ titleLabel: {
+ id: 'authoring.videoeditor.videoSource.title.label',
+ defaultMessage: 'Video source',
+ description: 'Title for the video source widget',
+ },
+ videoIdLabel: {
+ id: 'authoring.videoeditor.videoSource.videoId.label',
+ defaultMessage: 'Video ID',
+ description: 'Label for video ID field',
+ },
+ videoIdFeedback: {
+ id: 'authoring.videoeditor.videoSource.videoId.feedback',
+ defaultMessage: 'If you were assigned a video ID by edX, enter the ID here.',
+ description: 'Feedback for video ID field',
+ },
+ videoUrlLabel: {
+ id: 'authoring.videoeditor.videoSource.videoUrl.label',
+ defaultMessage: 'Video URL',
+ description: 'Label for video URL field',
+ },
+ videoUrlFeedback: {
+ id: 'authoring.videoeditor.videoSource.videoUrl.feedback',
+ defaultMessage: `The URL for your video. This can be a YouTube URL, or a link
+ to an .mp4, .ogg, or .webm video file hosted elsewhere on the internet.`,
+ description: 'Feedback for video URL field',
+ },
+ videoIdChangeAlert: {
+ id: 'authoring.videoeditor.videoIdChangeAlert.message',
+ defaultMessage: 'The Video ID field has changed, please check the Video URL and fallback URL values and update them if necessary.',
+ description: 'Body message for the alert that appears when the video id has been changed.',
+ },
+ fallbackVideoTitle: {
+ id: 'authoring.videoeditor.videoSource.fallbackVideo.title',
+ defaultMessage: 'Fallback videos',
+ description: 'Title for the fallback videos section',
+ },
+ fallbackVideoMessage: {
+ id: 'authoring.videoeditor.videoSource.fallbackVideo.message',
+ defaultMessage: `To be sure all learners can access the video, edX
+ recommends providing additional videos in both .mp4 and
+ .webm formats. The first listed video compatible with the
+ learner's device will play.`,
+ description: 'Test explaining reason for fallback videos',
+ },
+ fallbackVideoLabel: {
+ id: 'authoring.videoeditor.videoSource.fallbackVideo.label',
+ defaultMessage: 'Video URL',
+ description: 'Label for fallback video url field',
+ },
+ deleteFallbackVideo: {
+ id: 'authoring.videoeditor.videoSource.deleteFallbackVideo',
+ defaultMessage: 'Delete',
+ description: 'Message Presented To user for action to delete fallback video',
+ },
+ allowDownloadCheckboxLabel: {
+ id: 'authoring.videoeditor.videoSource.allowDownloadCheckboxLabel',
+ defaultMessage: 'Allow video downloads',
+ description: 'Label for allow video downloads checkbox',
+ },
+ allowDownloadTooltipMessage: {
+ id: 'authoring.videoeditor.videoSource.allowDownloadTooltipMessage',
+ defaultMessage: `Allow learners to download versions of this video in
+ different formats if they cannot use the edX video player or do not have
+ access to YouTube.`,
+ description: 'Message for allow video downloads checkbox',
+ },
+ allowVideoSharingCheckboxLabel: {
+ id: 'authoring.videoeditor.videoSource.allowVideoSharingCheckboxLabel',
+ defaultMessage: 'Allow this video to be shared on social media.',
+ description: 'Label for allow shareable video checkbox',
+ },
+ allowVideoSharingTooltipMessage: {
+ id: 'authoring.videoeditor.videoSource.allowVideoSharingTooltipMessage',
+ defaultMessage: `Allow learners to share this video publicly on social media.
+ The video will be viewable by anyone, they will not need to enroll in the course
+ or even have an edX account. Links to the course about page and to enroll in the
+ course will appear alongside the video.`,
+ description: 'Message for allow shareable video checkbox',
+ },
+ addButtonLabel: {
+ id: 'authoring.videoeditor.videoSource.fallbackVideo.addButtonLabel',
+ defaultMessage: 'Add a video URL',
+ description: 'Label for add a video URL button',
+ },
+});
+
+export default messages;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/__snapshots__/CollapsibleFormWidget.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/__snapshots__/CollapsibleFormWidget.test.jsx.snap
new file mode 100644
index 0000000000..8da3c5f03c
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/__snapshots__/CollapsibleFormWidget.test.jsx.snap
@@ -0,0 +1,145 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`CollapsibleFormWidget render snapshots: renders as expected with default props 1`] = `
+
+
+
+
+
+ tiTLE
+
+
+ SuBTItle
+
+
+
+
+
+
+
+
+ tiTLE
+
+
+
+
+
+
+
+
+ Some test string
+
+
+
+`;
+
+exports[`CollapsibleFormWidget render snapshots: renders with open={true} when there is error 1`] = `
+
+
+
+
+
+ tiTLE
+
+
+ SuBTItle
+
+
+
+
+
+
+
+
+
+ tiTLE
+
+
+
+
+
+
+
+
+ Some test string
+
+
+
+`;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.js
new file mode 100644
index 0000000000..1c8bb1ff5a
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.js
@@ -0,0 +1,57 @@
+/**
+ * handleIndexEvent({ handler, transform })
+ * return a method that takes an index and returns an event handler of the given type
+ * that calls a transform with the given index and the incoming value.
+ * @param {func} handler - event handler (onValue, onChecked, etc)
+ * @param {func} transform - transform method taking an index and a new value
+ * @return {func} - event handler creator for index-tied values
+ */
+export const handleIndexEvent = ({ handler, transform }) => (index) => (
+ handler(val => transform(index, val))
+);
+
+/**
+ * handleIndexTransformEvent({ handler, setter, local, transform })
+ * return a method that takes an index and returns an event handler of the given type
+ * that calls a transform with the given index and the incoming value.
+ * @param {func} handler - event handler (onValue, onChecked, etc)
+ * @param {string|number|object} local - local hook values
+ * @param {func} setter - method that updates models based on event
+ * @param {func} transform - transform method taking an index and a new value
+ * @return {func} - event handler creator for index-tied values with separate setter and transforms
+ */
+export const handleIndexTransformEvent = ({
+ handler,
+ local,
+ setter,
+ transform,
+}) => (index) => (
+ handler(val => setter(transform(local, index, val)))
+);
+
+/**
+ * onValue(handler)
+ * returns an event handler that calls the given method with the event target value
+ * Intended for most basic input types.
+ * @param {func} handler - callback to receive the event value
+ * @return - event handler that calls passed handler with event.target.value
+ */
+export const onValue = (handler) => (e) => handler(e.target.value);
+
+/**
+ * onValue(handler)
+ * returns an event handler that calls the given method with the event target value
+ * Intended for checkbox input types.
+ * @param {func} handler - callback to receive the event value
+ * @return - event handler that calls passed handler with event.target.checked
+ */
+export const onChecked = (handler) => (e) => handler(e.target.checked);
+
+/**
+ * onEvent(handler)
+ * returns an event handler that calls the given method with the event
+ * Intended for most basic input types.
+ * @param {func} handler - callback to receive the event value
+ * @return - event handler that calls passed handler with event
+ */
+export const onEvent = (handler) => (e) => handler(e);
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.test.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.test.js
new file mode 100644
index 0000000000..895d152363
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.test.js
@@ -0,0 +1,57 @@
+import * as handlers from './handlers';
+
+const handler = jest.fn(cb => ({ handler: cb }));
+const transform = jest.fn((...args) => ({ transform: args }));
+const setter = jest.fn(val => ({ setter: val }));
+const index = 'test-index';
+const val = 'TEST value';
+const local = 'local-test-value';
+describe('Video Settings Modal event handler methods', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ describe('handleIndexEvent', () => {
+ describe('returned method', () => {
+ it('takes index and calls handler with transform handler based on index', () => {
+ expect(
+ handlers.handleIndexEvent({ handler, transform })(index).handler(val),
+ ).toEqual(transform(index, val));
+ });
+ });
+ });
+ describe('handleIndexTransformEvent', () => {
+ describe('returned method', () => {
+ it('takes index and calls handler with setter(transform(local, index, val))', () => {
+ expect(
+ handlers.handleIndexTransformEvent({
+ handler,
+ setter,
+ local,
+ transform,
+ })(index).handler(val),
+ ).toEqual(setter(transform(local, index, val)));
+ });
+ });
+ });
+ describe('onValue', () => {
+ describe('returned method', () => {
+ it('calls handler with event.target.value', () => {
+ expect(handlers.onValue(handler)({ target: { value: val } })).toEqual(handler(val));
+ });
+ });
+ });
+ describe('onChecked', () => {
+ describe('returned method', () => {
+ it('calls handler with event.target.checked', () => {
+ expect(handlers.onChecked(handler)({ target: { checked: val } })).toEqual(handler(val));
+ });
+ });
+ });
+ describe('onEvent', () => {
+ describe('returned method', () => {
+ it('calls handler with event', () => {
+ expect(handlers.onEvent(handler)(val)).toEqual(handler(val));
+ });
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js
new file mode 100644
index 0000000000..d0b8503e2b
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js
@@ -0,0 +1,260 @@
+import {
+ useCallback,
+ useState,
+ useEffect,
+} from 'react';
+import { useSelector } from 'react-redux';
+
+import { StrictDict, keyStore } from '../../../../../utils';
+import { actions, selectors } from '../../../../../data/redux';
+
+import {
+ handleIndexTransformEvent,
+ onValue,
+ onChecked,
+} from './handlers';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './hooks';
+
+export const selectorKeys = keyStore(selectors.video);
+
+export const state = StrictDict(
+ [
+ selectorKeys.videoSource,
+ selectorKeys.videoId,
+ selectorKeys.fallbackVideos,
+ selectorKeys.allowVideoDownloads,
+ selectorKeys.allowVideoSharing,
+
+ selectorKeys.thumbnail,
+
+ selectorKeys.transcripts,
+ selectorKeys.allowTranscriptDownloads,
+ selectorKeys.showTranscriptByDefault,
+
+ selectorKeys.duration,
+
+ selectorKeys.handout,
+
+ selectorKeys.licenseType,
+ selectorKeys.licenseDetails,
+ ].reduce(
+ (obj, key) => ({ ...obj, [key]: (val) => useState(val) }),
+ {},
+ ),
+);
+
+/**
+ * updateArray(array, index, val)
+ * Returns a new array with the element at replaced with
+ * @param {any[]} array - array of values
+ * @param {number} index - array index to replace
+ * @param {any} val - new value
+ * @return {any[]} - new array with element at index replaced with val
+ */
+export const updatedArray = (array, index, val) => {
+ const newArray = [...array];
+ newArray.splice(index, 1, val);
+ return newArray;
+};
+
+/**
+ * updateObject(object, index, val)
+ * Returns a new object with the element at replaced with
+ * @param {object} object - object of values
+ * @param {string} index - object index to replace
+ * @param {any} val - new value
+ * @return {any[]} - new object with element at index replaced with val
+ */
+export const updatedObject = (obj, index, val) => ({ ...obj, [index]: val });
+
+/**
+ * updateFormField({ dispatch, key })(val)
+ * Creates a callback to update a given form field based on an incoming value.
+ * @param {func} dispatch - redux dispatch method
+ * @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 })),
+ [],
+);
+
+/**
+ * valueHooks({ dispatch, key })
+ * returns local and redux state associated with the given data key, as well as methods
+ * to update either or both of those.
+ * @param {string} key - redux video state key
+ * @param {func} dispatch - redux dispatch method
+ * @return {object} - hooks based on the local and redux value associated with the given key
+ * formValue - value state in redux
+ * setFormValue - sets form field in redux
+ * local - value state in hook
+ * setLocal - sets form field in hook
+ * 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);
+ setFormValue(val);
+ },
+ [setLocal, setFormValue],
+ );
+ return {
+ formValue,
+ local,
+ setLocal,
+ setFormValue,
+ setAll,
+ };
+};
+
+/**
+ * genericWidget({ dispatch, key })
+ * Returns the value-tied hooks for inputs associated with a flat value in redux
+ * Tied to redux video shape based on data key
+ * includes onChange, onBlur, and onCheckedChange methods. blur and checked change
+ * instantly affect both redux and local, while change (while typing) only affects
+ * the local component.
+ * @param {func} dispatch - redux dispatch method
+ * @param {string} key - redux video shape key
+ * @return {object} - state hooks
+ * formValue - value state in redux
+ * setFormValue - sets form field in redux
+ * local - value state in hook
+ * setLocal - sets form field in hook
+ * setAll - sets form field in hook AND redux
+ * onChange - handle input change by updating local state
+ * onCheckedChange - handle checked change by updating local and redux state
+ * onBlur - handle input blur by updating local and redux states
+ */
+export const genericWidget = ({ dispatch, key }) => {
+ const {
+ formValue,
+ local,
+ setLocal,
+ setFormValue,
+ setAll,
+ } = module.valueHooks({ dispatch, key });
+ return {
+ formValue,
+ local,
+ setLocal,
+ setAll,
+ setFormValue,
+ onChange: onValue(setLocal),
+ onCheckedChange: onChecked(setAll),
+ onBlur: onValue(setAll),
+ };
+};
+
+/**
+ * arrayWidget({ dispatch, key })
+ * Returns the value-tied hooks for inputs associated with a value in an array in the
+ * video redux shape.
+ * Tied to redux video shape based on data key
+ * includes onChange, onBlur, and onClear methods. blur changes local and redux state,
+ * on change affects only local state, and onClear sets both to an empty string.
+ * The creators from this widget will require an index to provide the final event-handler.
+ * @param {func} dispatch - redux dispatch method
+ * @param {string} key - redux video shape key
+ * @return {object} - state hooks
+ * formValue - value state in redux
+ * setFormValue - sets form field in redux
+ * local - value state in hook
+ * setLocal - sets form field in hook
+ * setAll - sets form field in hook AND redux
+ * onChange(index) - handle input change by updating local state
+ * onBlur(index) - handle input blur by updating local and redux states
+ * onClear(index) - handle clear event by setting value to empty string
+ */
+export const arrayWidget = ({ dispatch, key }) => {
+ const widget = module.valueHooks({ dispatch, key });
+ return {
+ ...widget,
+ onBlur: handleIndexTransformEvent({
+ handler: onValue,
+ setter: widget.setAll,
+ transform: module.updatedArray,
+ local: widget.local,
+ }),
+ onChange: handleIndexTransformEvent({
+ handler: onValue,
+ setter: widget.setLocal,
+ transform: module.updatedArray,
+ local: widget.local,
+ }),
+ onClear: (index) => () => widget.setAll(module.updatedArray(widget.local, index, '')),
+ };
+};
+
+/**
+ * objectWidget({ dispatch, key })
+ * Returns the value-tied hooks for inputs associated with a value in an object in the
+ * video redux shape.
+ * Tied to redux video shape based on data key
+ * includes onChange and onBlur methods. blur changes local and redux state,
+ * on change affects only local state.
+ * The creators from this widget will require an index to provide the final event-handler.
+ * @param {func} dispatch - redux dispatch method
+ * @param {string} key - redux video shape key
+ * @return {object} - state hooks
+ * formValue - value state in redux
+ * setFormValue - sets form field in redux
+ * local - value state in hook
+ * setLocal - sets form field in hook
+ * setAll - sets form field in hook AND redux
+ * onChange(index) - handle input change by updating local state
+ * onBlur(index) - handle input blur by updating local and redux states
+ * onClear(index) - handle clear event by setting value to empty string
+ */
+export const objectWidget = ({ dispatch, key }) => {
+ const widget = module.valueHooks({ dispatch, key });
+ return {
+ ...widget,
+ onChange: handleIndexTransformEvent({
+ handler: onValue,
+ setter: widget.setLocal,
+ transform: module.updatedObject,
+ local: widget.local,
+ }),
+ onBlur: handleIndexTransformEvent({
+ handler: onValue,
+ setter: widget.setAll,
+ transform: module.updatedObject,
+ local: widget.local,
+ }),
+ };
+};
+
+/**
+ * widgetValues({ fields, dispatch })
+ * widget value populator, that takes a fields mapping (dataKey: widgetFn) and dispatch
+ * method, and returns object of widget values.
+ * @param {object} fields - object with video data keys for keys and widget methods for values
+ * @param {func} dispatch - redux dispatch method
+ * @return {object} - { : }
+ */
+export const widgetValues = ({ fields, dispatch }) => Object.keys(fields).reduce(
+ (obj, key) => ({
+ ...obj,
+ [key]: fields[key]({ key, dispatch }),
+ }),
+ {},
+);
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.test.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.test.js
new file mode 100644
index 0000000000..ebfa493729
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.test.js
@@ -0,0 +1,312 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+
+import { useEffect } from 'react';
+import { useSelector } from 'react-redux';
+
+import { keyStore } from '../../../../../utils';
+import { actions, selectors } from '../../../../../data/redux';
+import { MockUseState } from '../../../../../testUtils';
+
+import * as handlers from './handlers';
+import * as hooks from './hooks';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useState: (val) => ({ useState: val }),
+ useEffect: jest.fn(),
+ useCallback: jest.fn((cb, prereqs) => ({ useCallback: { cb, prereqs } })),
+ useMemo: jest.fn((cb, prereqs) => ({ useMemo: { cb, prereqs } })),
+}));
+
+jest.mock('./handlers', () => ({
+ handleIndexEvent: jest.fn(args => ({ handleIndexEvent: args })),
+ handleIndexTransformEvent: jest.fn(args => ({ handleIndexTransformEvent: args })),
+ onValue: jest.fn(cb => ({ onValue: cb })),
+ onChecked: jest.fn(cb => ({ onChecked: cb })),
+ onEvent: jest.fn(cb => ({ onEvent: cb })),
+}));
+
+jest.mock('../../../../../data/redux', () => ({
+ actions: {
+ video: {
+ updateField: (val) => ({ updateField: val }),
+ },
+ },
+ selectors: {
+ video: {
+ videoSource: (state) => ({ videoSource: state }),
+ fallbackVideos: (state) => ({ fallbackVideos: state }),
+ allowVideoDownloads: (state) => ({ allowVideoDownloads: state }),
+ allowVideoSharing: (state) => ({ allowVideoSharing: state }),
+ thumbnail: (state) => ({ thumbnail: state }),
+ transcripts: (state) => ({ transcripts: state }),
+ allowTranscriptDownloads: (state) => ({ allowTranscriptDownloads: state }),
+ showTranscriptByDefault: (state) => ({ showTranscriptByDefault: state }),
+ duration: (state) => ({ duration: state }),
+ handout: (state) => ({ handout: state }),
+ licenseType: (state) => ({ licenseType: state }),
+ licenseDetails: (state) => ({ licenseDetails: state }),
+ },
+ },
+}));
+
+const keys = {
+ hooks: keyStore(hooks),
+ selectors: hooks.selectorKeys,
+};
+
+const state = new MockUseState(hooks);
+const testValue = 'my-test-value';
+const testValue2 = 'my-test-value-2';
+const testKey = keys.selectors.handout;
+const dispatch = jest.fn(val => ({ dispatch: val }));
+
+let out;
+
+describe('Video Settings modal hooks', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ describe('state hooks', () => {
+ state.testGetter(state.keys.videoSource);
+ state.testGetter(state.keys.fallbackVideos);
+ state.testGetter(state.keys.allowVideoDownloads);
+ state.testGetter(state.keys.allowVideoSharing);
+
+ state.testGetter(state.keys.thumbnail);
+
+ state.testGetter(state.keys.transcripts);
+ state.testGetter(state.keys.allowTranscriptDownloads);
+ state.testGetter(state.keys.showTranscriptByDefault);
+
+ state.testGetter(state.keys.duration);
+
+ state.testGetter(state.keys.handout);
+
+ state.testGetter(state.keys.licenseType);
+ state.testGetter(state.keys.licenseDetails);
+ });
+ describe('non-state hooks', () => {
+ beforeEach(() => state.mock());
+ afterEach(() => state.restore());
+ describe('updatedArray', () => {
+ it('returns a new array with the given index replaced', () => {
+ const testArray = ['0', '1', '2', '3', '4'];
+ const oldArray = [...testArray];
+ expect(hooks.updatedArray(testArray, 3, testValue)).toEqual(
+ ['0', '1', '2', testValue, '4'],
+ );
+ expect(testArray).toEqual(oldArray);
+ });
+ });
+ describe('updatedObject', () => {
+ it('returns a new object with the given index replaced', () => {
+ const testObj = { some: 'data', [testKey]: testValue };
+ const oldObj = { ...testObj };
+ expect(hooks.updatedObject(testObj, testKey, testValue2)).toEqual(
+ { ...testObj, [testKey]: testValue2 },
+ );
+ expect(testObj).toEqual(oldObj);
+ });
+ });
+ describe('updateFormField', () => {
+ it('returns a memoized callback that is only created once', () => {
+ expect(hooks.updateFormField({ dispatch, key: testKey }).useCallback.prereqs).toEqual([]);
+ });
+ it('returns memoized callback that dispaches updateField with val on the given key', () => {
+ hooks.updateFormField({ dispatch, key: testKey }).useCallback.cb(testValue);
+ expect(dispatch).toHaveBeenCalledWith(actions.video.updateField({
+ [testKey]: testValue,
+ }));
+ });
+ });
+ describe('valueHooks', () => {
+ let formValue;
+ beforeEach(() => {
+ formValue = useSelector(selectors.video[testKey]);
+ });
+ describe('behavior', () => {
+ describe('initialization', () => {
+ test('useEffect memoized on formValue', () => {
+ hooks.valueHooks({ dispatch, key: testKey });
+ expect(useEffect).toHaveBeenCalled();
+ expect(useEffect.mock.calls[0][1]).toEqual([formValue]);
+ });
+ test('calls setLocal with formValue by default', () => {
+ hooks.valueHooks({ dispatch, key: testKey });
+ useEffect.mock.calls[0][0]();
+ expect(state.setState[testKey]).toHaveBeenCalledWith(formValue);
+ });
+ });
+ });
+ describe('returned object', () => {
+ const mockUpdateFormField = (args) => jest.fn(
+ (val) => ({ updateFormField: { args, val } }),
+ );
+ beforeEach(() => {
+ jest.spyOn(hooks, keys.hooks.updateFormField)
+ .mockImplementationOnce(mockUpdateFormField);
+ out = hooks.valueHooks({ dispatch, key: testKey });
+ });
+ test('formValue from selectors.video[key]', () => {
+ expect(out.formValue).toEqual(useSelector(selectors.video[testKey]));
+ });
+ describe('local and setLocal', () => {
+ test('keyed to state, initialized with formValue', () => {
+ const { local, setLocal } = out;
+ expect(local).toEqual(formValue);
+ setLocal(testValue);
+ expect(state.setState[testKey]).toHaveBeenCalledWith(testValue);
+ });
+ });
+ test('setFormValue forwarded from module', () => {
+ expect(out.setFormValue(testValue)).toEqual(
+ mockUpdateFormField({ dispatch, key: testKey })(testValue),
+ );
+ });
+ describe('setAll', () => {
+ it('returns a memoized callback based on setLocal and setFormValue', () => {
+ expect(out.setAll.useCallback.prereqs).toEqual([out.setLocal, out.setFormValue]);
+ });
+ it('calls setLocal and setFormValue with the passed value', () => {
+ out.setAll.useCallback.cb(testValue);
+ expect(out.setLocal).toHaveBeenCalledWith(testValue);
+ expect(out.setFormValue).toHaveBeenCalledWith(testValue);
+ });
+ });
+ });
+ });
+ describe('genericWidget', () => {
+ const valueProps = {
+ formValue: '123',
+ local: 23,
+ setLocal: jest.fn(),
+ setFormValue: jest.fn(),
+ setAll: jest.fn(),
+ };
+ beforeEach(() => {
+ jest.spyOn(hooks, keys.hooks.valueHooks).mockReturnValueOnce(valueProps);
+ out = hooks.genericWidget({ dispatch, key: testKey });
+ });
+ describe('returned object', () => {
+ it('forwards formValue and local from valueHooks', () => {
+ expect(hooks.valueHooks).toHaveBeenCalledWith({ dispatch, key: testKey });
+ expect(out.formValue).toEqual(valueProps.formValue);
+ expect(out.local).toEqual(valueProps.local);
+ });
+ test('setFormValue mapped to valueHooks.setFormValue', () => {
+ expect(out.setFormValue).toEqual(valueProps.setFormValue);
+ });
+ test('onChange mapped to handlers.onValue(valueHooks.setLocal)', () => {
+ expect(out.onChange).toEqual(handlers.onValue(valueProps.setLocal));
+ });
+ test('onCheckedChange mapped to handlers.onChecked(valueHooks.setAll)', () => {
+ expect(out.onCheckedChange).toEqual(handlers.onChecked(valueProps.setAll));
+ });
+ test('onBlur mapped to handlers.onValue(valueHooks.setAll)', () => {
+ expect(out.onBlur).toEqual(handlers.onValue(valueProps.setAll));
+ });
+ });
+ });
+ describe('non-generic widgets', () => {
+ const widgetValues = {
+ formValue: '123',
+ local: 23,
+ setLocal: jest.fn(),
+ setFormValue: jest.fn(),
+ setAll: jest.fn(),
+ };
+ let valueHooksSpy;
+ beforeEach(() => {
+ valueHooksSpy = jest.spyOn(hooks, keys.hooks.valueHooks).mockReturnValue(widgetValues);
+ });
+ afterEach(() => {
+ valueHooksSpy.mockRestore();
+ });
+ describe('arrayWidget', () => {
+ const mockUpdatedArray = (...args) => ({ updatedArray: args });
+ let arraySpy;
+ beforeEach(() => {
+ arraySpy = jest.spyOn(hooks, keys.hooks.updatedArray)
+ .mockImplementation(mockUpdatedArray);
+ out = hooks.arrayWidget({ dispatch, key: testKey });
+ });
+ afterEach(() => {
+ arraySpy.mockRestore();
+ });
+ it('forwards widget values', () => {
+ expect(out.formValue).toEqual(widgetValues.formValue);
+ expect(out.local).toEqual(widgetValues.local);
+ });
+ it('overrides onChange with handleIndexTransformEvent', () => {
+ expect(out.onChange).toEqual(handlers.handleIndexTransformEvent({
+ handler: handlers.onValue,
+ setter: widgetValues.setLocal,
+ transform: arraySpy,
+ local: widgetValues.local,
+ }));
+ });
+ it('overrides onBlur with handleIndexTransformEvent', () => {
+ expect(out.onBlur).toEqual(handlers.handleIndexTransformEvent({
+ handler: handlers.onValue,
+ setter: widgetValues.setAll,
+ transform: arraySpy,
+ local: widgetValues.local,
+ }));
+ });
+ it('adds onClear event that calls setAll with empty string', () => {
+ out.onClear(testKey)();
+ expect(widgetValues.setAll).toHaveBeenCalledWith(
+ arraySpy(widgetValues.local, testKey, ''),
+ );
+ });
+ });
+ describe('objectWidget', () => {
+ beforeEach(() => {
+ out = hooks.objectWidget({ dispatch, key: testKey });
+ });
+ it('forwards widget values', () => {
+ expect(out.formValue).toEqual(widgetValues.formValue);
+ expect(out.local).toEqual(widgetValues.local);
+ });
+ it('overrides onChange with handleIndexTransformEvent', () => {
+ expect(out.onChange).toEqual(handlers.handleIndexTransformEvent({
+ handler: handlers.onValue,
+ setter: widgetValues.setLocal,
+ transform: hooks.updatedObject,
+ local: widgetValues.local,
+ }));
+ });
+ it('overrides onBlur with handleIndexTransformEvent', () => {
+ expect(out.onBlur).toEqual(handlers.handleIndexTransformEvent({
+ handler: handlers.onValue,
+ setter: widgetValues.setAll,
+ transform: hooks.updatedObject,
+ local: widgetValues.local,
+ }));
+ });
+ });
+ });
+ describe('widgetValues', () => {
+ describe('returned object', () => {
+ test('shaped to the fields object, where each value is called with key and dispatch', () => {
+ const testKeys = ['1', '24', '23gsa'];
+ const fieldMethods = [
+ jest.fn(v => ({ v1: v })),
+ jest.fn(v => ({ v2: v })),
+ jest.fn(v => ({ v3: v })),
+ ];
+ const fields = testKeys.reduce((obj, key, index) => ({
+ ...obj,
+ [key]: fieldMethods[index],
+ }), {});
+ const expected = testKeys.reduce((obj, key, index) => ({
+ ...obj,
+ [key]: fieldMethods[index]({ key, dispatch }),
+ }), {});
+ expect(hooks.widgetValues({ fields, dispatch })).toMatchObject(expected);
+ });
+ });
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/messages.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/messages.js
new file mode 100644
index 0000000000..57e097fb76
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/messages.js
@@ -0,0 +1,85 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+
+ expandAltText: {
+ id: 'authoring.videoeditor.expand',
+ defaultMessage: 'Expand',
+ },
+ collapseAltText: {
+ id: 'authoring.videoeditor.collapse',
+ defaultMessage: 'Collapse',
+ },
+ validateErrorTitle: {
+ id: 'authoring.videoeditor.validate.error.title',
+ defaultMessage: 'We couldn\'t add your video.',
+ description: 'Title of validation error.',
+ },
+ validateErrorBody: {
+ id: 'authoring.videoeditor.validate.error.body',
+ defaultMessage: 'Please check your entries and try again.',
+ description: 'Body of validation error.',
+ },
+ durationTitle: {
+ id: 'authoring.videoeditor.duration.title',
+ defaultMessage: 'Duration',
+ description: 'Title of Duration widget',
+ },
+ durationDescription: {
+ id: 'authoring.videoeditor.duration.description',
+ defaultMessage: 'Set a specific section of the video to play.',
+ description: 'Description of Duration widget',
+ },
+ startTimeLabel: {
+ id: 'authoring.videoeditor.duration.startTime.label',
+ defaultMessage: 'Start time',
+ description: 'Label of start time input field',
+ },
+ stopTimeLabel: {
+ id: 'authoring.videoeditor.duration.stopTime.label',
+ defaultMessage: 'Stop time',
+ description: 'Label of stop time input field',
+ },
+ durationHint: {
+ id: 'authoring.videoeditor.duration.hint',
+ defaultMessage: 'Enter time as HH:MM:SS',
+ description: 'Hint text for start and stop time input fields',
+ },
+ fullVideoLength: {
+ id: 'authoring.videoeditor.duration.fullVideoLength',
+ defaultMessage: 'Full video length',
+ description: 'Text describing a video with neither custom start time nor custom stop time',
+ },
+ startsAt: {
+ id: 'authoring.videoeditor.duration.startsAt',
+ defaultMessage: 'Starts at {startTime}',
+ description: 'Text describing a video with custom start time and default stop time',
+ },
+ total: {
+ id: 'authoring.videoeditor.duration.total',
+ defaultMessage: 'Total: {total}',
+ description: 'Text describing a video with custom start time and custom stop time, or just a custom stop time',
+ },
+ custom: {
+ id: 'authoring.videoeditor.duration.custom',
+ defaultMessage: 'Custom: {total}',
+ description: 'Text describing a video with custom start time and custom stop time, or just a custom stop time for a collapsed widget',
+ },
+ noTranscriptsAdded: {
+ id: 'authoring.videoeditor.transcripts.empty',
+ defaultMessage: 'No transcripts added',
+ description: 'Message shown when the user has not selected any transcripts for the video.',
+ },
+ videoTypeYoutube: {
+ id: 'authoring.videoeditor.videotype.youtube',
+ defaultMessage: 'YouTube video',
+ description: 'Shown on the preview card if the video is from youtube.com.',
+ },
+ videoTypeOther: {
+ id: 'authoring.videoeditor.videotype.other',
+ defaultMessage: 'Other video',
+ description: 'Shown on the preview card if the video source could not be identified.',
+ },
+});
+
+export default messages;
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/index.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/index.jsx
new file mode 100644
index 0000000000..5f01ec92cb
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/index.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Button, Icon } from '@openedx/paragon';
+import { ArrowBackIos } from '@openedx/paragon/icons';
+import {
+ FormattedMessage,
+ injectIntl,
+} from '@edx/frontend-platform/i18n';
+
+// import VideoPreview from './components/VideoPreview';
+import { ErrorSummary } from './ErrorSummary';
+import DurationWidget from './components/DurationWidget';
+import HandoutWidget from './components/HandoutWidget';
+import LicenseWidget from './components/LicenseWidget';
+import ThumbnailWidget from './components/ThumbnailWidget';
+import TranscriptWidget from './components/TranscriptWidget';
+import VideoSourceWidget from './components/VideoSourceWidget';
+import VideoPreviewWidget from './components/VideoPreviewWidget';
+import './index.scss';
+import SocialShareWidget from './components/SocialShareWidget';
+import messages from '../../messages';
+
+const VideoSettingsModal = ({
+ onReturn,
+ isLibrary,
+}) => (
+ <>
+ {!isLibrary && (
+
+
+
+
+ )}
+
+
+
+ {!isLibrary && (
+
+ )}
+
+
+
+
+
+ >
+);
+
+VideoSettingsModal.propTypes = {
+ onReturn: PropTypes.func.isRequired,
+ isLibrary: PropTypes.func.isRequired,
+};
+
+export default injectIntl(VideoSettingsModal);
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/index.scss b/src/editors/containers/VideoEditor/components/VideoSettingsModal/index.scss
new file mode 100644
index 0000000000..119b04dfba
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/index.scss
@@ -0,0 +1,6 @@
+// .video-settings-modal {
+// .video-preview {
+// }
+// .video-controls {
+// }
+// }
diff --git a/src/editors/containers/VideoEditor/hooks.js b/src/editors/containers/VideoEditor/hooks.js
new file mode 100644
index 0000000000..c046888879
--- /dev/null
+++ b/src/editors/containers/VideoEditor/hooks.js
@@ -0,0 +1,54 @@
+import { useState, createContext } from 'react';
+import { thunkActions } from '../../data/redux';
+
+import { StrictDict } from '../../utils';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+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 = () => {
+ const [durationErrors, setDurationErrors] = module.state.durationErrors({});
+ const [handoutErrors, setHandoutErrors] = module.state.handoutErrors({});
+ const [licenseErrors, setLicenseErrors] = module.state.licenseErrors({});
+ const [thumbnailErrors, setThumbnailErrors] = module.state.thumbnailErrors({});
+ const [transcriptsErrors, setTranscriptsErrors] = module.state.transcriptsErrors({});
+ const [videoSourceErrors, setVideoSourceErrors] = module.state.videoSourceErrors({});
+
+ return {
+ error: {
+ duration: [durationErrors, setDurationErrors],
+ handout: [handoutErrors, setHandoutErrors],
+ license: [licenseErrors, setLicenseErrors],
+ thumbnail: [thumbnailErrors, setThumbnailErrors],
+ transcripts: [transcriptsErrors, setTranscriptsErrors],
+ videoSource: [videoSourceErrors, setVideoSourceErrors],
+ },
+ validateEntry: () => {
+ if (Object.keys(durationErrors).length > 0) { return false; }
+ if (Object.keys(handoutErrors).length > 0) { return false; }
+ if (Object.keys(licenseErrors).length > 0) { return false; }
+ if (Object.keys(thumbnailErrors).length > 0) { return false; }
+ if (Object.keys(transcriptsErrors).length > 0) { return false; }
+ if (Object.keys(videoSourceErrors).length > 0) { return false; }
+ return true;
+ },
+ };
+};
+export const fetchVideoContent = () => ({ dispatch }) => (
+ dispatch(thunkActions.video.saveVideoData())
+);
diff --git a/src/editors/containers/VideoEditor/hooks.test.js b/src/editors/containers/VideoEditor/hooks.test.js
new file mode 100644
index 0000000000..32a8df46d8
--- /dev/null
+++ b/src/editors/containers/VideoEditor/hooks.test.js
@@ -0,0 +1,108 @@
+import { dispatch } from 'react-redux';
+import { thunkActions } from '../../data/redux';
+import { MockUseState } from '../../testUtils';
+
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './hooks';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useRef: jest.fn(val => ({ current: val })),
+ useEffect: jest.fn(),
+ useCallback: (cb, prereqs) => ({ cb, prereqs }),
+}));
+
+jest.mock('react-redux', () => {
+ const dispatchFn = jest.fn();
+ return {
+ ...jest.requireActual('react-redux'),
+ dispatch: dispatchFn,
+ useDispatch: jest.fn(() => dispatchFn),
+ };
+});
+
+jest.mock('../../data/redux', () => ({
+ thunkActions: {
+ video: {
+ saveVideoData: jest.fn(),
+ },
+ },
+}));
+
+const state = new MockUseState(module);
+
+let hook;
+
+describe('VideoEditorHooks', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ describe('state hooks', () => {
+ state.testGetter(state.keys.durationErrors);
+ state.testGetter(state.keys.handoutErrors);
+ state.testGetter(state.keys.licenseErrors);
+ state.testGetter(state.keys.thumbnailErrors);
+ state.testGetter(state.keys.transcriptsErrors);
+ state.testGetter(state.keys.videoSourceErrors);
+ });
+
+ describe('errors hook', () => {
+ beforeEach(() => {
+ state.mock();
+ });
+ afterEach(() => {
+ state.restore();
+ });
+
+ const fakeDurationError = {
+ field1: 'field1msg',
+ field2: 'field2msg',
+ };
+ test('error: state values', () => {
+ expect(module.errorsHook().error.duration).toEqual([
+ state.stateVals[state.keys.durationErrors],
+ state.setState[state.keys.durationErrors],
+ ]);
+ expect(module.errorsHook().error.handout).toEqual([
+ state.stateVals[state.keys.handoutErrors],
+ state.setState[state.keys.handoutErrors],
+ ]);
+ expect(module.errorsHook().error.license).toEqual([
+ state.stateVals[state.keys.licenseErrors],
+ state.setState[state.keys.licenseErrors],
+ ]);
+ expect(module.errorsHook().error.thumbnail).toEqual([
+ state.stateVals[state.keys.thumbnailErrors],
+ state.setState[state.keys.thumbnailErrors],
+ ]);
+ expect(module.errorsHook().error.transcripts).toEqual([
+ state.stateVals[state.keys.transcriptsErrors],
+ state.setState[state.keys.transcriptsErrors],
+ ]);
+ expect(module.errorsHook().error.videoSource).toEqual([
+ state.stateVals[state.keys.videoSourceErrors],
+ state.setState[state.keys.videoSourceErrors],
+ ]);
+ });
+ describe('validateEntry', () => {
+ test('validateEntry: returns true if all validation calls are true', () => {
+ hook = module.errorsHook();
+ expect(hook.validateEntry()).toEqual(true);
+ });
+ test('validateEntry: returns false if any validation calls are false', () => {
+ state.mockVal(state.keys.durationErrors, fakeDurationError);
+ hook = module.errorsHook();
+ expect(hook.validateEntry()).toEqual(false);
+ });
+ });
+ });
+ describe('fetchVideoContent', () => {
+ it('equals dispatch(thunkActions.video.saveVideoData())', () => {
+ hook = module.fetchVideoContent()({ dispatch });
+ expect(hook).toEqual(dispatch(thunkActions.video.saveVideoData()));
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/index.jsx b/src/editors/containers/VideoEditor/index.jsx
new file mode 100644
index 0000000000..633e51f9f2
--- /dev/null
+++ b/src/editors/containers/VideoEditor/index.jsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+
+import {
+ Spinner,
+} from '@openedx/paragon';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+
+import { selectors } from '../../data/redux';
+import { RequestKeys } from '../../data/constants/requests';
+
+import EditorContainer from '../EditorContainer';
+import VideoEditorModal from './components/VideoEditorModal';
+import { ErrorContext, errorsHook, fetchVideoContent } from './hooks';
+import messages from './messages';
+
+const VideoEditor = ({
+ onClose,
+ returnFunction,
+ // injected
+ intl,
+ // redux
+ studioViewFinished,
+ isLibrary,
+}) => {
+ const {
+ error,
+ validateEntry,
+ } = errorsHook();
+ return (
+
+
+ {studioViewFinished ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+};
+
+VideoEditor.defaultProps = {
+ onClose: null,
+ returnFunction: null,
+};
+VideoEditor.propTypes = {
+ onClose: PropTypes.func,
+ returnFunction: PropTypes.func,
+ // injected
+ intl: intlShape.isRequired,
+ // redux
+ studioViewFinished: PropTypes.bool.isRequired,
+ isLibrary: PropTypes.bool.isRequired,
+};
+
+export const mapStateToProps = (state) => ({
+ studioViewFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchStudioView }),
+ isLibrary: selectors.app.isLibrary(state),
+});
+
+export const mapDispatchToProps = {};
+
+export const VideoEditorInternal = VideoEditor; // For testing only
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(VideoEditor));
diff --git a/src/editors/containers/VideoEditor/index.test.jsx b/src/editors/containers/VideoEditor/index.test.jsx
new file mode 100644
index 0000000000..99c1f157a4
--- /dev/null
+++ b/src/editors/containers/VideoEditor/index.test.jsx
@@ -0,0 +1,73 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { formatMessage } from '../../testUtils';
+import { selectors } from '../../data/redux';
+import { RequestKeys } from '../../data/constants/requests';
+import { VideoEditorInternal as VideoEditor, mapStateToProps, mapDispatchToProps } from '.';
+
+jest.mock('../EditorContainer', () => 'EditorContainer');
+jest.mock('./components/VideoEditorModal', () => 'VideoEditorModal');
+
+jest.mock('./hooks', () => ({
+ ErrorContext: {
+ Provider: 'ErrorContext.Provider',
+ },
+ errorsHook: jest.fn(() => ({
+ error: 'hooks.errorsHook.error',
+ validateEntry: jest.fn().mockName('validateEntry'),
+ })),
+ fetchVideoContent: jest.fn().mockName('fetchVideoContent'),
+}));
+
+jest.mock('../../data/redux', () => ({
+ selectors: {
+ requests: {
+ isFinished: jest.fn((state, params) => ({ isFailed: { state, params } })),
+ },
+ app: {
+ isLibrary: jest.fn(state => ({ isLibrary: state })),
+ },
+ },
+}));
+
+jest.mock('@openedx/paragon', () => ({
+ ...jest.requireActual('@openedx/paragon'),
+ Spinner: 'Spinner',
+}));
+
+describe('VideoEditor', () => {
+ const props = {
+ onClose: jest.fn().mockName('props.onClose'),
+ intl: { formatMessage },
+ studioViewFinished: false,
+ isLibrary: false,
+ };
+ describe('snapshots', () => {
+ test('renders as expected with default behavior', () => {
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ test('renders as expected with default behavior', () => {
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ });
+ describe('mapStateToProps', () => {
+ const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
+ test('studioViewFinished from requests.isFinished', () => {
+ expect(
+ mapStateToProps(testState).studioViewFinished,
+ ).toEqual(selectors.requests.isFinished(testState, { requestKey: RequestKeys.fetchStudioView }));
+ });
+ test('isLibrary from app.isLibrary', () => {
+ expect(
+ mapStateToProps(testState).isLibrary,
+ ).toEqual(selectors.app.isLibrary(testState));
+ });
+ });
+ describe('mapDispatchToProps', () => {
+ test('is empty', () => {
+ expect(mapDispatchToProps).toEqual({});
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/messages.js b/src/editors/containers/VideoEditor/messages.js
new file mode 100644
index 0000000000..c041f69e35
--- /dev/null
+++ b/src/editors/containers/VideoEditor/messages.js
@@ -0,0 +1,16 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ spinnerScreenReaderText: {
+ id: 'authoring.videoEditor.spinnerScreenReaderText',
+ defaultMessage: 'loading',
+ description: 'Loading message for spinner screenreader text.',
+ },
+ replaceVideoButtonLabel: {
+ id: 'authoring.videoEditor.replaceVideoButtonLabel',
+ defaultMessage: 'Replace video',
+ description: 'Text of the replace video button to return to the video gallery',
+ },
+});
+
+export default messages;
diff --git a/src/editors/containers/VideoGallery/hooks.js b/src/editors/containers/VideoGallery/hooks.js
new file mode 100644
index 0000000000..648db7a641
--- /dev/null
+++ b/src/editors/containers/VideoGallery/hooks.js
@@ -0,0 +1,216 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './hooks';
+import messages from './messages';
+import * as appHooks from '../../hooks';
+import { selectors } from '../../data/redux';
+import analyticsEvt from '../../data/constants/analyticsEvt';
+import {
+ filterKeys, filterMessages, sortFunctions, sortKeys, sortMessages,
+} from './utils';
+
+export const {
+ navigateCallback,
+ navigateTo,
+} = appHooks;
+
+export const useSearchAndSortProps = () => {
+ const [searchString, setSearchString] = React.useState('');
+ const [sortBy, setSortBy] = React.useState(sortKeys.dateNewest);
+ const [filterBy, setFilterBy] = React.useState(filterKeys.anyStatus);
+ const [hideSelectedVideos, setHideSelectedVideos] = React.useState(false);
+
+ return {
+ searchString,
+ onSearchChange: (e) => setSearchString(e.target.value),
+ clearSearchString: () => setSearchString(''),
+ sortBy,
+ onSortClick: (key) => () => setSortBy(key),
+ sortKeys,
+ sortMessages,
+ filterBy,
+ onFilterClick: (key) => () => setFilterBy(key),
+ filterKeys,
+ filterMessages,
+ showSwitch: false,
+ hideSelectedVideos,
+ switchMessage: messages.hideSelectedCourseVideosSwitchLabel,
+ onSwitchClick: () => setHideSelectedVideos(!hideSelectedVideos),
+ };
+};
+
+export const filterListBySearch = ({
+ searchString,
+ videoList,
+}) => (
+ videoList.filter(({ displayName }) => displayName.toLowerCase()
+ .includes(searchString.toLowerCase()))
+);
+
+export const filterListByStatus = ({ statusFilter, videoList }) => {
+ if (statusFilter === filterKeys.anyStatus) {
+ return videoList;
+ }
+ return videoList.filter(({ status }) => filterKeys[statusFilter] === status);
+};
+
+export const filterListByHideSelectedCourse = ({ videoList }) => (
+ // TODO Missing to implement this
+ videoList
+);
+
+export const filterList = ({
+ sortBy,
+ filterBy,
+ searchString,
+ videos,
+}) => {
+ let filteredList = module.filterListBySearch({
+ searchString,
+ videoList: videos,
+ });
+ filteredList = module.filterListByStatus({
+ statusFilter: filterBy,
+ videoList: filteredList,
+ });
+ filteredList = module.filterListByHideSelectedCourse({
+ videoList: filteredList,
+ });
+ return filteredList.sort(sortFunctions[sortBy in sortKeys ? sortKeys[sortBy] : sortKeys.dateNewest]);
+};
+
+export const useVideoListProps = ({
+ searchSortProps,
+ videos,
+}) => {
+ const [highlighted, setHighlighted] = React.useState(null);
+ const [
+ showSelectVideoError,
+ setShowSelectVideoError,
+ ] = React.useState(false);
+ const [
+ showSizeError,
+ setShowSizeError,
+ ] = React.useState(false);
+ const filteredList = module.filterList({
+ ...searchSortProps,
+ videos,
+ });
+ const learningContextId = useSelector(selectors.app.learningContextId);
+ const blockId = useSelector(selectors.app.blockId);
+ return {
+ galleryError: {
+ show: showSelectVideoError,
+ set: () => setShowSelectVideoError(true),
+ dismiss: () => setShowSelectVideoError(false),
+ message: messages.selectVideoError,
+ },
+ // TODO We need to update this message when implementing the video upload screen
+ inputError: {
+ show: showSizeError,
+ set: () => setShowSizeError(true),
+ dismiss: () => setShowSelectVideoError(false),
+ message: messages.fileSizeError,
+ },
+ galleryProps: {
+ galleryIsEmpty: Object.keys(filteredList).length === 0,
+ searchIsEmpty: filteredList.length === 0,
+ displayList: filteredList,
+ highlighted,
+ onHighlightChange: (e) => setHighlighted(e.target.value),
+ emptyGalleryLabel: messages.emptyGalleryLabel,
+ showIdsOnCards: true,
+ height: '100%',
+ },
+ selectBtnProps: {
+ onClick: () => {
+ if (highlighted) {
+ navigateTo(`/course/${learningContextId}/editor/video/${blockId}?selectedVideoId=${highlighted}`);
+ } else {
+ setShowSelectVideoError(true);
+ }
+ },
+ },
+ };
+};
+
+export const useVideoUploadHandler = ({ replace }) => {
+ const learningContextId = useSelector(selectors.app.learningContextId);
+ const blockId = useSelector(selectors.app.blockId);
+ const path = `/course/${learningContextId}/editor/video_upload/${blockId}`;
+ if (replace) {
+ return () => window.location.replace(path);
+ }
+ return () => navigateTo(path);
+};
+
+export const useCancelHandler = () => (
+ navigateCallback({
+ destination: useSelector(selectors.app.returnUrl),
+ analytics: useSelector(selectors.app.analytics),
+ analyticsEvent: analyticsEvt.videoGalleryCancelClick,
+ })
+);
+
+export const buildVideos = ({ rawVideos }) => {
+ let videos = [];
+ const rawVideoList = Object.values(rawVideos);
+ if (rawVideoList.length > 0) {
+ videos = rawVideoList.map(video => ({
+ id: video.edx_video_id,
+ displayName: video.client_video_id,
+ externalUrl: video.course_video_image_url,
+ dateAdded: new Date(video.created),
+ locked: false,
+ thumbnail: video.course_video_image_url,
+ status: video.status_nontranslated,
+ statusBadgeVariant: module.getstatusBadgeVariant({ status: video.status_nontranslated }),
+ statusMessage: module.getStatusMessage({ status: video.status_nontranslated }),
+ duration: video.duration,
+ transcripts: video.transcripts,
+ }));
+ }
+ return videos;
+};
+
+export const getstatusBadgeVariant = ({ status }) => {
+ switch (status) {
+ case filterKeys.failed:
+ return 'danger';
+ case filterKeys.uploading:
+ case filterKeys.processing:
+ return 'light';
+ default:
+ return null;
+ }
+};
+
+export const getStatusMessage = ({ status }) => Object.values(filterMessages).find((m) => m.defaultMessage === status);
+
+export const useVideoProps = ({ videos }) => {
+ const searchSortProps = useSearchAndSortProps();
+ const videoList = useVideoListProps({
+ searchSortProps,
+ videos,
+ });
+ const {
+ galleryError,
+ galleryProps,
+ inputError,
+ selectBtnProps,
+ } = videoList;
+ const fileInput = { click: useVideoUploadHandler({ replace: false }) };
+
+ return {
+ galleryError,
+ inputError,
+ fileInput,
+ galleryProps,
+ searchSortProps,
+ selectBtnProps,
+ };
+};
diff --git a/src/editors/containers/VideoGallery/index.jsx b/src/editors/containers/VideoGallery/index.jsx
new file mode 100644
index 0000000000..5f5a8a8ca8
--- /dev/null
+++ b/src/editors/containers/VideoGallery/index.jsx
@@ -0,0 +1,88 @@
+import React, { useEffect } from 'react';
+import { Image } from '@openedx/paragon';
+import { useSelector } from 'react-redux';
+import { selectors } from '../../data/redux';
+import * as hooks from './hooks';
+import SelectionModal from '../../sharedComponents/SelectionModal';
+import { acceptedImgKeys } from './utils';
+import messages from './messages';
+import { RequestKeys } from '../../data/constants/requests';
+import videoThumbnail from '../../data/images/videoThumbnail.svg';
+
+const VideoGallery = () => {
+ const rawVideos = useSelector(selectors.app.videos);
+ const isLoaded = useSelector(
+ (state) => selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchVideos }),
+ );
+ const isFetchError = useSelector(
+ (state) => selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchVideos }),
+ );
+ const isUploadError = useSelector(
+ (state) => selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadVideo }),
+ );
+ const videos = hooks.buildVideos({ rawVideos });
+ const handleVideoUpload = hooks.useVideoUploadHandler({ replace: true });
+
+ useEffect(() => {
+ // If no videos exists redirects to the video upload screen
+ if (isLoaded && videos.length === 0) {
+ handleVideoUpload();
+ }
+ }, [isLoaded]);
+ const {
+ galleryError,
+ inputError,
+ fileInput,
+ galleryProps,
+ searchSortProps,
+ selectBtnProps,
+ } = hooks.useVideoProps({ videos });
+ const handleCancel = hooks.useCancelHandler();
+
+ const modalMessages = {
+ confirmMsg: messages.selectVideoButtonlabel,
+ titleMsg: messages.titleLabel,
+ uploadButtonMsg: messages.uploadButtonLabel,
+ fetchError: messages.fetchVideosError,
+ uploadError: messages.uploadVideoError,
+ };
+
+ const thumbnailFallback = (
+
+ );
+
+ return (
+
+
+
+ );
+};
+
+VideoGallery.propTypes = {};
+
+export default VideoGallery;
diff --git a/src/editors/containers/VideoGallery/index.test.jsx b/src/editors/containers/VideoGallery/index.test.jsx
new file mode 100644
index 0000000000..79771c90d8
--- /dev/null
+++ b/src/editors/containers/VideoGallery/index.test.jsx
@@ -0,0 +1,195 @@
+import { initializeMockApp } from '@edx/frontend-platform';
+import { AppProvider } from '@edx/frontend-platform/react';
+import { configureStore } from '@reduxjs/toolkit';
+import '@testing-library/jest-dom/extend-expect';
+import React from 'react';
+import {
+ act, fireEvent, render, screen,
+} from '@testing-library/react';
+
+import VideoGallery from './index';
+
+jest.unmock('react-redux');
+jest.unmock('@edx/frontend-platform/i18n');
+jest.unmock('@openedx/paragon');
+jest.unmock('@openedx/paragon/icons');
+
+let store;
+const initialVideos = [
+ {
+ edx_video_id: 'id_1',
+ client_video_id: 'client_id_1',
+ course_video_image_url: 'course_video_image_url_1',
+ created: '2022-09-07T04:56:58.726Z',
+ status: 'Uploading',
+ status_nontranslated: 'Uploading',
+ duration: 3,
+ transcripts: [],
+ },
+ {
+ edx_video_id: 'id_2',
+ client_video_id: 'client_id_2',
+ course_video_image_url: 'course_video_image_url_2',
+ created: '2022-11-07T04:56:58.726Z',
+ status: 'In Progress',
+ status_nontranslated: 'In Progress',
+ duration: 2,
+ transcripts: [],
+ }, {
+ edx_video_id: 'id_3',
+ client_video_id: 'client_id_3',
+ course_video_image_url: 'course_video_image_url_3',
+ created: '2022-01-07T04:56:58.726Z',
+ status: 'Ready',
+ status_nontranslated: 'Ready',
+ duration: 4,
+ transcripts: [],
+ },
+];
+
+// We are not using any style-based assertions and this function is very slow with jest-dom
+window.getComputedStyle = () => ({
+ getPropertyValue: () => undefined,
+});
+
+describe('VideoGallery', () => {
+ describe('component', () => {
+ let oldLocation;
+ beforeEach(async () => {
+ store = configureStore({
+ reducer: (state, action) => ((action && action.newState) ? action.newState : state),
+ preloadedState: {
+ app: {
+ videos: initialVideos,
+ learningContextId: 'course-v1:test+test+test',
+ blockId: 'some-block-id',
+ },
+ requests: {
+ fetchVideos: { status: 'completed' },
+ uploadVideo: { status: 'inactive' },
+ },
+ },
+ });
+
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'test-user',
+ administrator: true,
+ roles: [],
+ },
+ });
+ });
+ beforeAll(() => {
+ oldLocation = window.location;
+ delete window.location;
+ window.location = { replace: jest.fn() };
+ });
+ afterAll(() => {
+ window.location = oldLocation;
+ });
+
+ function updateState({ videos = initialVideos, fetchVideos = 'completed', uploadVideos = 'inactive' }) {
+ store.dispatch({
+ type: '',
+ newState: {
+ app: {
+ videos,
+ learningContextId: 'course-v1:test+test+test',
+ blockId: 'some-block-id',
+ },
+ requests: {
+ fetchVideos: { status: fetchVideos },
+ uploadVideo: { status: uploadVideos },
+ },
+ },
+ });
+ }
+
+ async function renderComponent() {
+ return render(
+
+
+ ,
+ );
+ }
+
+ it('displays a list of videos', async () => {
+ await renderComponent();
+ initialVideos.forEach(video => (
+ expect(screen.getByText(video.client_video_id)).toBeInTheDocument()
+ ));
+ });
+ it('navigates to video upload page when there are no videos', async () => {
+ expect(window.location.replace).not.toHaveBeenCalled();
+ updateState({ videos: [] });
+ await renderComponent();
+ expect(window.location.replace).toHaveBeenCalled();
+ });
+ it.each([
+ [/newest/i, [2, 1, 3]],
+ [/oldest/i, [3, 1, 2]],
+ [/name A-Z/i, [1, 2, 3]],
+ [/name Z-A/i, [3, 2, 1]],
+ [/longest/i, [3, 1, 2]],
+ [/shortest/i, [2, 1, 3]],
+ ])('videos can be sorted %s', async (sortBy, order) => {
+ await renderComponent();
+
+ fireEvent.click(screen.getByRole('button', {
+ name: /By newest/i,
+ }));
+ fireEvent.click(screen.getByRole('link', {
+ name: sortBy,
+ }));
+ const videoElements = screen.getAllByRole('button', { name: /client_id/ });
+ order.forEach((clientIdSuffix, idx) => {
+ expect(videoElements[idx]).toHaveTextContent(`client_id_${clientIdSuffix}`);
+ });
+ });
+ it.each([
+ ['Uploading', 1, [1]],
+ ['In Progress', 1, [2]],
+ ['Ready', 1, [3]],
+ ['Failed', 1, [4]],
+ ])('videos can be filtered by status %s', async (filterBy, length, items) => {
+ await renderComponent();
+ updateState({
+ videos: [...initialVideos, {
+ edx_video_id: 'id_4',
+ client_video_id: 'client_id_4',
+ course_video_image_url: 'course_video_image_url_4',
+ created: '2022-01-07T04:56:58.726Z',
+ status: 'Failed',
+ status_nontranslated: 'Failed',
+ duration: 4,
+ transcripts: [],
+ }],
+ });
+
+ act(() => {
+ fireEvent.click(screen.getByTestId('dropdown-filter'));
+ });
+
+ act(() => {
+ fireEvent.click(screen.getByRole('button', {
+ name: filterBy,
+ }));
+ });
+
+ const videoElements = await screen.findAllByRole('button', { name: /client_id/ });
+ expect(videoElements).toHaveLength(length);
+ items.forEach(clientIdx => (
+ expect(screen.getByText(`client_id_${clientIdx}`)).toBeInTheDocument()
+ ));
+ });
+
+ it('filters videos by search string', async () => {
+ await renderComponent();
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'CLIENT_ID_2' } });
+ expect(screen.queryByText('client_id_2')).toBeInTheDocument();
+ expect(screen.queryByText('client_id_1')).not.toBeInTheDocument();
+ expect(screen.queryByText('client_id_3')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/editors/containers/VideoGallery/messages.js b/src/editors/containers/VideoGallery/messages.js
new file mode 100644
index 0000000000..e26dd63db3
--- /dev/null
+++ b/src/editors/containers/VideoGallery/messages.js
@@ -0,0 +1,117 @@
+const messages = {
+ // Gallery
+ emptyGalleryLabel: {
+ id: 'authoring.selectvideomodal.emptyGalleryLabel',
+ defaultMessage:
+ 'No results found.',
+ description: 'Label for when video gallery is empty.',
+ },
+ selectVideoButtonlabel: {
+ id: 'authoring.selectvideomodal.selectvideo.label',
+ defaultMessage: 'Select video',
+ description: 'Label for Select video button',
+ },
+ titleLabel: {
+ id: 'authoring.selectvideomodal.title.label',
+ defaultMessage: 'Add video to your course',
+ description: 'Title for the select video modal',
+ },
+ uploadButtonLabel: {
+ id: 'authoring.selectvideomodal.upload.label',
+ defaultMessage: 'Upload or embed a new video',
+ description: 'Label for upload button',
+ },
+
+ // Sort Dropdown
+ sortByDateNewest: {
+ id: 'authoring.selectvideomodal.sort.datenewest.label',
+ defaultMessage: 'newest',
+ description: 'Dropdown label for sorting by date (newest)',
+ },
+ sortByDateOldest: {
+ id: 'authoring.selectvideomodal.sort.dateoldest.label',
+ defaultMessage: 'oldest',
+ description: 'Dropdown label for sorting by date (oldest)',
+ },
+ sortByNameAscending: {
+ id: 'authoring.selectvideomodal.sort.nameascending.label',
+ defaultMessage: 'name A-Z',
+ description: 'Dropdown label for sorting by name (ascending)',
+ },
+ sortByNameDescending: {
+ id: 'authoring.selectvideomodal.sort.namedescending.label',
+ defaultMessage: 'name Z-A',
+ description: 'Dropdown label for sorting by name (descending)',
+ },
+ sortByDurationShortest: {
+ id: 'authoring.selectvideomodal.sort.durationshortest.label',
+ defaultMessage: 'shortest',
+ description: 'Dropdown label for sorting by duration (shortest)',
+ },
+ sortByDurationLongest: {
+ id: 'authoring.selectvideomodal.sort.durationlongest.label',
+ defaultMessage: 'longest',
+ description: 'Dropdown label for sorting by duration (longest)',
+ },
+
+ // Video status labels
+ videoStatusAny: {
+ id: 'authoring.selectvideomodal.videostatusnone.label',
+ defaultMessage: 'Any status',
+ description: 'Label for video status (any status)',
+ },
+ videoStatusUploading: {
+ id: 'authoring.selectvideomodal.videostatusuploading.label',
+ defaultMessage: 'Uploading',
+ description: 'Label for video status (uploading)',
+ },
+ videoStatusProcessing: {
+ id: 'authoring.selectvideomodal.videostatusprocessing.label',
+ defaultMessage: 'In Progress',
+ description: 'Label for video status (processing)',
+ },
+ videoStatusReady: {
+ id: 'authoring.selectvideomodal.videostatusready.label',
+ defaultMessage: 'Ready',
+ description: 'Label for video status (ready)',
+ },
+ videoStatusFailed: {
+ id: 'authoring.selectvideomodal.videostatusfailed.label',
+ defaultMessage: 'Failed',
+ description: 'Label for video status (failed)',
+ },
+
+ // Hide switch
+ hideSelectedCourseVideosSwitchLabel: {
+ id: 'authoring.selectvideomodal.switch.hideselectedcoursevideos.label',
+ defaultMessage: 'Hide selected course videos',
+ description: 'Switch label for hide selected course videos',
+ },
+
+ // Errors
+ selectVideoError: {
+ id: 'authoring.selectvideomodal.error.selectVideoError',
+ defaultMessage: 'Select a video to continue.',
+ description:
+ 'Message presented to user when clicking Next without selecting a video',
+ },
+ fileSizeError: {
+ id: 'authoring.selectvideomodal.error.fileSizeError',
+ defaultMessage:
+ 'Video must be 10 MB or less. Please resize image and try again.',
+ description:
+ 'Message presented to user when file size of video is larger than 10 MB',
+ },
+ uploadVideoError: {
+ id: 'authoring.selectvideomodal.error.uploadVideoError',
+ defaultMessage: 'Failed to upload video. Please try again.',
+ description: 'Message presented to user when video fails to upload',
+ },
+ fetchVideosError: {
+ id: 'authoring.selectvideomodal.error.fetchVideosError',
+ defaultMessage: 'Failed to obtain course videos. Please try again.',
+ description: 'Message presented to user when videos are not found',
+ },
+};
+
+export default messages;
diff --git a/src/editors/containers/VideoGallery/utils.js b/src/editors/containers/VideoGallery/utils.js
new file mode 100644
index 0000000000..1b4767b417
--- /dev/null
+++ b/src/editors/containers/VideoGallery/utils.js
@@ -0,0 +1,63 @@
+import { StrictDict, keyStore } from '../../utils';
+import messages from './messages';
+
+const messageKeys = keyStore(messages);
+
+export const sortKeys = StrictDict({
+ dateNewest: 'dateNewest',
+ dateOldest: 'dateOldest',
+ nameAscending: 'nameAscending',
+ nameDescending: 'nameDescending',
+ durationShortest: 'durationShortest',
+ durationLongest: 'durationLongest',
+});
+
+export const sortMessages = StrictDict({
+ dateNewest: messages[messageKeys.sortByDateNewest],
+ dateOldest: messages[messageKeys.sortByDateOldest],
+ nameAscending: messages[messageKeys.sortByNameAscending],
+ nameDescending: messages[messageKeys.sortByNameDescending],
+ durationShortest: messages[messageKeys.sortByDurationShortest],
+ durationLongest: messages[messageKeys.sortByDurationLongest],
+});
+
+export const filterKeys = StrictDict({
+ anyStatus: 'anyStatus',
+ uploading: 'Uploading',
+ processing: 'In Progress',
+ ready: 'Ready',
+ failed: 'Failed',
+});
+
+export const filterMessages = StrictDict({
+ anyStatus: messages[messageKeys.videoStatusAny],
+ uploading: messages[messageKeys.videoStatusUploading],
+ processing: messages[messageKeys.videoStatusProcessing],
+ ready: messages[messageKeys.videoStatusReady],
+ failed: messages[messageKeys.videoStatusFailed],
+});
+
+export const sortFunctions = StrictDict({
+ dateNewest: (a, b) => b.dateAdded - a.dateAdded,
+ dateOldest: (a, b) => a.dateAdded - b.dateAdded,
+ nameAscending: (a, b) => {
+ const nameA = a.displayName.toLowerCase();
+ const nameB = b.displayName.toLowerCase();
+ if (nameA < nameB) { return -1; }
+ if (nameB < nameA) { return 1; }
+ return b.dateAdded - a.dateAdded;
+ },
+ nameDescending: (a, b) => {
+ const nameA = a.displayName.toLowerCase();
+ const nameB = b.displayName.toLowerCase();
+ if (nameA < nameB) { return 1; }
+ if (nameB < nameA) { return -1; }
+ return b.dateAdded - a.dateAdded;
+ },
+ durationShortest: (a, b) => a.duration - b.duration,
+ durationLongest: (a, b) => b.duration - a.duration,
+});
+
+export const acceptedImgKeys = StrictDict({
+ mp4: '.mp4',
+});
diff --git a/src/editors/containers/VideoGallery/utils.test.js b/src/editors/containers/VideoGallery/utils.test.js
new file mode 100644
index 0000000000..ef9818833f
--- /dev/null
+++ b/src/editors/containers/VideoGallery/utils.test.js
@@ -0,0 +1,62 @@
+import { sortFunctions } from './utils';
+
+describe('VideGallery utils', () => {
+ describe('sortFunctions', () => {
+ const dateA = {
+ dateAdded: new Date('2023-03-30'),
+ };
+ const dateB = {
+ dateAdded: new Date('2023-03-31'),
+ };
+ const nameA = {
+ displayName: 'This is the Name A',
+ dateAdded: new Date('2023-03-30'),
+ };
+ const nameB = {
+ displayName: 'Hello World',
+ dateAdded: new Date('2023-03-30'),
+ };
+ const nameC = {
+ displayName: 'Hello World',
+ dateAdded: new Date('2023-03-31'),
+ };
+ const durationA = {
+ duration: 10,
+ };
+ const durationB = {
+ duration: 100,
+ };
+ test('correct functionality of dateNewest', () => {
+ expect(sortFunctions.dateNewest(dateA, dateB)).toBeGreaterThan(0);
+ expect(sortFunctions.dateNewest(dateB, dateA)).toBeLessThan(0);
+ expect(sortFunctions.dateNewest(dateA, dateA)).toEqual(0);
+ });
+ test('correct functionality of dateOldest', () => {
+ expect(sortFunctions.dateOldest(dateA, dateB)).toBeLessThan(0);
+ expect(sortFunctions.dateOldest(dateB, dateA)).toBeGreaterThan(0);
+ expect(sortFunctions.dateOldest(dateA, dateA)).toEqual(0);
+ });
+ test('correct functionality of nameAscending', () => {
+ expect(sortFunctions.nameAscending(nameA, nameB)).toEqual(1);
+ expect(sortFunctions.nameAscending(nameB, nameA)).toEqual(-1);
+ expect(sortFunctions.nameAscending(nameA, nameA)).toEqual(0);
+ expect(sortFunctions.nameAscending(nameB, nameC)).toBeGreaterThan(0);
+ });
+ test('correct functionality of nameDescending', () => {
+ expect(sortFunctions.nameDescending(nameA, nameB)).toEqual(-1);
+ expect(sortFunctions.nameDescending(nameB, nameA)).toEqual(1);
+ expect(sortFunctions.nameDescending(nameA, nameA)).toEqual(0);
+ expect(sortFunctions.nameDescending(nameB, nameC)).toBeGreaterThan(0);
+ });
+ test('correct functionality of durationShortest', () => {
+ expect(sortFunctions.durationShortest(durationA, durationB)).toBeLessThan(0);
+ expect(sortFunctions.durationShortest(durationB, durationA)).toBeGreaterThan(0);
+ expect(sortFunctions.durationShortest(durationA, durationA)).toEqual(0);
+ });
+ test('correct functionality of durationLongest', () => {
+ expect(sortFunctions.durationLongest(durationA, durationB)).toBeGreaterThan(0);
+ expect(sortFunctions.durationLongest(durationB, durationA)).toBeLessThan(0);
+ expect(sortFunctions.durationLongest(durationA, durationA)).toEqual(0);
+ });
+ });
+});
diff --git a/src/editors/containers/VideoUploadEditor/VideoUploader.jsx b/src/editors/containers/VideoUploadEditor/VideoUploader.jsx
new file mode 100644
index 0000000000..028d1c085a
--- /dev/null
+++ b/src/editors/containers/VideoUploadEditor/VideoUploader.jsx
@@ -0,0 +1,98 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ Icon, IconButton, Dropzone, InputGroup, FormControl,
+} from '@openedx/paragon';
+import { ArrowForward, FileUpload, Close } from '@openedx/paragon/icons';
+import { useDispatch } from 'react-redux';
+import { thunkActions } from '../../data/redux';
+import * as hooks from './hooks';
+import messages from './messages';
+
+const URLUploader = () => {
+ const [textInputValue, setTextInputValue] = React.useState('');
+ const onURLUpload = hooks.onVideoUpload('selectedVideoUrl');
+ const intl = useIntl();
+ return (
+
+
+
+
+
+ {intl.formatMessage(messages.dropVideoFileHere)}
+ {intl.formatMessage(messages.info)}
+
+
+ OR
+
+
+
+ { event.stopPropagation(); }}
+ onChange={(event) => { setTextInputValue(event.target.value); }}
+ trailingElement={(
+ {
+ event.stopPropagation();
+ if (textInputValue.trim() !== '') {
+ onURLUpload(textInputValue);
+ }
+ }}
+ />
+ )}
+ />
+
+
+
+ );
+};
+
+export const VideoUploader = ({ setLoading }) => {
+ const dispatch = useDispatch();
+ const intl = useIntl();
+ const goBack = hooks.useHistoryGoBack();
+
+ const handleProcessUpload = ({ fileData }) => {
+ dispatch(thunkActions.video.uploadVideo({
+ supportedFiles: [fileData],
+ setLoadSpinner: setLoading,
+ postUploadRedirect: hooks.onVideoUpload('selectedVideoId'),
+ }));
+ };
+
+ return (
+
+ );
+};
+
+VideoUploader.propTypes = {
+ setLoading: PropTypes.func.isRequired,
+};
+
+export default VideoUploader;
diff --git a/src/editors/containers/VideoUploadEditor/VideoUploader.test.jsx b/src/editors/containers/VideoUploadEditor/VideoUploader.test.jsx
new file mode 100644
index 0000000000..90838d507b
--- /dev/null
+++ b/src/editors/containers/VideoUploadEditor/VideoUploader.test.jsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import { render, fireEvent, act } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { initializeMockApp } from '@edx/frontend-platform';
+import { configureStore } from '@reduxjs/toolkit';
+import { AppProvider } from '@edx/frontend-platform/react';
+import '@testing-library/jest-dom';
+import * as redux from 'react-redux';
+import * as hooks from './hooks';
+import { VideoUploader } from './VideoUploader';
+
+jest.unmock('react-redux');
+jest.unmock('@edx/frontend-platform/i18n');
+jest.unmock('@openedx/paragon');
+jest.unmock('@openedx/paragon/icons');
+
+describe('VideoUploader', () => {
+ const setLoadingMock = jest.fn();
+ const onURLUploadMock = jest.fn();
+ let store;
+
+ beforeEach(async () => {
+ store = configureStore({
+ reducer: (state, action) => ((action && action.newState) ? action.newState : state),
+ preloadedState: {
+ app: {
+ learningContextId: 'course-v1:test+test+test',
+ blockId: 'some-block-id',
+ },
+ },
+ });
+
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'test-user',
+ administrator: true,
+ roles: [],
+ },
+ });
+ });
+
+ const renderComponent = async (storeParam, setLoadingMockParam) => render(
+
+
+
+ ,
+ ,
+ );
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders as expected with default behavior', async () => {
+ expect(await renderComponent(store, setLoadingMock)).toMatchSnapshot();
+ });
+
+ it('calls onURLUpload when URL submit button is clicked', async () => {
+ const onVideoUploadSpy = jest.spyOn(hooks, 'onVideoUpload').mockImplementation(() => onURLUploadMock);
+
+ const { getByPlaceholderText, getAllByRole } = await renderComponent(store, setLoadingMock);
+
+ const urlInput = getByPlaceholderText('Paste your video ID or URL');
+ const urlSubmitButton = getAllByRole('button', { name: /submit/i });
+ expect(urlSubmitButton).toHaveLength(1);
+
+ fireEvent.change(urlInput, { target: { value: 'https://example.com/video.mp4' } });
+ urlSubmitButton.forEach((button) => fireEvent.click(button));
+ expect(onURLUploadMock).toHaveBeenCalledWith('https://example.com/video.mp4');
+
+ onVideoUploadSpy.mockRestore();
+ });
+
+ it('calls handleProcessUpload when file is selected', async () => {
+ const useDispatchSpy = jest.spyOn(redux, 'useDispatch');
+ const mockDispatchFn = jest.fn();
+ useDispatchSpy.mockReturnValue(mockDispatchFn);
+
+ const { getByTestId } = await renderComponent(store, setLoadingMock);
+
+ const fileInput = getByTestId('dropzone-container');
+ const file = new File(['file'], 'video.mp4', {
+ type: 'video/mp4',
+ });
+ Object.defineProperty(fileInput, 'files', {
+ value: [file],
+ });
+ await act(async () => fireEvent.drop(fileInput));
+ // Test dispacting thunkAction
+ expect(mockDispatchFn).toHaveBeenCalledWith(expect.any(Function));
+ useDispatchSpy.mockRestore();
+ });
+});
diff --git a/src/editors/containers/VideoUploadEditor/__snapshots__/VideoUploader.test.jsx.snap b/src/editors/containers/VideoUploadEditor/__snapshots__/VideoUploader.test.jsx.snap
new file mode 100644
index 0000000000..265aed2971
--- /dev/null
+++ b/src/editors/containers/VideoUploadEditor/__snapshots__/VideoUploader.test.jsx.snap
@@ -0,0 +1,384 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`VideoUploader renders as expected with default behavior 1`] = `
+{
+ "asFragment": [Function],
+ "baseElement":
+
+
+
+
+
+
+
+
+
+
+
+
+ Drag and drop video here or click to upload
+
+
+ Upload MP4 or MOV files (5 GB max)
+
+
+
+ OR
+
+
+
+
+
+
+ ,
+
+
+
+ ,
+ "container":
+
+
+
+
+
+
+
+
+
+
+
+ Drag and drop video here or click to upload
+
+
+ Upload MP4 or MOV files (5 GB max)
+
+
+
+ OR
+
+
+
+
+
+
+ ,
+
+
+
,
+ "debug": [Function],
+ "findAllByAltText": [Function],
+ "findAllByDisplayValue": [Function],
+ "findAllByLabelText": [Function],
+ "findAllByPlaceholderText": [Function],
+ "findAllByRole": [Function],
+ "findAllByTestId": [Function],
+ "findAllByText": [Function],
+ "findAllByTitle": [Function],
+ "findByAltText": [Function],
+ "findByDisplayValue": [Function],
+ "findByLabelText": [Function],
+ "findByPlaceholderText": [Function],
+ "findByRole": [Function],
+ "findByTestId": [Function],
+ "findByText": [Function],
+ "findByTitle": [Function],
+ "getAllByAltText": [Function],
+ "getAllByDisplayValue": [Function],
+ "getAllByLabelText": [Function],
+ "getAllByPlaceholderText": [Function],
+ "getAllByRole": [Function],
+ "getAllByTestId": [Function],
+ "getAllByText": [Function],
+ "getAllByTitle": [Function],
+ "getByAltText": [Function],
+ "getByDisplayValue": [Function],
+ "getByLabelText": [Function],
+ "getByPlaceholderText": [Function],
+ "getByRole": [Function],
+ "getByTestId": [Function],
+ "getByText": [Function],
+ "getByTitle": [Function],
+ "queryAllByAltText": [Function],
+ "queryAllByDisplayValue": [Function],
+ "queryAllByLabelText": [Function],
+ "queryAllByPlaceholderText": [Function],
+ "queryAllByRole": [Function],
+ "queryAllByTestId": [Function],
+ "queryAllByText": [Function],
+ "queryAllByTitle": [Function],
+ "queryByAltText": [Function],
+ "queryByDisplayValue": [Function],
+ "queryByLabelText": [Function],
+ "queryByPlaceholderText": [Function],
+ "queryByRole": [Function],
+ "queryByTestId": [Function],
+ "queryByText": [Function],
+ "queryByTitle": [Function],
+ "rerender": [Function],
+ "unmount": [Function],
+}
+`;
diff --git a/src/editors/containers/VideoUploadEditor/__snapshots__/index.test.jsx.snap b/src/editors/containers/VideoUploadEditor/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..0868961991
--- /dev/null
+++ b/src/editors/containers/VideoUploadEditor/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,392 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`VideoUploadEditor renders as expected with default behavior 1`] = `
+{
+ "asFragment": [Function],
+ "baseElement":
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Drag and drop video here or click to upload
+
+
+ Upload MP4 or MOV files (5 GB max)
+
+
+
+ OR
+
+
+
+
+
+
+
+ ,
+
+
+
+ ,
+ "container":
+
+
+
+
+
+
+
+
+
+
+
+
+ Drag and drop video here or click to upload
+
+
+ Upload MP4 or MOV files (5 GB max)
+
+
+
+ OR
+
+
+
+
+
+
+
+ ,
+
+
+
,
+ "debug": [Function],
+ "findAllByAltText": [Function],
+ "findAllByDisplayValue": [Function],
+ "findAllByLabelText": [Function],
+ "findAllByPlaceholderText": [Function],
+ "findAllByRole": [Function],
+ "findAllByTestId": [Function],
+ "findAllByText": [Function],
+ "findAllByTitle": [Function],
+ "findByAltText": [Function],
+ "findByDisplayValue": [Function],
+ "findByLabelText": [Function],
+ "findByPlaceholderText": [Function],
+ "findByRole": [Function],
+ "findByTestId": [Function],
+ "findByText": [Function],
+ "findByTitle": [Function],
+ "getAllByAltText": [Function],
+ "getAllByDisplayValue": [Function],
+ "getAllByLabelText": [Function],
+ "getAllByPlaceholderText": [Function],
+ "getAllByRole": [Function],
+ "getAllByTestId": [Function],
+ "getAllByText": [Function],
+ "getAllByTitle": [Function],
+ "getByAltText": [Function],
+ "getByDisplayValue": [Function],
+ "getByLabelText": [Function],
+ "getByPlaceholderText": [Function],
+ "getByRole": [Function],
+ "getByTestId": [Function],
+ "getByText": [Function],
+ "getByTitle": [Function],
+ "queryAllByAltText": [Function],
+ "queryAllByDisplayValue": [Function],
+ "queryAllByLabelText": [Function],
+ "queryAllByPlaceholderText": [Function],
+ "queryAllByRole": [Function],
+ "queryAllByTestId": [Function],
+ "queryAllByText": [Function],
+ "queryAllByTitle": [Function],
+ "queryByAltText": [Function],
+ "queryByDisplayValue": [Function],
+ "queryByLabelText": [Function],
+ "queryByPlaceholderText": [Function],
+ "queryByRole": [Function],
+ "queryByTestId": [Function],
+ "queryByText": [Function],
+ "queryByTitle": [Function],
+ "rerender": [Function],
+ "unmount": [Function],
+}
+`;
diff --git a/src/editors/containers/VideoUploadEditor/hooks.js b/src/editors/containers/VideoUploadEditor/hooks.js
new file mode 100644
index 0000000000..a2774d9c60
--- /dev/null
+++ b/src/editors/containers/VideoUploadEditor/hooks.js
@@ -0,0 +1,45 @@
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './hooks';
+import { selectors, thunkActions } from '../../data/redux';
+import store from '../../data/store';
+import * as appHooks from '../../hooks';
+
+export const {
+ navigateTo,
+} = appHooks;
+
+export const postUploadRedirect = (storeState, uploadType = 'selectedVideoUrl') => {
+ const learningContextId = selectors.app.learningContextId(storeState);
+ const blockId = selectors.app.blockId(storeState);
+ return (videoUrl) => navigateTo(`/course/${learningContextId}/editor/video/${blockId}?${uploadType}=${videoUrl}`);
+};
+
+export const onVideoUpload = (uploadType) => {
+ const storeState = store.getState();
+ return module.postUploadRedirect(storeState, uploadType);
+};
+
+export const useUploadVideo = async ({
+ dispatch,
+ supportedFiles,
+ setLoadSpinner,
+ postUploadRedirectFunction,
+}) => {
+ dispatch(thunkActions.video.uploadVideo({
+ supportedFiles,
+ setLoadSpinner,
+ postUploadRedirectFunction,
+ }));
+};
+
+export const useHistoryGoBack = () => (() => window.history.back());
+
+export default {
+ postUploadRedirect,
+ onVideoUpload,
+ useUploadVideo,
+ useHistoryGoBack,
+};
diff --git a/src/editors/containers/VideoUploadEditor/index.jsx b/src/editors/containers/VideoUploadEditor/index.jsx
new file mode 100644
index 0000000000..ae2be7b5fb
--- /dev/null
+++ b/src/editors/containers/VideoUploadEditor/index.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Spinner } from '@openedx/paragon';
+import './index.scss';
+import messages from './messages';
+import { VideoUploader } from './VideoUploader';
+
+const VideoUploadEditor = () => {
+ const [loading, setLoading] = React.useState(false);
+ const intl = useIntl();
+
+ return (!loading) ? (
+
+
+
+ ) : (
+
+
+
+ );
+};
+
+export default VideoUploadEditor;
diff --git a/src/editors/containers/VideoUploadEditor/index.scss b/src/editors/containers/VideoUploadEditor/index.scss
new file mode 100644
index 0000000000..4e27f44d12
--- /dev/null
+++ b/src/editors/containers/VideoUploadEditor/index.scss
@@ -0,0 +1,38 @@
+.dropzone-middle {
+ border: 2px dashed #CCCCCC;
+
+ &.active {
+ border: 2px solid #262626; /* change color when active */
+ }
+}
+
+.pgn__dropzone {
+ height: 96vh;
+ width: 100%;
+}
+
+.video-id-prompt {
+ input::placeholder {
+ color: #454545;
+ // color: #5E35B1;
+ font-weight: '500';
+ word-wrap: 'break-word';
+ }
+
+ button {
+ border: none !important;
+ background-color: #FFFFFF;
+ }
+
+ .btn-icon.url-submit-button {
+ &, &:active, &:hover {
+ background-color: transparent !important;
+ border: none !important;
+ color: #707070 !important;
+ }
+ }
+
+ .prompt-button {
+ background: rgba(239 234 247 / .70);
+ }
+}
diff --git a/src/editors/containers/VideoUploadEditor/index.test.jsx b/src/editors/containers/VideoUploadEditor/index.test.jsx
new file mode 100644
index 0000000000..92b291c499
--- /dev/null
+++ b/src/editors/containers/VideoUploadEditor/index.test.jsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { initializeMockApp } from '@edx/frontend-platform';
+import { AppProvider } from '@edx/frontend-platform/react';
+import { configureStore } from '@reduxjs/toolkit';
+import { render, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import VideoUploadEditor from '.';
+
+jest.unmock('react-redux');
+jest.unmock('@edx/frontend-platform/i18n');
+jest.unmock('@openedx/paragon');
+jest.unmock('@openedx/paragon/icons');
+
+describe('VideoUploadEditor', () => {
+ let store;
+
+ const renderComponent = async (storeParam) => render(
+
+
+
+ ,
+ ,
+ );
+
+ beforeEach(async () => {
+ store = configureStore({
+ reducer: (state, action) => ((action && action.newState) ? action.newState : state),
+ preloadedState: {},
+ });
+
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'test-user',
+ administrator: true,
+ roles: [],
+ },
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders as expected with default behavior', async () => {
+ expect(await renderComponent(store)).toMatchSnapshot();
+ });
+
+ it('calls window.history.back when close button is clicked', async () => {
+ const container = await renderComponent(store);
+ const closeButton = container.getAllByRole('button', { name: /close/i });
+ const oldHistoryBack = window.history.back;
+ window.history.back = jest.fn();
+
+ expect(closeButton).toHaveLength(1);
+ expect(window.history.back).not.toHaveBeenCalled();
+ closeButton.forEach((button) => fireEvent.click(button));
+ expect(window.history.back).toHaveBeenCalled();
+
+ window.history.back = oldHistoryBack;
+ });
+});
diff --git a/src/editors/containers/VideoUploadEditor/messages.js b/src/editors/containers/VideoUploadEditor/messages.js
new file mode 100644
index 0000000000..0a071e53c8
--- /dev/null
+++ b/src/editors/containers/VideoUploadEditor/messages.js
@@ -0,0 +1,36 @@
+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',
+ },
+ pasteURL: {
+ id: 'VideoUploadEditor.pasteURL',
+ defaultMessage: 'Paste your video ID or URL',
+ description: 'Paste URL message for video upload',
+ },
+ closeButtonAltText: {
+ id: 'VideoUploadEditor.closeButtonAltText',
+ defaultMessage: 'Close',
+ description: 'Close button alt text',
+ },
+ submitButtonAltText: {
+ id: 'VideoUploadEditor.submitButtonAltText',
+ defaultMessage: 'Submit',
+ description: 'Submit button alt text',
+ },
+});
+
+export default messages;
diff --git a/src/editors/data/constants/advancedOlxTemplates/circuitschematic.js b/src/editors/data/constants/advancedOlxTemplates/circuitschematic.js
new file mode 100644
index 0000000000..e3d5eb6ada
--- /dev/null
+++ b/src/editors/data/constants/advancedOlxTemplates/circuitschematic.js
@@ -0,0 +1,95 @@
+/* eslint-disable */
+// ---
+// metadata:
+// display_name: Circuit Schematic Builder
+// markdown: !!null
+// data: |
+const circuitSchematic = `
+
+ Circuit schematic problems allow students to create virtual circuits by
+ arranging elements such as voltage sources, capacitors, resistors, and
+ MOSFETs on an interactive grid. The system evaluates a DC, AC, or
+ transient analysis of the circuit.
+
+
+ For more information, see
+
+ Circuit Schematic Builder Problem in Building and Running an edX Course .
+
+
+ When you add the problem, be sure to select Settings
+ to specify a Display Name and other values that apply.
+
+ You can use the following example problems as models.
+
+
+ Make a voltage divider that splits the provided voltage evenly.
+
+
+
+
+
+dc_value = "dc analysis not found"
+for response in submission[0]:
+ if response[0] == 'dc':
+ for node in response[1:]:
+ dc_value = node['output']
+
+if dc_value == .5:
+ correct = ['correct']
+else:
+ correct = ['incorrect']
+
+
+
+
+
Explanation
+
+ You can form a voltage divider that evenly divides the input
+ voltage with two identically valued resistors, with the sampled
+ voltage taken in between the two.
+
+
+
+
+
+
+
+ Make a high-pass filter.
+
+
+
+
+ac_values = None
+for response in submission[0]:
+ if response[0] == 'ac':
+ for node in response[1:]:
+ ac_values = node['NodeA']
+print "the ac analysis value:", ac_values
+if ac_values == None:
+ correct = ['incorrect']
+elif ac_values[0][1] < ac_values[1][1]:
+ correct = ['correct']
+else:
+ correct = ['incorrect']
+
+
+
+
+
Explanation
+
+ You can form a simple high-pass filter without any further
+ constraints by simply putting a resistor in series with a
+ capacitor. The actual values of the components do not really
+ matter in this problem.
+
+
+
+
+
+ `;
+
+export default circuitSchematic;
diff --git a/src/editors/data/constants/advancedOlxTemplates/customgrader.js b/src/editors/data/constants/advancedOlxTemplates/customgrader.js
new file mode 100644
index 0000000000..f24c59263b
--- /dev/null
+++ b/src/editors/data/constants/advancedOlxTemplates/customgrader.js
@@ -0,0 +1,83 @@
+/* eslint-disable */
+// ---
+// metadata:
+// display_name: Custom Python-Evaluated Input
+// markdown: !!null
+// data: |
+const customGrader = `
+
+ In custom Python-evaluated input (also called "write-your-own-grader"
+ problems), the grader uses a Python script that you create and embed in
+ the problem to evaluate a learner's response or provide hints. These
+ problems can be any type. Numerical input and text input problems are
+ the most common write-your-own-grader problems.
+
+
+ You can use script tag format or answer tag format to create these problems.
+
+
+ You can create custom Python-evaluated input problems that provide
+ partial credit or that randomize variables in the Python code. You can
+ also add images to the solution by using an HTML "img" tag. Note that
+ the "img" tag must be between the "div" tags that are inside the
+ "solution" tags, and that learners do not see these images until they
+ click the "Show Answer" button.
+
+
+ For more information, see
+ Write-Your-Own-Grader Problem in Building and Running an edX Course .
+
+
+ When you add the problem, be sure to select Settings
+ to specify a Display Name and other values that apply.
+
+ You can use the following example problem as a model.
+
+
+
+
+ Enter two integers that sum to 10.
+
+
+
+
+
Explanation
+
Any set of integers on the line \(y = 10 - x\) satisfy these constraints.
+
+
+
+
+
+ Enter two integers that sum to 20.
+
+
+
+
+
Explanation
+
Any set of integers on the line \(y = 20 - x\) satisfy these constraints.
+
To add an image to the solution, use an HTML "img" tag. Make sure to include alt text.
+
+
+
+
+ `;
+
+export default customGrader;
diff --git a/src/editors/data/constants/advancedOlxTemplates/formularesponse.js b/src/editors/data/constants/advancedOlxTemplates/formularesponse.js
new file mode 100644
index 0000000000..72aaa62c65
--- /dev/null
+++ b/src/editors/data/constants/advancedOlxTemplates/formularesponse.js
@@ -0,0 +1,16 @@
+/* eslint-disable */
+// ---
+// metadata:
+// display_name: Math Expression Input
+// markdown: !!null
+// data: |
+const formulaResponse = `
+
+ You can use this template as a guide to the OLX markup to use for math expression problems. Edit this component to replace the example with your own assessment.
+ Add the question text, or prompt, here. This text is required. Example: Write an expression for the product of R_1, R_2, and the inverse of R_3.
+ You can add an optional tip or note related to the prompt like this. Example: To test this example, the correct answer is R_1*R_2/R_3
+
+
+
+ `;
+export default formulaResponse;
diff --git a/src/editors/data/constants/advancedOlxTemplates/imageresponse.js b/src/editors/data/constants/advancedOlxTemplates/imageresponse.js
new file mode 100644
index 0000000000..1980d40ead
--- /dev/null
+++ b/src/editors/data/constants/advancedOlxTemplates/imageresponse.js
@@ -0,0 +1,33 @@
+/* eslint-disable */
+// ---
+// metadata:
+// display_name: Image Mapped Input
+// markdown: !!null
+// data: |
+const imageResponse = `
+
+ In an image mapped input problem, also known as a "pointing on a picture" problem, students click inside a defined region in an image. You define this region by including coordinates in the body of the problem. You can define one rectangular region,
+ multiple rectangular regions, or one non-rectangular region. For more information, see
+ Image Mapped Input Problem
+ in
+ Building and Running an edx Course .
+
+ When you add the problem, be sure to select
+ Settings
+ to specify a
+ Display Name
+ and other values that apply.
+ You can use the following example problem as a model.
+
+ What country is home to the Great Pyramid of Giza as well as the cities of Cairo and Memphis? Click the country on the map below.
+
+
+
+
Explanation
+
Egypt is home to not only the Pyramids, Cairo, and Memphis, but also the Sphinx and the ancient Royal Library of Alexandria.
+
+
+
+ `;
+
+export default imageResponse;
diff --git a/src/editors/data/constants/advancedOlxTemplates/index.js b/src/editors/data/constants/advancedOlxTemplates/index.js
new file mode 100644
index 0000000000..16602efdb2
--- /dev/null
+++ b/src/editors/data/constants/advancedOlxTemplates/index.js
@@ -0,0 +1,11 @@
+import { StrictDict } from '../../../utils';
+import circuitSchematic from './circuitschematic';
+import customGrader from './customgrader';
+import formulaResponse from './formularesponse';
+import imageResponse from './imageresponse';
+import jsInputResponse from './jsinput_response';
+import problemWithHint from './problem_with_hint';
+
+export default StrictDict({
+ circuitSchematic, customGrader, formulaResponse, imageResponse, jsInputResponse, problemWithHint,
+});
diff --git a/src/editors/data/constants/advancedOlxTemplates/jsinput_response.js b/src/editors/data/constants/advancedOlxTemplates/jsinput_response.js
new file mode 100644
index 0000000000..34e6f6e55c
--- /dev/null
+++ b/src/editors/data/constants/advancedOlxTemplates/jsinput_response.js
@@ -0,0 +1,83 @@
+/* eslint-disable */
+// ---
+// metadata:
+// display_name: Custom JavaScript Display and Grading
+// markdown: !!null
+// showanswer: never
+// data: |
+const jsInputResponse = `
+
+ In these problems (also called custom JavaScript problems or JS Input
+ problems), you add a problem or tool that uses JavaScript in Studio.
+ Studio embeds the problem in an IFrame so that your learners can
+ interact with it in the LMS. You can grade your learners' work using
+ JavaScript and some basic Python, and the grading is integrated into the
+ edX grading system.
+
+
+ The JS Input problem that you create must use HTML, JavaScript, and
+ cascading style sheets (CSS). You can use any application creation tool,
+ such as the Google Web Toolkit (GWT), to create your JS Input problem.
+
+
+ For more information, see
+
+ Custom JavaScript Problem in Building and Running an edX Course .
+
+
+ JavaScript developers can also see
+
+ Custom JavaScript Applications in the EdX Developer's Guide .
+
+
+ When you add the problem, be sure to select Settings
+ to specify a Display Name and other values that apply.
+ Also, be sure to specify a title attribute on the jsinput tag;
+ this title is used for the title attribute on the generated IFrame. Generally,
+ the title attribute on the IFrame should match the title tag of the HTML hosted
+ within the IFrame, which is specified by the html_file attribute.
+
+ You can use the following example problem as a model.
+
+
+
+ This is paragraph text displayed before the IFrame.
+
+
+ `;
+
+export default jsInputResponse;
diff --git a/src/editors/data/constants/advancedOlxTemplates/problem_with_hint.js b/src/editors/data/constants/advancedOlxTemplates/problem_with_hint.js
new file mode 100644
index 0000000000..442b0cc3d7
--- /dev/null
+++ b/src/editors/data/constants/advancedOlxTemplates/problem_with_hint.js
@@ -0,0 +1,48 @@
+/* eslint-disable */
+// ---
+// metadata:
+// display_name: Problem with Adaptive Hint
+// markdown: !!null
+// data: |
+const problemWithHint = `
+
+
Problem With Adaptive Hint
+ This problem demonstrates a question with hints, based on using the hintfn method.
+
+
+
+ What is the best programming language that exists today? You may enter your answer in upper or lower case, with or without quotes.
+
+
+
+
+ `;
+
+export default problemWithHint;
diff --git a/src/editors/data/constants/analyticsEvt.js b/src/editors/data/constants/analyticsEvt.js
new file mode 100644
index 0000000000..1fef2fa7d1
--- /dev/null
+++ b/src/editors/data/constants/analyticsEvt.js
@@ -0,0 +1,7 @@
+const analyticsEvt = {
+ editorSaveClick: 'edx.ui.authoring.editor.save',
+ editorCancelClick: 'edx.ui.authoring.editor.cancel',
+ videoGalleryCancelClick: 'edx.ui.authoring.videogallery.cancel',
+};
+
+export default analyticsEvt;
diff --git a/src/editors/data/constants/app.js b/src/editors/data/constants/app.js
new file mode 100644
index 0000000000..3b0fc7b9c1
--- /dev/null
+++ b/src/editors/data/constants/app.js
@@ -0,0 +1,11 @@
+import { StrictDict } from '../../utils';
+
+/* eslint-disable import/prefer-default-export */
+export const blockTypes = StrictDict({
+ html: 'html',
+ video: 'video',
+ problem: 'problem',
+ // ADDED_EDITORS GO BELOW
+ video_upload: 'video_upload',
+ game: 'game',
+});
diff --git a/src/editors/data/constants/basicOlxTemplates/dropdown.js b/src/editors/data/constants/basicOlxTemplates/dropdown.js
new file mode 100644
index 0000000000..e66e0d1f0d
--- /dev/null
+++ b/src/editors/data/constants/basicOlxTemplates/dropdown.js
@@ -0,0 +1,12 @@
+/* eslint-disable */
+const dropdown = `
+
+
+
+
+
+
+
+ `
+
+export default dropdown;
diff --git a/src/editors/data/constants/basicOlxTemplates/index.js b/src/editors/data/constants/basicOlxTemplates/index.js
new file mode 100644
index 0000000000..977d3b7a54
--- /dev/null
+++ b/src/editors/data/constants/basicOlxTemplates/index.js
@@ -0,0 +1,10 @@
+import { StrictDict } from '../../../utils';
+import dropdown from './dropdown';
+import multiSelect from './multiSelect';
+import numeric from './numeric';
+import singleSelect from './singleSelect';
+import textInput from './textInput';
+
+export default StrictDict({
+ dropdown, multiSelect, numeric, singleSelect, textInput,
+});
diff --git a/src/editors/data/constants/basicOlxTemplates/multiSelect.js b/src/editors/data/constants/basicOlxTemplates/multiSelect.js
new file mode 100644
index 0000000000..a743ae6845
--- /dev/null
+++ b/src/editors/data/constants/basicOlxTemplates/multiSelect.js
@@ -0,0 +1,12 @@
+/* eslint-disable */
+ const multiSelect= `
+
+
+
+
+
+
+
+ `
+
+export default multiSelect;
\ No newline at end of file
diff --git a/src/editors/data/constants/basicOlxTemplates/numeric.js b/src/editors/data/constants/basicOlxTemplates/numeric.js
new file mode 100644
index 0000000000..5b7a915c9c
--- /dev/null
+++ b/src/editors/data/constants/basicOlxTemplates/numeric.js
@@ -0,0 +1,9 @@
+/* eslint-disable */
+const numeric = `
+
+
+
+
+ `
+
+export default numeric;
\ No newline at end of file
diff --git a/src/editors/data/constants/basicOlxTemplates/singleSelect.js b/src/editors/data/constants/basicOlxTemplates/singleSelect.js
new file mode 100644
index 0000000000..ea0e370366
--- /dev/null
+++ b/src/editors/data/constants/basicOlxTemplates/singleSelect.js
@@ -0,0 +1,12 @@
+/* eslint-disable */
+const singleSelect = `
+
+
+
+
+
+
+
+ `
+
+export default singleSelect;
\ No newline at end of file
diff --git a/src/editors/data/constants/basicOlxTemplates/textInput.js b/src/editors/data/constants/basicOlxTemplates/textInput.js
new file mode 100644
index 0000000000..a1ece7a16e
--- /dev/null
+++ b/src/editors/data/constants/basicOlxTemplates/textInput.js
@@ -0,0 +1,9 @@
+/* eslint-disable */
+const textInput =`
+
+
+
+
+ `
+
+export default textInput;
\ No newline at end of file
diff --git a/src/editors/data/constants/licenses.js b/src/editors/data/constants/licenses.js
new file mode 100644
index 0000000000..9b021aca18
--- /dev/null
+++ b/src/editors/data/constants/licenses.js
@@ -0,0 +1,26 @@
+import { StrictDict } from '../../utils';
+
+export const LicenseNames = StrictDict({
+ select: 'Select',
+ allRightsReserved: 'All Rights Reserved',
+ creativeCommons: 'Creative Commons',
+});
+
+export const LicenseTypes = StrictDict({
+ allRightsReserved: 'all-rights-reserved',
+ creativeCommons: 'creative-commons',
+ select: 'select',
+ // publicDomainDedication: 'public-domain-dedication', // future?
+});
+
+export const LicenseLevel = StrictDict({
+ block: 'block',
+ course: 'course',
+ library: 'library',
+});
+
+export default {
+ LicenseLevel,
+ LicenseNames,
+ LicenseTypes,
+};
diff --git a/src/editors/data/constants/mockData.js b/src/editors/data/constants/mockData.js
new file mode 100644
index 0000000000..d8d4b9f26a
--- /dev/null
+++ b/src/editors/data/constants/mockData.js
@@ -0,0 +1,96 @@
+/* istanbul ignore file */
+export const mockImageData = [
+ {
+ displayName: 'shahrukh.jpg',
+ contentType: 'image/jpeg',
+ dateAdded: 'Jan 05, 2022 at 17:38 UTC',
+ url: '/asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
+ externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
+ portableUrl: '/static/shahrukh.jpg',
+ thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@shahrukh.jpg',
+ locked: false,
+ id: 'asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
+ },
+ {
+ displayName: 'IMG_5899.jpg',
+ contentType: 'image/jpeg',
+ dateAdded: 'Nov 16, 2021 at 18:55 UTC',
+ url: '/asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
+ externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
+ portableUrl: '/static/IMG_5899.jpg',
+ thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@IMG_5899.jpg',
+ locked: false,
+ id: 'asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
+ },
+ {
+ displayName: 'ccexample.srt',
+ contentType: 'application/octet-stream',
+ dateAdded: 'Nov 01, 2021 at 15:42 UTC',
+ url: '/asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
+ externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
+ portableUrl: '/static/ccexample.srt',
+ thumbnail: null,
+ locked: false,
+ id: 'asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
+ },
+ {
+ displayName: 'Tennis Ball.jpeg',
+ contentType: 'image/jpeg',
+ dateAdded: 'Aug 04, 2021 at 16:52 UTC',
+ url: '/asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
+ externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
+ portableUrl: '/static/Tennis_Ball.jpeg',
+ thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@Tennis_Ball-jpeg.jpg',
+ locked: false,
+ id: 'asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
+ },
+];
+
+export const mockVideoData = [
+ {
+ displayName: 'shahrukh.jpg',
+ contentType: 'image/jpeg',
+ dateAdded: 'Jan 05, 2022 at 17:38 UTC',
+ url: '/asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
+ externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
+ portableUrl: '/static/shahrukh.jpg',
+ thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@shahrukh.jpg',
+ locked: false,
+ id: 'asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
+ },
+ {
+ displayName: 'IMG_5899.jpg',
+ contentType: 'image/jpeg',
+ dateAdded: 'Nov 16, 2021 at 18:55 UTC',
+ url: '/asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
+ externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
+ portableUrl: '/static/IMG_5899.jpg',
+ thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@IMG_5899.jpg',
+ locked: false,
+ id: 'asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
+ },
+ {
+ displayName: 'ccexample.srt',
+ contentType: 'application/octet-stream',
+ dateAdded: 'Nov 01, 2021 at 15:42 UTC',
+ url: '/asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
+ externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
+ portableUrl: '/static/ccexample.srt',
+ thumbnail: null,
+ locked: false,
+ id: 'asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
+ },
+ {
+ displayName: 'Tennis Ball.jpeg',
+ contentType: 'image/jpeg',
+ dateAdded: 'Aug 04, 2021 at 16:52 UTC',
+ url: '/asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
+ externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
+ portableUrl: '/static/Tennis_Ball.jpeg',
+ thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@Tennis_Ball-jpeg.jpg',
+ locked: false,
+ id: 'asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
+ },
+];
+
+export const mockBlockIdByType = (type) => `${type}-block-id`;
diff --git a/src/editors/data/constants/problem.js b/src/editors/data/constants/problem.js
new file mode 100644
index 0000000000..5dcc8558cc
--- /dev/null
+++ b/src/editors/data/constants/problem.js
@@ -0,0 +1,229 @@
+import { StrictDict } from '../../utils';
+import singleSelect from '../images/singleSelect.png';
+import multiSelect from '../images/multiSelect.png';
+import dropdown from '../images/dropdown.png';
+import numericalInput from '../images/numericalInput.png';
+import textInput from '../images/textInput.png';
+import advancedOlxTemplates from './advancedOlxTemplates';
+import basicOlxTemplates from './basicOlxTemplates';
+
+export const ProblemTypeKeys = StrictDict({
+ SINGLESELECT: 'multiplechoiceresponse',
+ MULTISELECT: 'choiceresponse',
+ DROPDOWN: 'optionresponse',
+ NUMERIC: 'numericalresponse',
+ TEXTINPUT: 'stringresponse',
+ ADVANCED: 'advanced',
+});
+
+export const ProblemTypes = StrictDict({
+ [ProblemTypeKeys.SINGLESELECT]: {
+ title: 'Single select',
+ preview: singleSelect,
+ previewDescription: 'Learners must select the correct answer from a list of possible options.',
+ description: 'Enter your single select answers below and select which choices are correct. Learners must choose one correct answer.',
+ helpLink: 'https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/multiple_choice.html',
+ prev: ProblemTypeKeys.TEXTINPUT,
+ next: ProblemTypeKeys.MULTISELECT,
+ template: basicOlxTemplates.singleSelect,
+
+ },
+ [ProblemTypeKeys.MULTISELECT]: {
+ title: 'Multi-select',
+ preview: multiSelect,
+ previewDescription: 'Learners must select all correct answers from a list of possible options.',
+ description: 'Enter your multi select answers below and select which choices are correct. Learners must choose all correct answers.',
+ helpLink: 'https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/checkbox.html',
+ next: ProblemTypeKeys.DROPDOWN,
+ prev: ProblemTypeKeys.SINGLESELECT,
+ template: basicOlxTemplates.multiSelect,
+ },
+ [ProblemTypeKeys.DROPDOWN]: {
+ title: 'Dropdown',
+ preview: dropdown,
+ previewDescription: 'Learners must select the correct answer from a list of possible options',
+ description: 'Enter your dropdown answers below and select which choice is correct. Learners must select one correct answer.',
+ helpLink: 'https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/dropdown.html',
+ next: ProblemTypeKeys.NUMERIC,
+ prev: ProblemTypeKeys.MULTISELECT,
+ template: basicOlxTemplates.dropdown,
+ },
+ [ProblemTypeKeys.NUMERIC]: {
+ title: 'Numerical input',
+ preview: numericalInput,
+ previewDescription: 'Specify one or more correct numeric answers, submitted in a response field.',
+ description: 'Enter correct numerical input answers below. Learners must enter one correct answer.',
+ helpLink: 'https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/numerical_input.html',
+ next: ProblemTypeKeys.TEXTINPUT,
+ prev: ProblemTypeKeys.DROPDOWN,
+ template: basicOlxTemplates.numeric,
+ },
+ [ProblemTypeKeys.TEXTINPUT]: {
+ title: 'Text input',
+ preview: textInput,
+ previewDescription: 'Specify one or more correct text answers, including numbers and special characters, submitted in a response field.',
+ description: 'Enter your text input answers below and select which choices are correct. Learners must enter one correct answer.',
+ helpLink: 'https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/text_input.html',
+ prev: ProblemTypeKeys.NUMERIC,
+ next: ProblemTypeKeys.SINGLESELECT,
+ template: basicOlxTemplates.textInput,
+ },
+ [ProblemTypeKeys.ADVANCED]: {
+ title: 'Advanced Problem',
+ preview: ('
'),
+ description: 'An Advanced Problem Type',
+ helpLink: 'something.com',
+ },
+});
+
+export const AdvanceProblemKeys = StrictDict({
+ BLANK: 'blankadvanced',
+ CIRCUITSCHEMATIC: 'circuitschematic',
+ JSINPUT: 'jsinputresponse',
+ CUSTOMGRADER: 'customgrader',
+ IMAGE: 'imageresponse',
+ FORMULA: 'formularesponse',
+ PROBLEMWITHHINT: 'problemwithhint',
+});
+
+export const AdvanceProblems = StrictDict({
+ [AdvanceProblemKeys.BLANK]: {
+ title: 'Blank problem',
+ status: '',
+ template: ' ',
+ },
+ [AdvanceProblemKeys.CIRCUITSCHEMATIC]: {
+ title: 'Circuit schematic builder',
+ status: 'Not supported',
+ template: advancedOlxTemplates.circuitSchematic,
+ },
+ [AdvanceProblemKeys.JSINPUT]: {
+ title: 'Custom JavaScript display and grading',
+ status: '',
+ template: advancedOlxTemplates.jsInputResponse,
+ },
+ [AdvanceProblemKeys.CUSTOMGRADER]: {
+ title: 'Custom Python-evaluated input',
+ status: 'Provisional',
+ template: advancedOlxTemplates.customGrader,
+ },
+ [AdvanceProblemKeys.IMAGE]: {
+ title: 'Image mapped input',
+ status: 'Not supported',
+ template: advancedOlxTemplates.imageResponse,
+ },
+ [AdvanceProblemKeys.FORMULA]: {
+ title: 'Math expression input',
+ status: '',
+ template: advancedOlxTemplates.formulaResponse,
+ },
+ [AdvanceProblemKeys.PROBLEMWITHHINT]: {
+ title: 'Problem with adaptive hint',
+ status: 'Not supported',
+ template: advancedOlxTemplates.problemWithHint,
+ },
+});
+
+export const ShowAnswerTypesKeys = StrictDict({
+ ALWAYS: 'always',
+ ANSWERED: 'answered',
+ ATTEMPTED: 'attempted',
+ CLOSED: 'closed',
+ FINISHED: 'finished',
+ CORRECT_OR_PAST_DUE: 'correct_or_past_due',
+ PAST_DUE: 'past_due',
+ NEVER: 'never',
+ AFTER_SOME_NUMBER_OF_ATTEMPTS: 'after_attempts',
+ AFTER_ALL_ATTEMPTS: 'after_all_attempts',
+ AFTER_ALL_ATTEMPTS_OR_CORRECT: 'after_all_attempts_or_correct',
+ ATTEMPTED_NO_PAST_DUE: 'attempted_no_past_due',
+});
+
+export const ShowAnswerTypes = StrictDict({
+ [ShowAnswerTypesKeys.ALWAYS]: {
+ id: 'authoring.problemeditor.settings.showanswertype.always',
+ defaultMessage: 'Always',
+ },
+ [ShowAnswerTypesKeys.ANSWERED]: {
+ id: 'authoring.problemeditor.settings.showanswertype.answered',
+ defaultMessage: 'Answered',
+ },
+ [ShowAnswerTypesKeys.ATTEMPTED]: {
+ id: 'authoring.problemeditor.settings.showanswertype.attempted',
+ defaultMessage: 'Attempted or Past Due',
+ },
+ [ShowAnswerTypesKeys.CLOSED]: {
+ id: 'authoring.problemeditor.settings.showanswertype.closed',
+ defaultMessage: 'Closed',
+ },
+ [ShowAnswerTypesKeys.FINISHED]: {
+ id: 'authoring.problemeditor.settings.showanswertype.finished',
+ defaultMessage: 'Finished',
+ },
+ [ShowAnswerTypesKeys.CORRECT_OR_PAST_DUE]: {
+ id: 'authoring.problemeditor.settings.showanswertype.correct_or_past_due',
+ defaultMessage: 'Correct or Past Due',
+ },
+ [ShowAnswerTypesKeys.PAST_DUE]: {
+ id: 'authoring.problemeditor.settings.showanswertype.past_due',
+ defaultMessage: 'Past Due',
+ },
+ [ShowAnswerTypesKeys.NEVER]: {
+ id: 'authoring.problemeditor.settings.showanswertype.never',
+ defaultMessage: 'Never',
+ },
+ [ShowAnswerTypesKeys.AFTER_SOME_NUMBER_OF_ATTEMPTS]: {
+ id: 'authoring.problemeditor.settings.showanswertype.after_attempts',
+ defaultMessage: 'After Some Number of Attempts',
+ },
+ [ShowAnswerTypesKeys.AFTER_ALL_ATTEMPTS]: {
+ id: 'authoring.problemeditor.settings.showanswertype.after_all_attempts',
+ defaultMessage: 'After All Attempts',
+ },
+ [ShowAnswerTypesKeys.AFTER_ALL_ATTEMPTS_OR_CORRECT]: {
+ id: 'authoring.problemeditor.settings.showanswertype.after_all_attempts_or_correct',
+ defaultMessage: 'After All Attempts or Correct',
+ },
+ [ShowAnswerTypesKeys.ATTEMPTED_NO_PAST_DUE]: {
+ id: 'authoring.problemeditor.settings.showanswertype.attempted_no_past_due',
+ defaultMessage: 'Attempted',
+ },
+});
+
+export const RandomizationTypesKeys = StrictDict({
+ NEVER: 'never',
+ ALWAYS: 'always',
+ ONRESET: 'onreset',
+ PERSTUDENT: 'per_student',
+});
+
+export const RandomizationTypes = StrictDict({
+ [RandomizationTypesKeys.ALWAYS]: {
+ id: 'authoring.problemeditor.settings.RandomizationTypes.always',
+ defaultMessage: 'Always',
+ },
+ [RandomizationTypesKeys.NEVER]: {
+ id: 'authoring.problemeditor.settings.RandomizationTypes.never',
+ defaultMessage: 'Never',
+ },
+ [RandomizationTypesKeys.ONRESET]: {
+ id: 'authoring.problemeditor.settings.RandomizationTypes.onreset',
+ defaultMessage: 'On Reset',
+ },
+ [RandomizationTypesKeys.PERSTUDENT]: {
+ id: 'authoring.problemeditor.settings.RandomizationTypes.perstudent',
+ defaultMessage: 'Per Student',
+ },
+});
+
+export const RichTextProblems = [ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT];
+
+export const settingsOlxAttributes = [
+ '@_display_name',
+ '@_weight',
+ '@_max_atempts',
+ '@_showanswer',
+ '@_show_reset_button',
+ '@_submission_wait_seconds',
+ '@_attempts_before_showanswer_button',
+];
diff --git a/src/editors/data/constants/requests.js b/src/editors/data/constants/requests.js
new file mode 100644
index 0000000000..ec736f27ea
--- /dev/null
+++ b/src/editors/data/constants/requests.js
@@ -0,0 +1,30 @@
+import { StrictDict } from '../../utils';
+
+export const RequestStates = StrictDict({
+ inactive: 'inactive',
+ pending: 'pending',
+ completed: 'completed',
+ failed: 'failed',
+});
+
+export const RequestKeys = StrictDict({
+ fetchVideos: 'fetchVideos',
+ fetchBlock: 'fetchBlock',
+ fetchImages: 'fetchImages',
+ fetchUnit: 'fetchUnit',
+ fetchStudioView: 'fetchStudioView',
+ saveBlock: 'saveBlock',
+ uploadVideo: 'uploadVideo',
+ allowThumbnailUpload: 'allowThumbnailUpload',
+ uploadThumbnail: 'uploadThumbnail',
+ uploadTranscript: 'uploadTranscript',
+ deleteTranscript: 'deleteTranscript',
+ fetchCourseDetails: 'fetchCourseDetails',
+ updateTranscriptLanguage: 'updateTranscriptLanguage',
+ getTranscriptFile: 'getTranscriptFile',
+ checkTranscriptsForImport: 'checkTranscriptsForImport',
+ importTranscript: 'importTranscript',
+ uploadAsset: 'uploadAsset',
+ fetchAdvancedSettings: 'fetchAdvancedSettings',
+ fetchVideoFeatures: 'fetchVideoFeatures',
+});
diff --git a/src/editors/data/constants/tinyMCE.js b/src/editors/data/constants/tinyMCE.js
new file mode 100644
index 0000000000..a6e40203a5
--- /dev/null
+++ b/src/editors/data/constants/tinyMCE.js
@@ -0,0 +1,76 @@
+import { StrictDict } from '../../utils';
+
+const listKeyStore = (list) => StrictDict(
+ list.reduce((obj, val) => ({ ...obj, [val]: val }), {}),
+);
+
+export const commands = StrictDict({
+ insertContent: 'mceInsertContent',
+});
+
+export const buttons = StrictDict({
+ addImageButton: 'addimagebutton',
+ blockQuote: 'blockquote',
+ codeBlock: 'codeBlock',
+ align: StrictDict({
+ center: 'aligncenter',
+ justify: 'alignjustify',
+ left: 'alignleft',
+ right: 'alignright',
+ }),
+ foreColor: 'forecolor',
+ backColor: 'backcolor',
+ bold: 'bold',
+ bullist: 'bullist',
+ charmap: 'charmap',
+ code: 'code-modified', // use a custom button name, consistently, for our text-only button
+ codesample: 'codesample',
+ customLabelButton: 'customLabelButton',
+ editImageSettings: 'editimagesettings',
+ emoticons: 'emoticons',
+ flip: StrictDict({
+ vert: 'flipv',
+ horiz: 'fliph',
+ }),
+ formatSelect: 'formatSelect',
+ hr: 'hr',
+ imageUploadButton: 'imageuploadbutton',
+ indent: 'indent',
+ italic: 'italic',
+ link: 'link',
+ unlink: 'unlink',
+ numlist: 'numlist',
+ outdent: 'outdent',
+ redo: 'redo',
+ removeFormat: 'removeformat',
+ rotate: StrictDict({
+ left: 'rotateleft',
+ right: 'rotateright',
+ }),
+ quickLink: 'quicklink',
+ table: 'table',
+ undo: 'undo',
+ underline: 'underline',
+ a11ycheck: 'a11ycheck',
+ embediframe: 'embediframe',
+});
+
+export const plugins = listKeyStore([
+ 'link',
+ 'lists',
+ 'codesample',
+ 'emoticons',
+ 'table',
+ 'hr',
+ 'charmap',
+ 'code',
+ 'autoresize',
+ 'image',
+ 'imagetools',
+ 'quickbars',
+ 'a11ychecker',
+ 'powerpaste',
+ 'embediframe',
+]);
+
+export const textToSpeechIcon = ' ';
diff --git a/src/editors/data/constants/tinyMCEStyles.js b/src/editors/data/constants/tinyMCEStyles.js
new file mode 100644
index 0000000000..52cb0208e1
--- /dev/null
+++ b/src/editors/data/constants/tinyMCEStyles.js
@@ -0,0 +1,235 @@
+const getStyles = () => (
+ `@import url("https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,600;1,700&display=swap");
+
+ .mce-content-body *[contentEditable=false] {
+ cursor: default;
+ }
+ .mce-content-body *[contentEditable=true] {
+ cursor: text;
+ }
+ .mce-content-body div.mce-resizehandle {
+ background-color: #4099ff;
+ border-color: #4099ff;
+ border-style: solid;
+ border-width: 1px;
+ box-sizing: border-box;
+ height: 10px;
+ position: absolute;
+ width: 10px;
+ z-index: 1298;
+ }
+ .mce-content-body div.mce-resizehandle:hover {
+ background-color: #4099ff;
+ }
+ .mce-content-body div.mce-resizehandle:nth-of-type(1) {
+ cursor: nwse-resize;
+ }
+ .mce-content-body div.mce-resizehandle:nth-of-type(2) {
+ cursor: nesw-resize;
+ }
+ .mce-content-body div.mce-resizehandle:nth-of-type(3) {
+ cursor: nwse-resize;
+ }
+ .mce-content-body div.mce-resizehandle:nth-of-type(4) {
+ cursor: nesw-resize;
+ }
+ .mce-content-body .mce-resize-backdrop {
+ z-index: 10000;
+ }
+ .mce-content-body .mce-clonedresizable {
+ cursor: default;
+ opacity: 0.5;
+ outline: 1px dashed black;
+ position: absolute;
+ z-index: 10001;
+ }
+ .mce-content-body .mce-clonedresizable.mce-resizetable-columns th,
+ .mce-content-body .mce-clonedresizable.mce-resizetable-columns td {
+ border: 0;
+ }
+ .mce-content-body .mce-resize-helper {
+ background: #555;
+ background: rgba(0, 0, 0, 0.75);
+ border: 1px;
+ border-radius: 3px;
+ color: white;
+ display: none;
+ font-family: sans-serif;
+ font-size: 12px;
+ line-height: 14px;
+ margin: 5px 10px;
+ padding: 5px;
+ position: absolute;
+ white-space: nowrap;
+ z-index: 10002;
+ }
+ .mce-content-body img[data-mce-selected],
+ .mce-content-body video[data-mce-selected],
+ .mce-content-body audio[data-mce-selected],
+ .mce-content-body object[data-mce-selected],
+ .mce-content-body embed[data-mce-selected],
+ .mce-content-body table[data-mce-selected] {
+ outline: 3px solid #b4d7ff;
+ }
+ .mce-content-body *[contentEditable=false] *[contentEditable=true]:focus {
+ outline: 3px solid #b4d7ff;
+ }
+ .mce-content-body *[contentEditable=false] *[contentEditable=true]:hover {
+ outline: 3px solid #b4d7ff;
+ }
+ .mce-content-body *[contentEditable=false][data-mce-selected] {
+ cursor: not-allowed;
+ outline: 3px solid #b4d7ff;
+ }
+ .mce-content-body.mce-content-readonly *[contentEditable=true]:focus,
+ .mce-content-body.mce-content-readonly *[contentEditable=true]:hover {
+ outline: none;
+ }
+ .mce-content-body *[data-mce-selected="inline-boundary"] {
+ background-color: #b4d7ff;
+ }
+ .mce-content-body .mce-edit-focus {
+ outline: 3px solid #b4d7ff;
+ }
+ .mce-content-body img::-moz-selection {
+ background: none;
+ }
+ .mce-content-body img::selection {
+ background: none;
+ }
+ .mce-content-body {
+ padding: 0;
+ background-color: #fff;
+ font-family: 'Open Sans', Verdana, Arial, Helvetica, sans-serif;
+ font-size: 16px;
+ line-height: 1.6;
+ color: #3c3c3c;
+ scrollbar-3dlight-color: #F0F0EE;
+ scrollbar-arrow-color: #676662;
+ scrollbar-base-color: #F0F0EE;
+ scrollbar-darkshadow-color: #DDDDDD;
+ scrollbar-face-color: #E0E0DD;
+ scrollbar-highlight-color: #F0F0EE;
+ scrollbar-shadow-color: #F0F0EE;
+ scrollbar-track-color: #F5F5F5;
+ }
+ .mce-content-body h1,
+ .mce-content-body .hd-1 {
+ color: #3c3c3c;
+ font-weight: normal;
+ font-size: 2em;
+ line-height: 1.4em;
+ margin: 0 0 1.41575em 0;
+ text-transform: initial;
+ }
+ .mce-content-body h2,
+ .mce-content-body .hd-2 {
+ letter-spacing: 1px;
+ margin-bottom: 15px;
+ color: #646464;
+ font-weight: 300;
+ font-size: 1.2em;
+ line-height: 1.2em;
+ text-transform: initial;
+ }
+ .mce-content-body h3,
+ .mce-content-body .hd-3 {
+ margin: 0 0 10px 0;
+ font-size: 1.1125em;
+ font-weight: 400;
+ text-transform: initial;
+ }
+ .mce-content-body .hd-3,
+ .mce-content-body h4,
+ .mce-content-body .hd-4,
+ .mce-content-body h5,
+ .mce-content-body .hd-5,
+ .mce-content-body h6,
+ .mce-content-body .hd-6 {
+ margin: 0 0 10px 0;
+ font-weight: 600;
+ }
+ .mce-content-body h4,
+ .mce-content-body .hd-4 {
+ font-size: 1em;
+ }
+ .mce-content-body h5,
+ .mce-content-body .hd-5 {
+ font-size: 0.83em;
+ }
+ .mce-content-body h6,
+ .mce-content-body .hd-6 {
+ font-size: 0.75em;
+ }
+ .mce-content-body p {
+ margin-bottom: 1.416em;
+ margin-top: 0;
+ font-size: 1em;
+ line-height: 1.6em !important;
+ color: #3c3c3c;
+ }
+ .mce-content-body em, .mce-content-body i {
+ font-style: italic;
+ }
+ .mce-content-body strong, .mce-content-body b {
+ font-weight: bold;
+ }
+ .mce-content-body p + p, .mce-content-body ul + p, .mce-content-body ol + p {
+ margin-top: 20px;
+ }
+ .mce-content-body ol, .mce-content-body ul {
+ margin: 1em 0;
+ padding: 0 0 0 1em;
+ color: #3c3c3c;
+ }
+ .mce-content-body ol li, .mce-content-body ul li {
+ margin-bottom: 0.708em;
+ }
+ .mce-content-body ol {
+ list-style: decimal outside none;
+ margin: 0;
+ }
+ .mce-content-body ul {
+ list-style: disc outside none;
+ margin: 0;
+ }
+ .mce-content-body a, .mce-content-body a:link, .mce-content-body a:visited, .mce-content-body a:hover, .mce-content-body a:active {
+ color: #0075b4;
+ text-decoration: none;
+ }
+ .mce-content-body img {
+ max-width: 100%;
+ }
+ .mce-content-body pre {
+ margin: 1em 0;
+ color: #3c3c3c;
+ font-family: monospace, serif;
+ font-size: 1em;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ }
+ .mce-content-body code {
+ font-family: monospace, serif;
+ background: none;
+ color: #3c3c3c;
+ padding: 0;
+ }
+ .mce-content-body[data-mce-placeholder] {
+ position: relative;
+ }
+ .mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before {
+ color: rgba(34, 47, 62, 0.7);
+ content: attr(data-mce-placeholder);
+ position: absolute;
+ }
+ .mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before {
+ margin: 0;
+ }
+ .mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before {
+ margin: 0;
+ }`
+);
+
+export { getStyles };
+
+export default getStyles({});
diff --git a/src/editors/data/constants/video.js b/src/editors/data/constants/video.js
new file mode 100644
index 0000000000..198afc7076
--- /dev/null
+++ b/src/editors/data/constants/video.js
@@ -0,0 +1,217 @@
+import { StrictDict } from '../../utils';
+
+export const videoTranscriptLanguages = StrictDict({
+ placeholder: '',
+ aa: 'Afar',
+ ab: 'Abkhazian',
+ af: 'Afrikaans',
+ ak: 'Akan',
+ sq: 'Albanian',
+ am: 'Amharic',
+ ar: 'Arabic',
+ an: 'Aragonese',
+ hy: 'Armenian',
+ as: 'Assamese',
+ av: 'Avaric',
+ ae: 'Avestan',
+ ay: 'Aymara',
+ az: 'Azerbaijani',
+ ba: 'Bashkir',
+ bm: 'Bambara',
+ eu: 'Basque',
+ be: 'Belarusian',
+ bn: 'Bengali',
+ bh: 'Bihari languages',
+ bi: 'Bislama',
+ bs: 'Bosnian',
+ br: 'Breton',
+ bg: 'Bulgarian',
+ my: 'Burmese',
+ ca: 'Catalan',
+ ch: 'Chamorro',
+ ce: 'Chechen',
+ zh: 'Chinese',
+ zh_HANS: 'Simplified Chinese',
+ zh_HANT: 'Traditional Chinese',
+ cu: 'Church Slavic',
+ cv: 'Chuvash',
+ kw: 'Cornish',
+ co: 'Corsican',
+ cr: 'Cree',
+ cs: 'Czech',
+ da: 'Danish',
+ dv: 'Divehi',
+ nl: 'Dutch',
+ dz: 'Dzongkha',
+ en: 'English',
+ eo: 'Esperanto',
+ et: 'Estonian',
+ ee: 'Ewe',
+ fo: 'Faroese',
+ fj: 'Fijian',
+ fi: 'Finnish',
+ fr: 'French',
+ fy: 'Western Frisian',
+ ff: 'Fulah',
+ ka: 'Georgian',
+ de: 'German',
+ gd: 'Gaelic',
+ ga: 'Irish',
+ gl: 'Galician',
+ gv: 'Manx',
+ el: 'Greek',
+ gn: 'Guarani',
+ gu: 'Gujarati',
+ ht: 'Haitian',
+ ha: 'Hausa',
+ he: 'Hebrew',
+ hz: 'Herero',
+ hi: 'Hindi',
+ ho: 'Hiri Motu',
+ hr: 'Croatian',
+ hu: 'Hungarian',
+ ig: 'Igbo',
+ is: 'Icelandic',
+ io: 'Ido',
+ ii: 'Sichuan Yi',
+ iu: 'Inuktitut',
+ ie: 'Interlingue',
+ ia: 'Interlingua',
+ id: 'Indonesian',
+ ik: 'Inupiaq',
+ it: 'Italian',
+ jv: 'Javanese',
+ ja: 'Japanese',
+ kl: 'Kalaallisut',
+ kn: 'Kannada',
+ ks: 'Kashmiri',
+ kr: 'Kanuri',
+ kk: 'Kazakh',
+ km: 'Central Khmer',
+ ki: 'Kikuyu',
+ rw: 'Kinyarwanda',
+ ky: 'Kirghiz',
+ kv: 'Komi',
+ kg: 'Kongo',
+ ko: 'Korean',
+ kj: 'Kuanyama',
+ ku: 'Kurdish',
+ lo: 'Lao',
+ la: 'Latin',
+ lv: 'Latvian',
+ li: 'Limburgan',
+ ln: 'Lingala',
+ lt: 'Lithuanian',
+ lb: 'Luxembourgish',
+ lu: 'Luba-Katanga',
+ lg: 'Ganda',
+ mk: 'Macedonian',
+ mh: 'Marshallese',
+ ml: 'Malayalam',
+ mi: 'Maori',
+ mr: 'Marathi',
+ ms: 'Malay',
+ mg: 'Malagasy',
+ mt: 'Maltese',
+ mn: 'Mongolian',
+ na: 'Nauru',
+ nv: 'Navajo',
+ nr: 'Ndebele: South',
+ nd: 'Ndebele: North',
+ ng: 'Ndonga',
+ ne: 'Nepali',
+ nn: 'Norwegian Nynorsk',
+ nb: 'Bokmål: Norwegian',
+ no: 'Norwegian',
+ ny: 'Chichewa',
+ oc: 'Occitan',
+ oj: 'Ojibwa',
+ or: 'Oriya',
+ om: 'Oromo',
+ os: 'Ossetian',
+ pa: 'Panjabi',
+ fa: 'Persian',
+ pi: 'Pali',
+ pl: 'Polish',
+ pt: 'Portuguese',
+ ps: 'Pushto',
+ qu: 'Quechua',
+ rm: 'Romansh',
+ ro: 'Romanian',
+ rn: 'Rundi',
+ ru: 'Russian',
+ sg: 'Sango',
+ sa: 'Sanskrit',
+ si: 'Sinhala',
+ sk: 'Slovak',
+ sl: 'Slovenian',
+ se: 'Northern Sami',
+ sm: 'Samoan',
+ sn: 'Shona',
+ sd: 'Sindhi',
+ so: 'Somali',
+ st: 'Sotho: Southern',
+ es: 'Spanish',
+ sc: 'Sardinian',
+ sr: 'Serbian',
+ ss: 'Swati',
+ su: 'Sundanese',
+ sw: 'Swahili',
+ sv: 'Swedish',
+ ty: 'Tahitian',
+ ta: 'Tamil',
+ tt: 'Tatar',
+ te: 'Telugu',
+ tg: 'Tajik',
+ tl: 'Tagalog',
+ th: 'Thai',
+ bo: 'Tibetan',
+ ti: 'Tigrinya',
+ to: 'Tonga (Tonga Islands)',
+ tn: 'Tswana',
+ ts: 'Tsonga',
+ tk: 'Turkmen',
+ tr: 'Turkish',
+ tw: 'Twi',
+ ug: 'Uighur',
+ uk: 'Ukrainian',
+ ur: 'Urdu',
+ uz: 'Uzbek',
+ ve: 'Venda',
+ vi: 'Vietnamese',
+ vo: 'Volapük',
+ cy: 'Welsh',
+ wa: 'Walloon',
+ wo: 'Wolof',
+ xh: 'Xhosa',
+ yi: 'Yiddish',
+ yo: 'Yoruba',
+ za: 'Zhuang',
+ zu: 'Zulu',
+});
+
+export const in8lTranscriptLanguages = (intl) => {
+ const messageLookup = {};
+ // for tests and non-internationlized setups, return en
+ if (!intl?.formatMessage) {
+ return videoTranscriptLanguages;
+ }
+ Object.keys(videoTranscriptLanguages).forEach((code) => {
+ messageLookup[code] = intl.formatMessage({
+ id: `authoring.videoeditor.transcripts.language.${code}`,
+ defaultMessage: videoTranscriptLanguages[code],
+ description: `Name of Language called in English ${videoTranscriptLanguages[code]}`,
+ });
+ });
+ return messageLookup;
+};
+
+export const timeKeys = StrictDict({
+ startTime: 'startTime',
+ stopTime: 'stopTime',
+});
+
+export default {
+ timeKeys,
+ videoTranscriptLanguages,
+};
diff --git a/src/editors/data/images/dropdown.png b/src/editors/data/images/dropdown.png
new file mode 100644
index 0000000000..9fcf029495
Binary files /dev/null and b/src/editors/data/images/dropdown.png differ
diff --git a/src/editors/data/images/multiSelect.png b/src/editors/data/images/multiSelect.png
new file mode 100644
index 0000000000..065716214a
Binary files /dev/null and b/src/editors/data/images/multiSelect.png differ
diff --git a/src/editors/data/images/numericalInput.png b/src/editors/data/images/numericalInput.png
new file mode 100644
index 0000000000..535631d3b6
Binary files /dev/null and b/src/editors/data/images/numericalInput.png differ
diff --git a/src/editors/data/images/singleSelect.png b/src/editors/data/images/singleSelect.png
new file mode 100644
index 0000000000..33fe718310
Binary files /dev/null and b/src/editors/data/images/singleSelect.png differ
diff --git a/src/editors/data/images/textInput.png b/src/editors/data/images/textInput.png
new file mode 100644
index 0000000000..39c3d92252
Binary files /dev/null and b/src/editors/data/images/textInput.png differ
diff --git a/src/editors/data/images/videoThumbnail.svg b/src/editors/data/images/videoThumbnail.svg
new file mode 100644
index 0000000000..82790d0061
--- /dev/null
+++ b/src/editors/data/images/videoThumbnail.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/editors/data/redux/app/index.js b/src/editors/data/redux/app/index.js
new file mode 100644
index 0000000000..8abd5f91d9
--- /dev/null
+++ b/src/editors/data/redux/app/index.js
@@ -0,0 +1,2 @@
+export { actions, reducer } from './reducer';
+export { default as selectors } from './selectors';
diff --git a/src/editors/data/redux/app/reducer.js b/src/editors/data/redux/app/reducer.js
new file mode 100644
index 0000000000..1951983f4a
--- /dev/null
+++ b/src/editors/data/redux/app/reducer.js
@@ -0,0 +1,72 @@
+import { createSlice } from '@reduxjs/toolkit';
+
+import { StrictDict } from '../../../utils';
+
+const initialState = {
+ blockValue: null,
+ unitUrl: null,
+ blockContent: null,
+ studioView: null,
+ saveResponse: null,
+ blockId: null,
+ blockTitle: null,
+ blockType: null,
+ learningContextId: null,
+ editorInitialized: false,
+ studioEndpointUrl: null,
+ lmsEndpointUrl: null,
+ images: {},
+ imageCount: 0,
+ videos: {},
+ courseDetails: {},
+ showRawEditor: false,
+};
+
+// eslint-disable-next-line no-unused-vars
+const app = createSlice({
+ name: 'app',
+ initialState,
+ reducers: {
+ initialize: (state, { payload }) => ({
+ ...state,
+ studioEndpointUrl: payload.studioEndpointUrl,
+ lmsEndpointUrl: payload.lmsEndpointUrl,
+ blockId: payload.blockId,
+ learningContextId: payload.learningContextId,
+ blockType: payload.blockType,
+ blockValue: null,
+ }),
+ setUnitUrl: (state, { payload }) => ({ ...state, unitUrl: payload }),
+ setBlockValue: (state, { payload }) => ({
+ ...state,
+ blockValue: payload,
+ blockTitle: payload.data.display_name,
+ }),
+ setStudioView: (state, { payload }) => ({ ...state, studioView: payload }),
+ setBlockContent: (state, { payload }) => ({ ...state, blockContent: payload }),
+ setBlockTitle: (state, { payload }) => ({ ...state, blockTitle: payload }),
+ setSaveResponse: (state, { payload }) => ({ ...state, saveResponse: payload }),
+ initializeEditor: (state) => ({ ...state, editorInitialized: true }),
+ setImages: (state, { payload }) => ({
+ ...state,
+ images: { ...state.images, ...payload.images },
+ imageCount: payload.imageCount,
+ }),
+ setVideos: (state, { payload }) => ({ ...state, videos: payload }),
+ setCourseDetails: (state, { payload }) => ({ ...state, courseDetails: payload }),
+ setShowRawEditor: (state, { payload }) => ({
+ ...state,
+ showRawEditor: payload.data?.metadata?.editor === 'raw',
+ }),
+ },
+});
+
+const actions = StrictDict(app.actions);
+
+const { reducer } = app;
+
+export {
+ actions,
+ initialState,
+ reducer,
+};
diff --git a/src/editors/data/redux/app/reducer.test.js b/src/editors/data/redux/app/reducer.test.js
new file mode 100644
index 0000000000..c125185d88
--- /dev/null
+++ b/src/editors/data/redux/app/reducer.test.js
@@ -0,0 +1,91 @@
+import { initialState, actions, reducer } from './reducer';
+
+const testingState = {
+ ...initialState,
+ arbitraryField: 'arbitrary',
+};
+
+describe('app reducer', () => {
+ it('has initial state', () => {
+ expect(reducer(undefined, {})).toEqual(initialState);
+ });
+
+ const testValue = 'roll for initiative';
+
+ describe('handling actions', () => {
+ describe('initialize', () => {
+ it('loads initial input fields into the store', () => {
+ const data = {
+ studioEndpointUrl: 'testURL',
+ lmsEndpointUrl: 'sOmEOtherTestuRl',
+ blockId: 'anID',
+ learningContextId: 'OTHERid',
+ blockType: 'someTYPE',
+ };
+ expect(reducer(
+ testingState,
+ actions.initialize({ ...data, other: 'field' }),
+ )).toEqual({
+ ...testingState,
+ ...data,
+ });
+ });
+ });
+ const setterTest = (action, target) => {
+ describe(action, () => {
+ it(`load ${target} from payload`, () => {
+ expect(reducer(testingState, actions[action](testValue))).toEqual({
+ ...testingState,
+ [target]: testValue,
+ });
+ });
+ });
+ };
+ [
+ ['setUnitUrl', 'unitUrl'],
+ ['setStudioView', 'studioView'],
+ ['setBlockContent', 'blockContent'],
+ ['setBlockTitle', 'blockTitle'],
+ ['setSaveResponse', 'saveResponse'],
+ ['setVideos', 'videos'],
+ ['setCourseDetails', 'courseDetails'],
+ ].map(args => setterTest(...args));
+ describe('setShowRawEditor', () => {
+ it('sets showRawEditor', () => {
+ const blockValue = { data: { metadata: { editor: 'raw' } } };
+ expect(reducer(testingState, actions.setShowRawEditor(blockValue))).toEqual({
+ ...testingState,
+ showRawEditor: true,
+ });
+ });
+ });
+ describe('setBlockValue', () => {
+ it('sets blockValue, as well as setting the blockTitle from data.display_name', () => {
+ const blockValue = { data: { display_name: 'my test name' }, other: 'data' };
+ expect(reducer(testingState, actions.setBlockValue(blockValue))).toEqual({
+ ...testingState,
+ blockValue,
+ blockTitle: blockValue.data.display_name,
+ });
+ });
+ });
+ describe('setImages', () => {
+ it('sets images, as well as setting imageCount', () => {
+ const imageData = { images: { id1: { id: 'id1' } }, imageCount: 1 };
+ expect(reducer(testingState, actions.setImages(imageData))).toEqual({
+ ...testingState,
+ images: imageData.images,
+ imageCount: imageData.imageCount,
+ });
+ });
+ });
+ describe('initializeEditor', () => {
+ it('sets editorInitialized to true', () => {
+ expect(reducer(testingState, actions.initializeEditor())).toEqual({
+ ...testingState,
+ editorInitialized: true,
+ });
+ });
+ });
+ });
+});
diff --git a/src/editors/data/redux/app/selectors.js b/src/editors/data/redux/app/selectors.js
new file mode 100644
index 0000000000..9976eee19f
--- /dev/null
+++ b/src/editors/data/redux/app/selectors.js
@@ -0,0 +1,103 @@
+import { createSelector } from 'reselect';
+import { blockTypes } from '../../constants/app';
+import * as urls from '../../services/cms/urls';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './selectors';
+
+export const appSelector = (state) => state.app;
+
+const mkSimpleSelector = (cb) => createSelector([module.appSelector], cb);
+
+// top-level app data selectors
+export const simpleSelectors = {
+ blockContent: mkSimpleSelector(app => app.blockContent),
+ blockId: mkSimpleSelector(app => app.blockId),
+ blockType: mkSimpleSelector(app => app.blockType),
+ blockValue: mkSimpleSelector(app => app.blockValue),
+ studioView: mkSimpleSelector(app => app.studioView),
+ learningContextId: mkSimpleSelector(app => app.learningContextId),
+ editorInitialized: mkSimpleSelector(app => app.editorInitialized),
+ saveResponse: mkSimpleSelector(app => app.saveResponse),
+ lmsEndpointUrl: mkSimpleSelector(app => app.lmsEndpointUrl),
+ studioEndpointUrl: mkSimpleSelector(app => app.studioEndpointUrl),
+ unitUrl: mkSimpleSelector(app => app.unitUrl),
+ blockTitle: mkSimpleSelector(app => app.blockTitle),
+ images: mkSimpleSelector(app => app.images),
+ videos: mkSimpleSelector(app => app.videos),
+ showRawEditor: mkSimpleSelector(app => app.showRawEditor),
+};
+
+export const returnUrl = createSelector(
+ [module.simpleSelectors.unitUrl, module.simpleSelectors.studioEndpointUrl, module.simpleSelectors.learningContextId,
+ module.simpleSelectors.blockId],
+ (unitUrl, studioEndpointUrl, learningContextId, blockId) => (
+ urls.returnUrl({
+ studioEndpointUrl, unitUrl, learningContextId, blockId,
+ })
+ ),
+);
+
+export const isInitialized = createSelector(
+ [
+ module.simpleSelectors.unitUrl,
+ module.simpleSelectors.blockValue,
+ ],
+ (unitUrl, blockValue) => !!(unitUrl && blockValue),
+);
+
+export const displayTitle = createSelector(
+ [
+ module.simpleSelectors.blockType,
+ module.simpleSelectors.blockTitle,
+ ],
+ (blockType, blockTitle) => {
+ if (blockType === null) {
+ return null;
+ }
+ if (blockTitle !== null) {
+ return blockTitle;
+ }
+ return (blockType === blockTypes.html)
+ ? 'Text'
+ : blockType[0].toUpperCase() + blockType.substring(1);
+ },
+);
+
+export const analytics = createSelector(
+ [
+ module.simpleSelectors.blockId,
+ module.simpleSelectors.blockType,
+ module.simpleSelectors.learningContextId,
+ ],
+ (blockId, blockType, learningContextId) => (
+ { blockId, blockType, learningContextId }
+ ),
+);
+
+export const isLibrary = createSelector(
+ [
+ module.simpleSelectors.learningContextId,
+ module.simpleSelectors.blockId,
+ ],
+ (learningContextId, blockId) => {
+ if (learningContextId && learningContextId.startsWith('library-v1')) {
+ return true;
+ }
+ if (blockId && blockId.startsWith('lb:')) {
+ return true;
+ }
+ return false;
+ },
+);
+
+export default {
+ ...simpleSelectors,
+ isInitialized,
+ returnUrl,
+ displayTitle,
+ analytics,
+ isLibrary,
+};
diff --git a/src/editors/data/redux/app/selectors.test.js b/src/editors/data/redux/app/selectors.test.js
new file mode 100644
index 0000000000..33a022e0b6
--- /dev/null
+++ b/src/editors/data/redux/app/selectors.test.js
@@ -0,0 +1,166 @@
+// import * in order to mock in-file references
+import { keyStore } from '../../../utils';
+import * as urls from '../../services/cms/urls';
+import * as selectors from './selectors';
+
+jest.mock('reselect', () => ({
+ createSelector: jest.fn((preSelectors, cb) => ({ preSelectors, cb })),
+}));
+jest.mock('../../services/cms/urls', () => ({
+ returnUrl: (args) => ({ returnUrl: args }),
+}));
+
+const testState = { some: 'arbitraryValue' };
+const testValue = 'my VALUE';
+
+describe('app selectors unit tests', () => {
+ const {
+ appSelector,
+ simpleSelectors,
+ } = selectors;
+ describe('appSelector', () => {
+ it('returns the app data', () => {
+ expect(appSelector({ ...testState, app: testValue })).toEqual(testValue);
+ });
+ });
+ describe('simpleSelectors', () => {
+ const testSimpleSelector = (key) => {
+ test(`${key} simpleSelector returns its value from the app store`, () => {
+ const { preSelectors, cb } = simpleSelectors[key];
+ expect(preSelectors).toEqual([appSelector]);
+ expect(cb({ ...testState, [key]: testValue })).toEqual(testValue);
+ });
+ };
+ const simpleKeys = keyStore(simpleSelectors);
+ describe('simple selectors link their values from app store', () => {
+ [
+ simpleKeys.blockContent,
+ simpleKeys.blockId,
+ simpleKeys.blockTitle,
+ simpleKeys.blockType,
+ simpleKeys.blockValue,
+ simpleKeys.learningContextId,
+ simpleKeys.editorInitialized,
+ simpleKeys.saveResponse,
+ simpleKeys.lmsEndpointUrl,
+ simpleKeys.studioEndpointUrl,
+ simpleKeys.unitUrl,
+ simpleKeys.blockTitle,
+ simpleKeys.studioView,
+ simpleKeys.images,
+ simpleKeys.videos,
+ simpleKeys.showRawEditor,
+ ].map(testSimpleSelector);
+ });
+ });
+ describe('returnUrl', () => {
+ it('is memoized based on unitUrl and studioEndpointUrl', () => {
+ expect(selectors.returnUrl.preSelectors).toEqual([
+ simpleSelectors.unitUrl,
+ simpleSelectors.studioEndpointUrl,
+ simpleSelectors.learningContextId,
+ simpleSelectors.blockId,
+ ]);
+ });
+ it('returns urls.returnUrl with the returnUrl', () => {
+ const { cb } = selectors.returnUrl;
+ const studioEndpointUrl = 'baseURL';
+ const unitUrl = 'some unit url';
+ const learningContextId = 'some learning context';
+ const blockId = 'block-v1 some v1 block id';
+ expect(
+ cb(unitUrl, studioEndpointUrl, learningContextId, blockId),
+ ).toEqual(
+ urls.returnUrl({
+ unitUrl, studioEndpointUrl, learningContextId, blockId,
+ }),
+ );
+ });
+ });
+ describe('isInitialized selector', () => {
+ it('is memoized based on unitUrl, editorInitialized, and blockValue', () => {
+ expect(selectors.isInitialized.preSelectors).toEqual([
+ simpleSelectors.unitUrl,
+ simpleSelectors.blockValue,
+ ]);
+ });
+ it('returns true iff unitUrl, blockValue, and editorInitialized are all truthy', () => {
+ const { cb } = selectors.isInitialized;
+ const truthy = {
+ url: { url: 'data' },
+ blockValue: { block: 'value' },
+ };
+
+ [
+ [[null, truthy.blockValue], false],
+ [[truthy.url, null], false],
+ [[truthy.url, truthy.blockValue], true],
+ ].map(([args, expected]) => expect(cb(...args)).toEqual(expected));
+ });
+ });
+ describe('displayTitle', () => {
+ const title = 'tItLe';
+ it('is memoized based on blockType and blockTitle', () => {
+ expect(selectors.displayTitle.preSelectors).toEqual([
+ simpleSelectors.blockType,
+ simpleSelectors.blockTitle,
+ ]);
+ });
+ it('returns null if blockType is null', () => {
+ expect(selectors.displayTitle.cb(null, title)).toEqual(null);
+ });
+ it('returns blockTitle if blockTitle is not null', () => {
+ expect(selectors.displayTitle.cb('html', title)).toEqual(title);
+ });
+ it('returns Text if the blockType is html', () => {
+ expect(selectors.displayTitle.cb('html', null)).toEqual('Text');
+ });
+ it('returns the blockType capitalized if not html', () => {
+ expect(selectors.displayTitle.cb('video', null)).toEqual('Video');
+ expect(selectors.displayTitle.cb('random', null)).toEqual('Random');
+ });
+ });
+
+ describe('isLibrary', () => {
+ const learningContextIdLibrary = 'library-v1:name';
+ const learningContextIdCourse = 'course-v1:name';
+ it('is memoized based on isLibrary', () => {
+ expect(selectors.isLibrary.preSelectors).toEqual([
+ simpleSelectors.learningContextId,
+ simpleSelectors.blockId,
+ ]);
+ });
+ describe('blockId is null', () => {
+ it('should return false when learningContextId null', () => {
+ expect(selectors.isLibrary.cb(null, null)).toEqual(false);
+ });
+ it('should return false when learningContextId defined', () => {
+ expect(selectors.isLibrary.cb(learningContextIdCourse, null)).toEqual(false);
+ });
+ });
+ describe('blockId is a course block', () => {
+ it('should return false when learningContextId null', () => {
+ expect(selectors.isLibrary.cb(null, 'block-v1:')).toEqual(false);
+ });
+ it('should return false when learningContextId defined', () => {
+ expect(selectors.isLibrary.cb(learningContextIdCourse, 'block-v1:')).toEqual(false);
+ });
+ });
+ describe('blockId is a v2 library block', () => {
+ it('should return true when learningContextId null', () => {
+ expect(selectors.isLibrary.cb(null, 'lb:')).toEqual(true);
+ });
+ it('should return false when learningContextId is a v1 library', () => {
+ expect(selectors.isLibrary.cb(learningContextIdLibrary, 'lb:')).toEqual(true);
+ });
+ });
+ describe('blockId is a v1 library block', () => {
+ it('should return false when learningContextId null', () => {
+ expect(selectors.isLibrary.cb(null, 'library-v1')).toEqual(false);
+ });
+ it('should return true when learningContextId a v1 library', () => {
+ expect(selectors.isLibrary.cb(learningContextIdLibrary, 'library-v1')).toEqual(true);
+ });
+ });
+ });
+});
diff --git a/src/editors/data/redux/game/index.js b/src/editors/data/redux/game/index.js
new file mode 100644
index 0000000000..78455d1169
--- /dev/null
+++ b/src/editors/data/redux/game/index.js
@@ -0,0 +1,2 @@
+export { actions, reducer } from './reducers';
+export { default as selectors } from './selectors';
diff --git a/src/editors/data/redux/game/reducers.js b/src/editors/data/redux/game/reducers.js
new file mode 100644
index 0000000000..93d0f02ffe
--- /dev/null
+++ b/src/editors/data/redux/game/reducers.js
@@ -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,
+};
diff --git a/src/editors/data/redux/game/selectors.js b/src/editors/data/redux/game/selectors.js
new file mode 100644
index 0000000000..736d49f93b
--- /dev/null
+++ b/src/editors/data/redux/game/selectors.js
@@ -0,0 +1,19 @@
+import { createSelector } from 'reselect';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+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,
+};
diff --git a/src/editors/data/redux/index.js b/src/editors/data/redux/index.js
new file mode 100644
index 0000000000..84e5648bda
--- /dev/null
+++ b/src/editors/data/redux/index.js
@@ -0,0 +1,34 @@
+import { combineReducers } from 'redux';
+
+import { StrictDict } from '../../utils';
+
+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';
+
+export { default as thunkActions } from './thunkActions';
+
+const modules = {
+ app,
+ requests,
+ video,
+ problem,
+ game,
+};
+
+const moduleProps = (propName) => Object.keys(modules).reduce(
+ (obj, moduleKey) => ({ ...obj, [moduleKey]: modules[moduleKey][propName] }),
+ {},
+);
+
+const rootReducer = combineReducers(moduleProps('reducer'));
+
+const actions = StrictDict(moduleProps('actions'));
+
+const selectors = StrictDict(moduleProps('selectors'));
+
+export { actions, selectors };
+
+export default rootReducer;
diff --git a/src/editors/data/redux/problem/index.js b/src/editors/data/redux/problem/index.js
new file mode 100644
index 0000000000..78455d1169
--- /dev/null
+++ b/src/editors/data/redux/problem/index.js
@@ -0,0 +1,2 @@
+export { actions, reducer } from './reducers';
+export { default as selectors } from './selectors';
diff --git a/src/editors/data/redux/problem/reducers.js b/src/editors/data/redux/problem/reducers.js
new file mode 100644
index 0000000000..034f1bbb38
--- /dev/null
+++ b/src/editors/data/redux/problem/reducers.js
@@ -0,0 +1,233 @@
+import _ from 'lodash';
+import { createSlice } from '@reduxjs/toolkit';
+import { indexToLetterMap } from '../../../containers/ProblemEditor/data/OLXParser';
+import { StrictDict } from '../../../utils';
+import { ProblemTypeKeys, RichTextProblems } from '../../constants/problem';
+import { ToleranceTypes } from '../../../containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants';
+
+const nextAlphaId = (lastId) => String.fromCharCode(lastId.charCodeAt(0) + 1);
+const initialState = {
+ rawOLX: '',
+ problemType: null,
+ question: '',
+ answers: [],
+ correctAnswerCount: 0,
+ groupFeedbackList: [],
+ generalFeedback: '',
+ additionalAttributes: {},
+ defaultSettings: {},
+ settings: {
+ randomization: null,
+ scoring: {
+ weight: 1,
+ attempts: {
+ unlimited: true,
+ number: null,
+ },
+ },
+ hints: [],
+ timeBetween: 0,
+ showAnswer: {
+ on: '',
+ afterAttempts: 0,
+ },
+ showResetButton: null,
+ solutionExplanation: '',
+ tolerance: {
+ value: null,
+ type: ToleranceTypes.none.type,
+ },
+ },
+};
+
+// eslint-disable-next-line no-unused-vars
+const problem = createSlice({
+ name: 'problem',
+ initialState,
+ reducers: {
+ updateField: (state, { payload }) => ({
+ ...state,
+ ...payload,
+ }),
+ updateQuestion: (state, { payload }) => ({
+ ...state,
+ question: payload,
+ }),
+ updateAnswer: (state, { payload }) => {
+ const { id, hasSingleAnswer, ...answer } = payload;
+ let { correctAnswerCount } = state;
+ const answers = state.answers.map(obj => {
+ if (obj.id === id) {
+ if (_.has(answer, 'correct') && payload.correct) {
+ correctAnswerCount += 1;
+ }
+ if (_.has(answer, 'correct') && payload.correct === false && correctAnswerCount > 0) {
+ correctAnswerCount -= 1;
+ }
+ return { ...obj, ...answer };
+ }
+ // set other answers as incorrect if problem only has one answer correct
+ // and changes object include correct key change
+ if (hasSingleAnswer && _.has(answer, 'correct') && obj.correct) {
+ return { ...obj, correct: false };
+ }
+ return obj;
+ });
+ return {
+ ...state,
+ correctAnswerCount,
+ answers,
+ };
+ },
+ deleteAnswer: (state, { payload }) => {
+ const { id, correct, editorState } = payload;
+ const EditorsArray = window.tinymce.editors;
+ if (state.answers.length === 1) {
+ return {
+ ...state,
+ correctAnswerCount: state.problemType === ProblemTypeKeys.NUMERIC ? 1 : 0,
+ answers: [{
+ id: 'A',
+ title: '',
+ selectedFeedback: '',
+ unselectedFeedback: '',
+ correct: state.problemType === ProblemTypeKeys.NUMERIC,
+ isAnswerRange: false,
+ }],
+ };
+ }
+ const answers = state.answers.filter(obj => obj.id !== id).map((answer, index) => {
+ const newId = indexToLetterMap[index];
+ if (answer.id === newId) {
+ return answer;
+ }
+ let newAnswer = {
+ ...answer,
+ id: newId,
+ selectedFeedback: editorState.selectedFeedback ? editorState.selectedFeedback[answer.id] : '',
+ unselectedFeedback: editorState.unselectedFeedback ? editorState.unselectedFeedback[answer.id] : '',
+ };
+ if (RichTextProblems.includes(state.problemType)) {
+ newAnswer = {
+ ...newAnswer,
+ title: editorState.answers[answer.id],
+ };
+ if (EditorsArray[`answer-${newId}`]) {
+ EditorsArray[`answer-${newId}`].setContent(newAnswer.title ?? '');
+ }
+ }
+ // Note: The following assumes selectedFeedback and unselectedFeedback is using ExpandedTextArea
+ // Content only needs to be set here when the 'next' feedback fields are shown.
+ if (EditorsArray[`selectedFeedback-${newId}`]) {
+ EditorsArray[`selectedFeedback-${newId}`].setContent(newAnswer.selectedFeedback ?? '');
+ }
+ if (EditorsArray[`unselectedFeedback-${newId}`]) {
+ EditorsArray[`unselectedFeedback-${newId}`].setContent(newAnswer.unselectedFeedback ?? '');
+ }
+ return newAnswer;
+ });
+ const groupFeedbackList = state.groupFeedbackList.map(feedback => {
+ const newAnswers = feedback.answers.filter(obj => obj !== id).map(letter => {
+ if (letter.charCodeAt(0) > id.charCodeAt(0)) {
+ return String.fromCharCode(letter.charCodeAt(0) - 1);
+ }
+ return letter;
+ });
+ return { ...feedback, answers: newAnswers };
+ });
+ return {
+ ...state,
+ answers,
+ correctAnswerCount: correct ? state.correctAnswerCount - 1 : state.correctAnswerCount,
+ groupFeedbackList,
+ };
+ },
+ addAnswer: (state) => {
+ const currAnswers = state.answers;
+ if (currAnswers.length >= indexToLetterMap.length) {
+ return state;
+ }
+ const newOption = {
+ id: currAnswers.length ? nextAlphaId(currAnswers[currAnswers.length - 1].id) : 'A',
+ title: '',
+ selectedFeedback: '',
+ unselectedFeedback: '',
+ correct: state.problemType === ProblemTypeKeys.NUMERIC,
+ isAnswerRange: false,
+ };
+ let { correctAnswerCount } = state;
+ if (state.problemType === ProblemTypeKeys.NUMERIC) {
+ correctAnswerCount += 1;
+ }
+
+ const answers = [
+ ...currAnswers,
+ newOption,
+ ];
+ return {
+ ...state,
+ correctAnswerCount,
+ answers,
+ };
+ },
+ addAnswerRange: (state) => {
+ // As you may only have one answer range at a time, overwrite the answer object.
+ const newOption = {
+ id: 'A',
+ title: '',
+ selectedFeedback: '',
+ unselectedFeedback: '',
+ correct: state.problemType === ProblemTypeKeys.NUMERIC,
+ isAnswerRange: true,
+ };
+ const correctAnswerCount = 1;
+ return {
+ ...state,
+ correctAnswerCount,
+ answers: [newOption],
+ };
+ },
+
+ updateSettings: (state, { payload }) => ({
+ ...state,
+ settings: {
+ ...state.settings,
+ ...payload,
+ },
+ }),
+ load: (state, { payload: { settings: { scoring, showAnswer, ...settings }, ...payload } }) => ({
+ ...state,
+ settings: {
+ ...state.settings,
+ scoring: { ...state.settings.scoring, ...scoring },
+ showAnswer: { ...state.settings.showAnswer, ...showAnswer },
+ ...settings,
+ },
+ ...payload,
+ }),
+ setEnableTypeSelection: (state, { payload }) => {
+ const { maxAttempts, showanswer, showResetButton } = payload;
+ const attempts = { number: maxAttempts, unlimited: false };
+ return {
+ ...state,
+ settings: {
+ ...state.settings,
+ scoring: { ...state.settings.scoring, attempts },
+ showAnswer: { ...state.settings.showAnswer, on: showanswer },
+ ...showResetButton,
+ },
+ problemType: null,
+ };
+ },
+ },
+});
+
+const actions = StrictDict(problem.actions);
+
+const { reducer } = problem;
+
+export {
+ actions,
+ initialState,
+ reducer,
+};
diff --git a/src/editors/data/redux/problem/reducers.test.js b/src/editors/data/redux/problem/reducers.test.js
new file mode 100644
index 0000000000..b503206deb
--- /dev/null
+++ b/src/editors/data/redux/problem/reducers.test.js
@@ -0,0 +1,444 @@
+import { initialState, actions, reducer } from './reducers';
+import { ProblemTypeKeys } from '../../constants/problem';
+
+const testingState = {
+ ...initialState,
+ arbitraryField: 'arbitrary',
+};
+
+describe('problem reducer', () => {
+ it('has initial state', () => {
+ expect(reducer(undefined, {})).toEqual(initialState);
+ });
+
+ const testValue = 'roll for initiative';
+
+ describe('handling actions', () => {
+ const setterTest = (action, target) => {
+ describe(action, () => {
+ it(`load ${target} from payload`, () => {
+ expect(reducer(testingState, actions[action](testValue))).toEqual({
+ ...testingState,
+ [target]: testValue,
+ });
+ });
+ });
+ };
+ [
+ ['updateQuestion', 'question'],
+ ].map(args => setterTest(...args));
+ describe('setEnableTypeSelection', () => {
+ it('sets given problemType to null', () => {
+ const payload = {
+ maxAttempts: 1,
+ showanswer: 'finished',
+ showResetButton: false,
+ };
+ expect(reducer(testingState, actions.setEnableTypeSelection(payload))).toEqual({
+ ...testingState,
+ settings: {
+ ...testingState.settings,
+ scoring: {
+ ...testingState.settings.scoring,
+ attempts: { number: 1, unlimited: false },
+ },
+ showAnswer: { ...testingState.settings.showAnswer, on: payload.showanswer },
+ ...payload.showResetButton,
+ },
+ problemType: null,
+ });
+ });
+ });
+ describe('load', () => {
+ it('sets answers', () => {
+ const answer = {
+ id: 'A',
+ correct: false,
+ selectedFeedback: '',
+ title: '',
+ isAnswerRange: false,
+ unselectedFeedback: '',
+ };
+ expect(reducer(testingState, actions.addAnswer(answer))).toEqual({
+ ...testingState,
+ answers: [answer],
+ });
+ });
+ });
+ describe('updateField', () => {
+ it('sets given parameter', () => {
+ const payload = { problemType: 'soMePRoblEMtYPe' };
+ expect(reducer(testingState, actions.updateField(payload))).toEqual({
+ ...testingState,
+ ...payload,
+ });
+ });
+ });
+ describe('updateSettings', () => {
+ it('sets given settings parameter', () => {
+ const payload = { hints: ['soMehInt'] };
+ expect(reducer(testingState, actions.updateSettings(payload))).toEqual({
+ ...testingState,
+ settings: {
+ ...testingState.settings,
+ ...payload,
+ },
+ });
+ });
+ });
+ describe('addAnswer', () => {
+ const answer = {
+ id: 'A',
+ correct: false,
+ selectedFeedback: '',
+ title: '',
+ isAnswerRange: false,
+ unselectedFeedback: '',
+ };
+ it('sets answers', () => {
+ expect(reducer({ ...testingState, problemType: 'choiceresponse' }, actions.addAnswer())).toEqual({
+ ...testingState,
+ problemType: 'choiceresponse',
+ answers: [answer],
+ });
+ });
+ it('sets answers for numeric input', () => {
+ const numericTestState = {
+ ...testingState,
+ problemType: ProblemTypeKeys.NUMERIC,
+ correctAnswerCount: 0,
+ };
+ expect(reducer(numericTestState, actions.addAnswer())).toEqual({
+ ...numericTestState,
+ correctAnswerCount: 1,
+ answers: [{
+ ...answer,
+ correct: true,
+ }],
+ });
+ });
+ });
+ describe('addAnswerRange', () => {
+ const answerRange = {
+ id: 'A',
+ correct: true,
+ selectedFeedback: '',
+ title: '',
+ isAnswerRange: true,
+ unselectedFeedback: '',
+ };
+ it('sets answerRange', () => {
+ expect(reducer({ ...testingState, problemType: ProblemTypeKeys.NUMERIC }, actions.addAnswerRange())).toEqual({
+ ...testingState,
+ correctAnswerCount: 1,
+ problemType: ProblemTypeKeys.NUMERIC,
+ answers: [answerRange],
+ });
+ });
+ });
+ describe('updateAnswer', () => {
+ it('sets answers, as well as setting the correctAnswerCount ', () => {
+ const answer = { id: 'A', correct: true };
+ expect(reducer(
+ {
+ ...testingState,
+ answers: [{
+ id: 'A',
+ correct: false,
+ }],
+ },
+ actions.updateAnswer(answer),
+ )).toEqual({
+ ...testingState,
+ correctAnswerCount: 1,
+ answers: [{ id: 'A', correct: true }],
+ });
+ });
+ });
+ describe('deleteAnswer', () => {
+ let windowSpy;
+ beforeEach(() => {
+ windowSpy = jest.spyOn(window, 'window', 'get');
+ });
+ afterEach(() => {
+ windowSpy.mockRestore();
+ });
+ it('sets a default when deleting the last answer', () => {
+ windowSpy.mockImplementation(() => ({
+ tinymce: {
+ editors: 'mock-editors',
+ },
+ }));
+ const payload = {
+ id: 'A',
+ correct: false,
+ editorState: 'empty',
+ };
+ expect(reducer(
+ {
+ ...testingState,
+ correctAnswerCount: 0,
+ answers: [{ id: 'A', correct: false }],
+ },
+ actions.deleteAnswer(payload),
+ )).toEqual({
+ ...testingState,
+ correctAnswerCount: 0,
+ answers: [{
+ id: 'A',
+ title: '',
+ selectedFeedback: '',
+ unselectedFeedback: '',
+ correct: false,
+ isAnswerRange: false,
+ }],
+ });
+ });
+ it('sets answers and correctAnswerCount', () => {
+ windowSpy.mockImplementation(() => ({
+ tinymce: {
+ editors: 'mock-editors',
+ },
+ }));
+ const payload = {
+ id: 'A',
+ correct: false,
+ editorState: {
+ answers: { A: 'mockA' },
+ },
+ };
+ expect(reducer(
+ {
+ ...testingState,
+ correctAnswerCount: 1,
+ answers: [
+ { id: 'A', correct: false },
+ { id: 'B', correct: true },
+ ],
+ },
+ actions.deleteAnswer(payload),
+ )).toEqual({
+ ...testingState,
+ correctAnswerCount: 1,
+ answers: [{
+ id: 'A',
+ correct: true,
+ selectedFeedback: '',
+ unselectedFeedback: '',
+ }],
+ });
+ });
+ it('sets answers and correctAnswerCount with editorState for RichTextProblems', () => {
+ const setContent = jest.fn();
+ windowSpy.mockImplementation(() => ({
+ tinymce: {
+ editors: {
+ 'answer-A': { setContent },
+ 'answer-B': { setContent },
+ },
+ },
+ }));
+ const payload = {
+ id: 'A',
+ correct: false,
+ editorState: {
+ answers: { A: 'editorAnsA', B: 'editorAnsB' },
+ },
+ };
+ expect(reducer(
+ {
+ ...testingState,
+ problemType: ProblemTypeKeys.SINGLESELECT,
+ correctAnswerCount: 1,
+ answers: [
+ { id: 'A', correct: false },
+ { id: 'B', correct: true },
+ ],
+ },
+ actions.deleteAnswer(payload),
+ )).toEqual({
+ ...testingState,
+ problemType: ProblemTypeKeys.SINGLESELECT,
+ correctAnswerCount: 1,
+ answers: [{
+ id: 'A',
+ correct: true,
+ title: 'editorAnsB',
+ selectedFeedback: '',
+ unselectedFeedback: '',
+ }],
+ });
+ });
+ it('sets selectedFeedback and unselectedFeedback with editorState', () => {
+ windowSpy.mockImplementation(() => ({
+ tinymce: {
+ editors: {
+ 'answer-A': 'mockEditor',
+ 'answer-B': 'mockEditor',
+ },
+ },
+ }));
+ const payload = {
+ id: 'A',
+ correct: false,
+ editorState: {
+ answers: { A: 'editorAnsA', B: 'editorAnsB' },
+ selectedFeedback: { A: 'editSelFA', B: 'editSelFB' },
+ unselectedFeedback: { A: 'editUnselFA', B: 'editUnselFB' },
+ },
+ };
+ expect(reducer(
+ {
+ ...testingState,
+ correctAnswerCount: 1,
+ answers: [
+ { id: 'A', correct: false },
+ { id: 'B', correct: true },
+ ],
+ },
+ actions.deleteAnswer(payload),
+ )).toEqual({
+ ...testingState,
+ correctAnswerCount: 1,
+ answers: [{
+ id: 'A',
+ correct: true,
+ selectedFeedback: 'editSelFB',
+ unselectedFeedback: 'editUnselFB',
+ }],
+ });
+ });
+ it('calls editor setContent to set answer and feedback fields', () => {
+ const setContent = jest.fn();
+ windowSpy.mockImplementation(() => ({
+ tinymce: {
+ editors: {
+ 'answer-A': { setContent },
+ 'answer-B': { setContent },
+ 'selectedFeedback-A': { setContent },
+ 'selectedFeedback-B': { setContent },
+ 'unselectedFeedback-A': { setContent },
+ 'unselectedFeedback-B': { setContent },
+ },
+ },
+ }));
+ const payload = {
+ id: 'A',
+ correct: false,
+ editorState: {
+ answers: { A: 'editorAnsA', B: 'editorAnsB' },
+ selectedFeedback: { A: 'editSelFA', B: 'editSelFB' },
+ unselectedFeedback: { A: 'editUnselFA', B: 'editUnselFB' },
+ },
+ };
+ reducer(
+ {
+ ...testingState,
+ problemType: ProblemTypeKeys.SINGLESELECT,
+ correctAnswerCount: 1,
+ answers: [
+ { id: 'A', correct: false },
+ { id: 'B', correct: true },
+ ],
+ },
+ actions.deleteAnswer(payload),
+ );
+ expect(window.tinymce.editors['answer-A'].setContent).toHaveBeenCalled();
+ expect(window.tinymce.editors['answer-A'].setContent).toHaveBeenCalledWith('editorAnsB');
+ expect(window.tinymce.editors['selectedFeedback-A'].setContent).toHaveBeenCalledWith('editSelFB');
+ expect(window.tinymce.editors['unselectedFeedback-A'].setContent).toHaveBeenCalledWith('editUnselFB');
+ });
+ it('sets groupFeedbackList by removing the checked item in the groupFeedback', () => {
+ windowSpy.mockImplementation(() => ({
+ tinymce: {
+ editors: 'mock-editors',
+ },
+ }));
+ const payload = {
+ id: 'A',
+ correct: false,
+ editorState: {
+ answer: { A: 'aNSwERA', B: 'anSWeRB' },
+ },
+ };
+ expect(reducer(
+ {
+ ...testingState,
+ correctAnswerCount: 1,
+ answers: [
+ { id: 'A', correct: false },
+ { id: 'B', correct: true },
+ { id: 'C', correct: false },
+ ],
+ groupFeedbackList: [{
+ id: 0,
+ answers: ['A', 'C'],
+ feedback: 'fake feedback',
+ }],
+ },
+ actions.deleteAnswer(payload),
+ )).toEqual({
+ ...testingState,
+ correctAnswerCount: 1,
+ answers: [{
+ id: 'A',
+ correct: true,
+ selectedFeedback: '',
+ unselectedFeedback: '',
+ },
+ {
+ id: 'B',
+ correct: false,
+ selectedFeedback: '',
+ unselectedFeedback: '',
+ }],
+ groupFeedbackList: [{
+ id: 0,
+ answers: ['B'],
+ feedback: 'fake feedback',
+ }],
+ });
+ });
+ it('if you delete an answer range, it will be replaced with a blank answer', () => {
+ windowSpy.mockImplementation(() => ({
+ tinymce: {
+ editors: 'mock-editors',
+ },
+ }));
+ const payload = {
+ id: 'A',
+ correct: true,
+ editorState: 'mockEditoRStAte',
+ };
+ expect(reducer(
+ {
+ ...testingState,
+ problemType: ProblemTypeKeys.NUMERIC,
+ correctAnswerCount: 1,
+ answers: [{
+ id: 'A',
+ correct: false,
+ selectedFeedback: '',
+ title: '',
+ isAnswerRange: true,
+ unselectedFeedback: '',
+ }],
+ },
+ actions.deleteAnswer(payload),
+ )).toEqual({
+ ...testingState,
+ problemType: ProblemTypeKeys.NUMERIC,
+ correctAnswerCount: 1,
+ answers: [{
+ id: 'A',
+ title: '',
+ selectedFeedback: '',
+ unselectedFeedback: '',
+ correct: true,
+ isAnswerRange: false,
+ }],
+ });
+ });
+ });
+ });
+});
diff --git a/src/editors/data/redux/problem/selectors.js b/src/editors/data/redux/problem/selectors.js
new file mode 100644
index 0000000000..1ba3c3ea0b
--- /dev/null
+++ b/src/editors/data/redux/problem/selectors.js
@@ -0,0 +1,24 @@
+import { createSelector } from 'reselect';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './selectors';
+
+export const problemState = (state) => state.problem;
+const mkSimpleSelector = (cb) => createSelector([module.problemState], cb);
+export const simpleSelectors = {
+ problemType: mkSimpleSelector(problemData => problemData.problemType),
+ generalFeedback: mkSimpleSelector(problemData => problemData.generalFeedback),
+ groupFeedbackList: mkSimpleSelector(problemData => problemData.groupFeedbackList),
+ answers: mkSimpleSelector(problemData => problemData.answers),
+ correctAnswerCount: mkSimpleSelector(problemData => problemData.correctAnswerCount),
+ settings: mkSimpleSelector(problemData => problemData.settings),
+ question: mkSimpleSelector(problemData => problemData.question),
+ defaultSettings: mkSimpleSelector(problemData => problemData.defaultSettings),
+ completeState: mkSimpleSelector(problemData => problemData),
+};
+
+export default {
+ ...simpleSelectors,
+};
diff --git a/src/editors/data/redux/problem/selectors.test.js b/src/editors/data/redux/problem/selectors.test.js
new file mode 100644
index 0000000000..72214ac08a
--- /dev/null
+++ b/src/editors/data/redux/problem/selectors.test.js
@@ -0,0 +1,53 @@
+// import * in order to mock in-file references
+import { keyStore } from '../../../utils';
+import * as selectors from './selectors';
+
+jest.mock('reselect', () => ({
+ createSelector: jest.fn((preSelectors, cb) => ({ preSelectors, cb })),
+}));
+
+const testState = { some: 'arbitraryValue' };
+const testValue = 'my VALUE';
+
+describe('problem selectors unit tests', () => {
+ const {
+ problemState,
+ simpleSelectors,
+ } = selectors;
+ describe('problemState', () => {
+ it('returns the problem data', () => {
+ expect(problemState({ ...testState, problem: testValue })).toEqual(testValue);
+ });
+ });
+ describe('simpleSelectors', () => {
+ const testSimpleSelector = (key) => {
+ test(`${key} simpleSelector returns its value from the problem store`, () => {
+ const { preSelectors, cb } = simpleSelectors[key];
+ expect(preSelectors).toEqual([problemState]);
+ expect(cb({ ...testState, [key]: testValue })).toEqual(testValue);
+ });
+ };
+ const simpleKeys = keyStore(simpleSelectors);
+ describe('simple selectors link their values from problem store', () => {
+ [
+ simpleKeys.problemType,
+ simpleKeys.answers,
+ simpleKeys.correctAnswerCount,
+ simpleKeys.settings,
+ simpleKeys.question,
+ simpleKeys.defaultSettings,
+ ].map(testSimpleSelector);
+ });
+ test('simple selector completeState equals the entire state', () => {
+ const { preSelectors, cb } = simpleSelectors[simpleKeys.completeState];
+ expect(preSelectors).toEqual([problemState]);
+ expect(cb({
+ ...testState,
+ [simpleKeys.completeState]: testValue,
+ })).toEqual({
+ ...testState,
+ [simpleKeys.completeState]: testValue,
+ });
+ });
+ });
+});
diff --git a/src/editors/data/redux/requests/index.js b/src/editors/data/redux/requests/index.js
new file mode 100644
index 0000000000..8abd5f91d9
--- /dev/null
+++ b/src/editors/data/redux/requests/index.js
@@ -0,0 +1,2 @@
+export { actions, reducer } from './reducer';
+export { default as selectors } from './selectors';
diff --git a/src/editors/data/redux/requests/reducer.js b/src/editors/data/redux/requests/reducer.js
new file mode 100644
index 0000000000..583c390648
--- /dev/null
+++ b/src/editors/data/redux/requests/reducer.js
@@ -0,0 +1,66 @@
+import { createSlice } from '@reduxjs/toolkit';
+
+import { StrictDict } from '../../../utils';
+
+import { RequestStates, RequestKeys } from '../../constants/requests';
+
+const initialState = {
+ [RequestKeys.fetchUnit]: { status: RequestStates.inactive },
+ [RequestKeys.fetchBlock]: { status: RequestStates.inactive },
+ [RequestKeys.fetchStudioView]: { status: RequestStates.inactive },
+ [RequestKeys.saveBlock]: { status: RequestStates.inactive },
+ [RequestKeys.uploadAsset]: { status: RequestStates.inactive },
+ [RequestKeys.allowThumbnailUpload]: { status: RequestStates.inactive },
+ [RequestKeys.uploadThumbnail]: { status: RequestStates.inactive },
+ [RequestKeys.uploadTranscript]: { status: RequestStates.inactive },
+ [RequestKeys.deleteTranscript]: { status: RequestStates.inactive },
+ [RequestKeys.fetchCourseDetails]: { status: RequestStates.inactive },
+ [RequestKeys.fetchImages]: { status: RequestStates.inactive },
+ [RequestKeys.fetchVideos]: { status: RequestStates.inactive },
+ [RequestKeys.uploadVideo]: { status: RequestStates.inactive },
+ [RequestKeys.checkTranscriptsForImport]: { status: RequestStates.inactive },
+ [RequestKeys.importTranscript]: { status: RequestStates.inactive },
+ [RequestKeys.fetchVideoFeatures]: { status: RequestStates.inactive },
+ [RequestKeys.fetchAdvancedSettings]: { status: RequestStates.inactive },
+};
+
+// eslint-disable-next-line no-unused-vars
+const requests = createSlice({
+ name: 'requests',
+ initialState,
+ reducers: {
+ startRequest: (state, { payload }) => ({
+ ...state,
+ [payload]: {
+ status: RequestStates.pending,
+ },
+ }),
+ completeRequest: (state, { payload }) => ({
+ ...state,
+ [payload.requestKey]: {
+ status: RequestStates.completed,
+ response: payload.response,
+ },
+ }),
+ failRequest: (state, { payload }) => ({
+ ...state,
+ [payload.requestKey]: {
+ status: RequestStates.failed,
+ error: payload.error,
+ },
+ }),
+ clearRequest: (state, { payload }) => ({
+ ...state,
+ [payload.requestKey]: {},
+ }),
+ },
+});
+
+const actions = StrictDict(requests.actions);
+const { reducer } = requests;
+
+export {
+ actions,
+ reducer,
+ initialState,
+};
diff --git a/src/editors/data/redux/requests/reducer.test.js b/src/editors/data/redux/requests/reducer.test.js
new file mode 100644
index 0000000000..62872a47db
--- /dev/null
+++ b/src/editors/data/redux/requests/reducer.test.js
@@ -0,0 +1,49 @@
+import { initialState, actions, reducer } from './reducer';
+import { RequestStates, RequestKeys } from '../../constants/requests';
+
+describe('requests reducer', () => {
+ test('intial state generated on create', () => {
+ expect(reducer(undefined, {})).toEqual(initialState);
+ });
+
+ describe('handling actions', () => {
+ const arbitraryKey = 'ArbItrAryKey';
+ const requestsList = [RequestKeys.fetchUnit, RequestKeys.fetchBlock, RequestKeys.saveBlock, arbitraryKey];
+
+ requestsList.forEach(requestKey => {
+ describe(`${requestKey} lifecycle`, () => {
+ const testAction = (action, args, expected) => {
+ const testingState = {
+ ...initialState,
+ arbitraryField: 'arbitrary',
+ [requestKey]: { arbitrary: 'state' },
+ };
+ expect(reducer(testingState, actions[action](args))).toEqual({
+ ...testingState,
+ [requestKey]: expected,
+ });
+ };
+ test('startRequest sets pending status', () => {
+ testAction('startRequest', requestKey, { status: RequestStates.pending });
+ });
+ test('completeRequest sets completed status and loads response', () => {
+ testAction(
+ 'completeRequest',
+ { requestKey },
+ { status: RequestStates.completed },
+ );
+ });
+ test('failRequest sets failed state and loads error', () => {
+ testAction(
+ 'failRequest',
+ { requestKey },
+ { status: RequestStates.failed },
+ );
+ });
+ test('clearRequest clears request state', () => {
+ testAction('clearRequest', { requestKey }, {});
+ });
+ });
+ });
+ });
+});
diff --git a/src/editors/data/redux/requests/selectors.js b/src/editors/data/redux/requests/selectors.js
new file mode 100644
index 0000000000..c9b318bcbe
--- /dev/null
+++ b/src/editors/data/redux/requests/selectors.js
@@ -0,0 +1,41 @@
+import { StrictDict } from '../../../utils';
+import { RequestStates } from '../../constants/requests';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './selectors';
+
+export const requestStatus = (state, { requestKey }) => state.requests[requestKey];
+
+export const statusSelector = (fn) => (state, { requestKey }) => fn(state.requests[requestKey]);
+
+export const isInactive = ({ status }) => status === RequestStates.inactive;
+export const isPending = ({ status }) => status === RequestStates.pending;
+export const isCompleted = ({ status }) => status === RequestStates.completed;
+export const isFailed = ({ status }) => status === RequestStates.failed;
+export const isFinished = ({ status }) => (
+ [RequestStates.failed, RequestStates.completed].includes(status)
+);
+export const error = (request) => request.error;
+export const errorStatus = (request) => request.error?.response?.status;
+export const errorCode = (request) => request.error?.response?.data;
+
+export const data = (request) => request.data;
+
+export const connectedStatusSelectors = () => ({
+ isInactive: module.statusSelector(isInactive),
+ isPending: module.statusSelector(isPending),
+ isCompleted: module.statusSelector(isCompleted),
+ isFailed: module.statusSelector(isFailed),
+ isFinished: module.statusSelector(isFinished),
+ error: module.statusSelector(error),
+ errorCode: module.statusSelector(errorCode),
+ errorStatus: module.statusSelector(errorStatus),
+ data: module.statusSelector(data),
+});
+
+export default StrictDict({
+ requestStatus,
+ ...module.connectedStatusSelectors(),
+});
diff --git a/src/editors/data/redux/requests/selectors.test.js b/src/editors/data/redux/requests/selectors.test.js
new file mode 100644
index 0000000000..d987a72e75
--- /dev/null
+++ b/src/editors/data/redux/requests/selectors.test.js
@@ -0,0 +1,123 @@
+/* eslint-disable no-import-assign */
+import { RequestStates } from '../../constants/requests';
+
+// import * in order to mock in-file references
+import * as selectors from './selectors';
+
+jest.mock('reselect', () => ({
+ createSelector: jest.fn((preSelectors, cb) => ({ preSelectors, cb })),
+}));
+
+const testValue = 'my test VALUE';
+const testKey = 'MY test key';
+
+describe('request selectors', () => {
+ describe('basic selectors', () => {
+ describe('requestStatus', () => {
+ it('returns the state associated with the given requestKey', () => {
+ expect(
+ selectors.requestStatus(
+ { requests: { [testKey]: testValue } },
+ { requestKey: testKey },
+ ),
+ ).toEqual(testValue);
+ });
+ });
+ describe('statusSelector', () => {
+ it('returns a state selector that applies a fn against request state by requestKey', () => {
+ const myMethod = ({ data }) => ({ myData: data });
+ expect(selectors.statusSelector(myMethod)(
+ { requests: { [testKey]: { data: testValue } } },
+ { requestKey: testKey },
+ )).toEqual({ myData: testValue });
+ });
+ });
+ describe('state selectors', () => {
+ const testStateSelector = (selector, expected) => {
+ describe(selector, () => {
+ it(`returns true iff the request status equals ${expected}`, () => {
+ expect(selectors[selector]({ status: expected })).toEqual(true);
+ expect(selectors[selector]({ status: 'other' })).toEqual(false);
+ });
+ });
+ };
+ testStateSelector('isInactive', RequestStates.inactive);
+ testStateSelector('isPending', RequestStates.pending);
+ testStateSelector('isCompleted', RequestStates.completed);
+ testStateSelector('isFailed', RequestStates.failed);
+ describe('isFinished', () => {
+ it('returns true iff the request is completed or failed', () => {
+ expect(selectors.isFinished({ status: RequestStates.completed })).toEqual(true);
+ expect(selectors.isFinished({ status: RequestStates.failed })).toEqual(true);
+ expect(selectors.isFinished({ status: 'other' })).toEqual(false);
+ });
+ });
+ });
+ describe('error selectors', () => {
+ describe('error', () => {
+ it('returns the error for the request', () => {
+ expect(selectors.error({ error: testValue })).toEqual(testValue);
+ });
+ });
+ describe('errorStatus', () => {
+ it('returns the status the error response iff one exists', () => {
+ expect(selectors.errorStatus({})).toEqual(undefined);
+ expect(selectors.errorStatus({ error: {} })).toEqual(undefined);
+ expect(selectors.errorStatus({ error: { response: {} } })).toEqual(undefined);
+ expect(selectors.errorStatus(
+ { error: { response: { status: testValue } } },
+ )).toEqual(testValue);
+ });
+ });
+ describe('errorCode', () => {
+ it('returns the status the error code iff one exists', () => {
+ expect(selectors.errorCode({})).toEqual(undefined);
+ expect(selectors.errorCode({ error: {} })).toEqual(undefined);
+ expect(selectors.errorCode({ error: { response: {} } })).toEqual(undefined);
+ expect(selectors.errorCode(
+ { error: { response: { data: testValue } } },
+ )).toEqual(testValue);
+ });
+ });
+ });
+ describe('data', () => {
+ it('returns the data from the request', () => {
+ expect(selectors.data({ data: testValue })).toEqual(testValue);
+ });
+ });
+ });
+ describe('exported selectors', () => {
+ test('requestStatus forwards basic selector', () => {
+ expect(selectors.default.requestStatus).toEqual(selectors.requestStatus);
+ });
+ describe('statusSelector selectors', () => {
+ let statusSelector;
+ let connectedSelectors;
+ beforeEach(() => {
+ statusSelector = selectors.statusSelector;
+ selectors.statusSelector = jest.fn(key => ({ statusSelector: key }));
+ connectedSelectors = selectors.connectedStatusSelectors();
+ });
+ afterEach(() => {
+ selectors.statusSelector = statusSelector;
+ });
+ const testStatusSelector = (name) => {
+ describe(name, () => {
+ it(`returns a status selector keyed to the ${name} selector`, () => {
+ expect(connectedSelectors[name].statusSelector).toEqual(selectors[name]);
+ });
+ });
+ };
+ [
+ 'isInactive',
+ 'isPending',
+ 'isCompleted',
+ 'isFailed',
+ 'error',
+ 'errorCode',
+ 'errorStatus',
+ 'data',
+ ].map(testStatusSelector);
+ });
+ });
+});
diff --git a/src/editors/data/redux/thunkActions/app.js b/src/editors/data/redux/thunkActions/app.js
new file mode 100644
index 0000000000..fa50c91a06
--- /dev/null
+++ b/src/editors/data/redux/thunkActions/app.js
@@ -0,0 +1,141 @@
+import { StrictDict, camelizeKeys } from '../../../utils';
+import * as requests from './requests';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './app';
+import { actions as appActions } from '../app';
+import { actions as requestsActions } from '../requests';
+import { RequestKeys } from '../../constants/requests';
+
+// Similar to `import { actions } from '..';` but avoid circular imports:
+const actions = {
+ app: appActions,
+ requests: requestsActions,
+};
+
+export const fetchBlock = () => (dispatch) => {
+ dispatch(requests.fetchBlock({
+ onSuccess: (response) => {
+ dispatch(actions.app.setBlockValue(response));
+ dispatch(actions.app.setShowRawEditor(response));
+ },
+ onFailure: (error) => dispatch(actions.requests.failRequest({
+ requestKey: RequestKeys.fetchBlock,
+ error,
+ })),
+ }));
+};
+
+export const fetchStudioView = () => (dispatch) => {
+ dispatch(requests.fetchStudioView({
+ onSuccess: (response) => dispatch(actions.app.setStudioView(response)),
+ onFailure: (error) => dispatch(actions.requests.failRequest({
+ requestKey: RequestKeys.fetchStudioView,
+ error,
+ })),
+ }));
+};
+
+export const fetchUnit = () => (dispatch) => {
+ dispatch(requests.fetchUnit({
+ onSuccess: (response) => dispatch(actions.app.setUnitUrl(response)),
+ onFailure: (error) => dispatch(actions.requests.failRequest({
+ requestKey: RequestKeys.fetchUnit,
+ error,
+ })),
+ }));
+};
+
+export const fetchImages = ({ pageNumber }) => (dispatch) => {
+ dispatch(requests.fetchImages({
+ pageNumber,
+ onSuccess: ({ images, imageCount }) => dispatch(actions.app.setImages({ images, imageCount })),
+ onFailure: (error) => dispatch(actions.requests.failRequest({
+ requestKey: RequestKeys.fetchImages,
+ error,
+ })),
+ }));
+};
+
+export const fetchVideos = () => (dispatch) => {
+ dispatch(requests.fetchVideos({
+ onSuccess: (response) => dispatch(actions.app.setVideos(response.data.videos)),
+ onFailure: (error) => dispatch(actions.requests.failRequest({
+ requestKey: RequestKeys.fetchVideos,
+ error,
+ })),
+ }));
+};
+
+export const fetchCourseDetails = () => (dispatch) => {
+ dispatch(requests.fetchCourseDetails({
+ onSuccess: (response) => dispatch(actions.app.setCourseDetails(response)),
+ onFailure: (error) => dispatch(actions.requests.failRequest({
+ requestKey: RequestKeys.fetchCourseDetails,
+ error,
+ })),
+ }));
+};
+
+/**
+ * @param {string} studioEndpointUrl
+ * @param {string} blockId
+ * @param {string} learningContextId
+ * @param {string} blockType
+ */
+export const initialize = (data) => (dispatch) => {
+ const editorType = data.blockType;
+ dispatch(actions.app.initialize(data));
+ dispatch(module.fetchBlock());
+ dispatch(module.fetchUnit());
+ switch (editorType) {
+ case 'problem':
+ dispatch(module.fetchImages({ pageNumber: 0 }));
+ break;
+ case 'video':
+ dispatch(module.fetchVideos());
+ dispatch(module.fetchStudioView());
+ dispatch(module.fetchCourseDetails());
+ break;
+ case 'html':
+ dispatch(module.fetchImages({ pageNumber: 0 }));
+ break;
+ default:
+ break;
+ }
+};
+
+/**
+ * @param {func} onSuccess
+ */
+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);
+ },
+ }));
+};
+
+export const uploadAsset = ({ file, setSelection }) => (dispatch) => {
+ dispatch(requests.uploadAsset({
+ asset: file,
+ onSuccess: (response) => setSelection(camelizeKeys(response.data.asset)),
+ }));
+};
+
+export default StrictDict({
+ fetchBlock,
+ fetchCourseDetails,
+ fetchStudioView,
+ fetchUnit,
+ fetchVideos,
+ initialize,
+ saveBlock,
+ fetchImages,
+ uploadAsset,
+});
diff --git a/src/editors/data/redux/thunkActions/app.test.js b/src/editors/data/redux/thunkActions/app.test.js
new file mode 100644
index 0000000000..2c962b2853
--- /dev/null
+++ b/src/editors/data/redux/thunkActions/app.test.js
@@ -0,0 +1,348 @@
+/* eslint-disable no-import-assign */
+import { actions } from '..';
+import { camelizeKeys } from '../../../utils';
+import { RequestKeys } from '../../constants/requests';
+import * as thunkActions from './app';
+
+jest.mock('./requests', () => ({
+ fetchBlock: (args) => ({ fetchBlock: args }),
+ fetchUnit: (args) => ({ fetchUnit: args }),
+ saveBlock: (args) => ({ saveBlock: args }),
+ uploadAsset: (args) => ({ uploadAsset: args }),
+ fetchStudioView: (args) => ({ fetchStudioView: args }),
+ fetchImages: (args) => ({ fetchImages: args }),
+ fetchVideos: (args) => ({ fetchVideos: args }),
+ fetchCourseDetails: (args) => ({ fetchCourseDetails: args }),
+}));
+
+jest.mock('../../../utils', () => ({
+ camelizeKeys: (args) => ([{ camelizeKeys: args }]),
+ ...jest.requireActual('../../../utils'),
+}));
+
+const testValue = {
+ data: {
+ assets: 'test VALUE',
+ videos: 'vIDeO vALUe',
+ },
+};
+
+describe('app thunkActions', () => {
+ let dispatch;
+ let dispatchedAction;
+ beforeEach(() => {
+ dispatch = jest.fn((action) => ({ dispatch: action }));
+ });
+ describe('fetchBlock', () => {
+ beforeEach(() => {
+ thunkActions.fetchBlock()(dispatch);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches fetchBlock action', () => {
+ expect(dispatchedAction.fetchBlock).not.toEqual(undefined);
+ });
+ it('dispatches actions.app.setBlockValue on success', () => {
+ dispatch.mockClear();
+ dispatchedAction.fetchBlock.onSuccess(testValue);
+ expect(dispatch).toHaveBeenCalledWith(actions.app.setBlockValue(testValue));
+ });
+ it('dispatches failRequest with fetchBlock requestKey on failure', () => {
+ dispatch.mockClear();
+ dispatchedAction.fetchBlock.onFailure(testValue);
+ expect(dispatch).toHaveBeenCalledWith(actions.requests.failRequest({
+ requestKey: RequestKeys.fetchBlock,
+ error: testValue,
+ }));
+ });
+ });
+
+ describe('fetchStudioView', () => {
+ beforeEach(() => {
+ thunkActions.fetchStudioView()(dispatch);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches fetchStudioView action', () => {
+ expect(dispatchedAction.fetchStudioView).not.toEqual(undefined);
+ });
+ it('dispatches actions.app.setStudioViewe on success', () => {
+ dispatch.mockClear();
+ dispatchedAction.fetchStudioView.onSuccess(testValue);
+ expect(dispatch).toHaveBeenCalledWith(actions.app.setStudioView(testValue));
+ });
+ it('dispatches failRequest with fetchStudioView requestKey on failure', () => {
+ dispatch.mockClear();
+ dispatchedAction.fetchStudioView.onFailure(testValue);
+ expect(dispatch).toHaveBeenCalledWith(actions.requests.failRequest({
+ requestKey: RequestKeys.fetchStudioView,
+ error: testValue,
+ }));
+ });
+ });
+
+ describe('fetchUnit', () => {
+ beforeEach(() => {
+ thunkActions.fetchUnit()(dispatch);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches fetchUnit action', () => {
+ expect(dispatchedAction.fetchUnit).not.toEqual(undefined);
+ });
+ it('dispatches actions.app.setUnitUrl on success', () => {
+ dispatch.mockClear();
+ dispatchedAction.fetchUnit.onSuccess(testValue);
+ expect(dispatch).toHaveBeenCalledWith(actions.app.setUnitUrl(testValue));
+ });
+ it('dispatches failRequest with fetchUnit requestKey on failure', () => {
+ dispatch.mockClear();
+ dispatchedAction.fetchUnit.onFailure(testValue);
+ expect(dispatch).toHaveBeenCalledWith(actions.requests.failRequest({
+ requestKey: RequestKeys.fetchUnit,
+ error: testValue,
+ }));
+ });
+ });
+ describe('fetchImages', () => {
+ beforeEach(() => {
+ thunkActions.fetchImages({ pageNumber: 0 })(dispatch);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches fetchImages action', () => {
+ expect(dispatchedAction.fetchImages).not.toEqual(undefined);
+ });
+ it('dispatches actions.app.setImages on success', () => {
+ dispatch.mockClear();
+ dispatchedAction.fetchImages.onSuccess({ images: {}, imageCount: 0 });
+ expect(dispatch).toHaveBeenCalledWith(actions.app.setImages({ images: {}, imageCount: 0 }));
+ });
+ it('dispatches failRequest with fetchImages requestKey on failure', () => {
+ dispatch.mockClear();
+ dispatchedAction.fetchImages.onFailure(testValue);
+ expect(dispatch).toHaveBeenCalledWith(actions.requests.failRequest({
+ requestKey: RequestKeys.fetchImages,
+ error: testValue,
+ }));
+ });
+ });
+ describe('fetchVideos', () => {
+ beforeEach(() => {
+ thunkActions.fetchVideos()(dispatch);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches fetchImages action', () => {
+ expect(dispatchedAction.fetchVideos).not.toEqual(undefined);
+ });
+ it('dispatches actions.app.setVideos on success', () => {
+ dispatch.mockClear();
+ dispatchedAction.fetchVideos.onSuccess(testValue);
+ expect(dispatch).toHaveBeenCalledWith(actions.app.setVideos(testValue.data.videos));
+ });
+ it('dispatches failRequest with fetchVideos requestKey on failure', () => {
+ dispatch.mockClear();
+ dispatchedAction.fetchVideos.onFailure(testValue);
+ expect(dispatch).toHaveBeenCalledWith(actions.requests.failRequest({
+ requestKey: RequestKeys.fetchVideos,
+ error: testValue,
+ }));
+ });
+ });
+ describe('fetchCourseDetails', () => {
+ beforeEach(() => {
+ thunkActions.fetchCourseDetails()(dispatch);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches fetchUnit action', () => {
+ expect(dispatchedAction.fetchCourseDetails).not.toEqual(undefined);
+ });
+ it('dispatches actions.app.setUnitUrl on success', () => {
+ dispatch.mockClear();
+ dispatchedAction.fetchCourseDetails.onSuccess(testValue);
+ expect(dispatch).toHaveBeenCalledWith(actions.app.setCourseDetails(testValue));
+ });
+ it('dispatches failRequest with fetchCourseDetails requestKey on failure', () => {
+ dispatch.mockClear();
+ dispatchedAction.fetchCourseDetails.onFailure(testValue);
+ expect(dispatch).toHaveBeenCalledWith(actions.requests.failRequest({
+ requestKey: RequestKeys.fetchCourseDetails,
+ error: testValue,
+ }));
+ });
+ });
+ describe('initialize without block type defined', () => {
+ it('dispatches actions.app.initialize, and then fetches both block and unit', () => {
+ const {
+ fetchBlock,
+ fetchUnit,
+ fetchStudioView,
+ fetchImages,
+ fetchVideos,
+ fetchCourseDetails,
+ } = thunkActions;
+ thunkActions.fetchBlock = () => 'fetchBlock';
+ thunkActions.fetchUnit = () => 'fetchUnit';
+ thunkActions.fetchStudioView = () => 'fetchStudioView';
+ thunkActions.fetchImages = () => 'fetchImages';
+ thunkActions.fetchVideos = () => 'fetchVideos';
+ thunkActions.fetchCourseDetails = () => 'fetchCourseDetails';
+ thunkActions.initialize(testValue)(dispatch);
+ expect(dispatch.mock.calls).toEqual([
+ [actions.app.initialize(testValue)],
+ [thunkActions.fetchBlock()],
+ [thunkActions.fetchUnit()],
+ ]);
+ thunkActions.fetchBlock = fetchBlock;
+ thunkActions.fetchUnit = fetchUnit;
+ thunkActions.fetchStudioView = fetchStudioView;
+ thunkActions.fetchImages = fetchImages;
+ thunkActions.fetchVideos = fetchVideos;
+ thunkActions.fetchCourseDetails = fetchCourseDetails;
+ });
+ });
+ describe('initialize with block type html', () => {
+ it('dispatches actions.app.initialize, and then fetches both block and unit', () => {
+ const {
+ fetchBlock,
+ fetchUnit,
+ fetchStudioView,
+ fetchImages,
+ fetchVideos,
+ fetchCourseDetails,
+ } = thunkActions;
+ thunkActions.fetchBlock = () => 'fetchBlock';
+ thunkActions.fetchUnit = () => 'fetchUnit';
+ thunkActions.fetchStudioView = () => 'fetchStudioView';
+ thunkActions.fetchImages = () => 'fetchImages';
+ thunkActions.fetchVideos = () => 'fetchVideos';
+ thunkActions.fetchCourseDetails = () => 'fetchCourseDetails';
+ const data = {
+ ...testValue,
+ blockType: 'html',
+ };
+ thunkActions.initialize(data)(dispatch);
+ expect(dispatch.mock.calls).toEqual([
+ [actions.app.initialize(data)],
+ [thunkActions.fetchBlock()],
+ [thunkActions.fetchUnit()],
+ [thunkActions.fetchImages()],
+ ]);
+ thunkActions.fetchBlock = fetchBlock;
+ thunkActions.fetchUnit = fetchUnit;
+ thunkActions.fetchStudioView = fetchStudioView;
+ thunkActions.fetchImages = fetchImages;
+ thunkActions.fetchVideos = fetchVideos;
+ thunkActions.fetchCourseDetails = fetchCourseDetails;
+ });
+ });
+ describe('initialize with block type problem', () => {
+ it('dispatches actions.app.initialize, and then fetches both block and unit', () => {
+ const {
+ fetchBlock,
+ fetchUnit,
+ fetchStudioView,
+ fetchImages,
+ fetchVideos,
+ fetchCourseDetails,
+ } = thunkActions;
+ thunkActions.fetchBlock = () => 'fetchBlock';
+ thunkActions.fetchUnit = () => 'fetchUnit';
+ thunkActions.fetchStudioView = () => 'fetchStudioView';
+ thunkActions.fetchImages = () => 'fetchImages';
+ thunkActions.fetchVideos = () => 'fetchVideos';
+ thunkActions.fetchCourseDetails = () => 'fetchCourseDetails';
+ const data = {
+ ...testValue,
+ blockType: 'problem',
+ };
+ thunkActions.initialize(data)(dispatch);
+ expect(dispatch.mock.calls).toEqual([
+ [actions.app.initialize(data)],
+ [thunkActions.fetchBlock()],
+ [thunkActions.fetchUnit()],
+ [thunkActions.fetchImages()],
+ ]);
+ thunkActions.fetchBlock = fetchBlock;
+ thunkActions.fetchUnit = fetchUnit;
+ thunkActions.fetchStudioView = fetchStudioView;
+ thunkActions.fetchImages = fetchImages;
+ thunkActions.fetchVideos = fetchVideos;
+ thunkActions.fetchCourseDetails = fetchCourseDetails;
+ });
+ });
+ describe('initialize with block type video', () => {
+ it('dispatches actions.app.initialize, and then fetches both block and unit', () => {
+ const {
+ fetchBlock,
+ fetchUnit,
+ fetchStudioView,
+ fetchImages,
+ fetchVideos,
+ fetchCourseDetails,
+ } = thunkActions;
+ thunkActions.fetchBlock = () => 'fetchBlock';
+ thunkActions.fetchUnit = () => 'fetchUnit';
+ thunkActions.fetchStudioView = () => 'fetchStudioView';
+ thunkActions.fetchImages = () => 'fetchImages';
+ thunkActions.fetchVideos = () => 'fetchVideos';
+ thunkActions.fetchCourseDetails = () => 'fetchCourseDetails';
+ const data = {
+ ...testValue,
+ blockType: 'video',
+ };
+ thunkActions.initialize(data)(dispatch);
+ expect(dispatch.mock.calls).toEqual([
+ [actions.app.initialize(data)],
+ [thunkActions.fetchBlock()],
+ [thunkActions.fetchUnit()],
+ [thunkActions.fetchVideos()],
+ [thunkActions.fetchStudioView()],
+ [thunkActions.fetchCourseDetails()],
+ ]);
+ thunkActions.fetchBlock = fetchBlock;
+ thunkActions.fetchUnit = fetchUnit;
+ thunkActions.fetchStudioView = fetchStudioView;
+ thunkActions.fetchImages = fetchImages;
+ thunkActions.fetchVideos = fetchVideos;
+ thunkActions.fetchCourseDetails = fetchCourseDetails;
+ });
+ });
+ describe('saveBlock', () => {
+ let returnToUnit;
+ let calls;
+ beforeEach(() => {
+ returnToUnit = jest.fn();
+ thunkActions.saveBlock(testValue, returnToUnit)(dispatch);
+ calls = dispatch.mock.calls;
+ });
+ it('dispatches actions.app.setBlockContent with content, before dispatching saveBlock', () => {
+ expect(calls[0]).toEqual([actions.app.setBlockContent(testValue)]);
+ const saveCall = calls[1][0];
+ expect(saveCall.saveBlock).not.toEqual(undefined);
+ });
+ it('dispatches saveBlock with passed content', () => {
+ expect(calls[1][0].saveBlock.content).toEqual(testValue);
+ });
+ it('dispatches actions.app.setSaveResponse with response and then calls returnToUnit', () => {
+ dispatch.mockClear();
+ const response = 'testRESPONSE';
+ calls[1][0].saveBlock.onSuccess(response);
+ expect(dispatch).toHaveBeenCalledWith(actions.app.setSaveResponse(response));
+ expect(returnToUnit).toHaveBeenCalled();
+ });
+ });
+ describe('uploadAsset', () => {
+ const setSelection = jest.fn();
+ beforeEach(() => {
+ thunkActions.uploadAsset({ file: testValue, setSelection })(dispatch);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches uploadAsset action', () => {
+ expect(dispatchedAction.uploadAsset).not.toBe(undefined);
+ });
+ test('passes file as image prop', () => {
+ expect(dispatchedAction.uploadAsset.asset).toEqual(testValue);
+ });
+ test('onSuccess: calls setSelection with camelized response.data.asset', () => {
+ dispatchedAction.uploadAsset.onSuccess({ data: { asset: testValue } });
+ expect(setSelection).toHaveBeenCalledWith(camelizeKeys(testValue));
+ });
+ });
+});
diff --git a/src/editors/data/redux/thunkActions/index.js b/src/editors/data/redux/thunkActions/index.js
new file mode 100644
index 0000000000..b787e23cff
--- /dev/null
+++ b/src/editors/data/redux/thunkActions/index.js
@@ -0,0 +1,11 @@
+import { StrictDict } from '../../../utils';
+
+import app from './app';
+import video from './video';
+import problem from './problem';
+
+export default StrictDict({
+ app,
+ video,
+ problem,
+});
diff --git a/src/editors/data/redux/thunkActions/problem.js b/src/editors/data/redux/thunkActions/problem.js
new file mode 100644
index 0000000000..62de4f44a9
--- /dev/null
+++ b/src/editors/data/redux/thunkActions/problem.js
@@ -0,0 +1,88 @@
+import _ from 'lodash';
+import { actions as problemActions } from '../problem';
+import * as requests from './requests';
+import { OLXParser } from '../../../containers/ProblemEditor/data/OLXParser';
+import { parseSettings } from '../../../containers/ProblemEditor/data/SettingsParser';
+import { ProblemTypeKeys } from '../../constants/problem';
+import ReactStateOLXParser from '../../../containers/ProblemEditor/data/ReactStateOLXParser';
+import { blankProblemOLX } from '../../../containers/ProblemEditor/data/mockData/olxTestData';
+import { camelizeKeys } from '../../../utils';
+import { fetchEditorContent } from '../../../containers/ProblemEditor/components/EditProblemView/hooks';
+
+// Similar to `import { actions, selectors } from '..';` but avoid circular imports:
+const actions = { problem: problemActions };
+
+export const switchToAdvancedEditor = () => (dispatch, getState) => {
+ const state = getState();
+ const editorObject = fetchEditorContent({ format: '' });
+ const reactOLXParser = new ReactStateOLXParser({ problem: state.problem, editorObject });
+ const rawOLX = reactOLXParser.buildOLX();
+ dispatch(actions.problem.updateField({ problemType: ProblemTypeKeys.ADVANCED, rawOLX }));
+};
+
+export const isBlankProblem = ({ rawOLX }) => {
+ if (rawOLX.replace(/\s/g, '') === blankProblemOLX.rawOLX) {
+ return true;
+ }
+ return false;
+};
+
+export const getDataFromOlx = ({ rawOLX, rawSettings, defaultSettings }) => {
+ let olxParser;
+ let parsedProblem;
+ const { default_to_advanced: defaultToAdvanced } = rawSettings;
+ try {
+ olxParser = new OLXParser(rawOLX);
+ if (defaultToAdvanced) {
+ parsedProblem = olxParser.getBetaParsedOLXData();
+ } else {
+ parsedProblem = olxParser.getParsedOLXData();
+ }
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error('The Problem Could Not Be Parsed from OLX. redirecting to Advanced editor.', error);
+ return { problemType: ProblemTypeKeys.ADVANCED, rawOLX, settings: parseSettings(rawSettings, defaultSettings) };
+ }
+ if (parsedProblem?.problemType === ProblemTypeKeys.ADVANCED) {
+ return { problemType: ProblemTypeKeys.ADVANCED, rawOLX, settings: parseSettings(rawSettings, defaultSettings) };
+ }
+ const { settings, ...data } = parsedProblem;
+ const parsedSettings = { ...settings, ...parseSettings(rawSettings, defaultSettings) };
+ if (!_.isEmpty(rawOLX) && !_.isEmpty(data)) {
+ return { ...data, rawOLX, settings: parsedSettings };
+ }
+ return { settings: parsedSettings };
+};
+
+export const loadProblem = ({ rawOLX, rawSettings, defaultSettings }) => (dispatch) => {
+ if (isBlankProblem({ rawOLX })) {
+ dispatch(actions.problem.setEnableTypeSelection(camelizeKeys(defaultSettings)));
+ } else {
+ dispatch(actions.problem.load(getDataFromOlx({ rawOLX, rawSettings, defaultSettings })));
+ }
+};
+
+export const fetchAdvancedSettings = ({ rawOLX, rawSettings }) => (dispatch) => {
+ const advancedProblemSettingKeys = ['max_attempts', 'showanswer', 'show_reset_button', 'rerandomize'];
+ dispatch(requests.fetchAdvancedSettings({
+ onSuccess: (response) => {
+ const defaultSettings = {};
+ Object.entries(response.data).forEach(([key, value]) => {
+ if (advancedProblemSettingKeys.includes(key)) {
+ defaultSettings[key] = value.value;
+ }
+ });
+ dispatch(actions.problem.updateField({ defaultSettings: camelizeKeys(defaultSettings) }));
+ loadProblem({ rawOLX, rawSettings, defaultSettings })(dispatch);
+ },
+ onFailure: () => { loadProblem({ rawOLX, rawSettings, defaultSettings: {} })(dispatch); },
+ }));
+};
+
+export const initializeProblem = (blockValue) => (dispatch) => {
+ const rawOLX = _.get(blockValue, 'data.data', {});
+ const rawSettings = _.get(blockValue, 'data.metadata', {});
+ dispatch(fetchAdvancedSettings({ rawOLX, rawSettings }));
+};
+
+export default { initializeProblem, switchToAdvancedEditor, fetchAdvancedSettings };
diff --git a/src/editors/data/redux/thunkActions/problem.test.js b/src/editors/data/redux/thunkActions/problem.test.js
new file mode 100644
index 0000000000..828547b5b4
--- /dev/null
+++ b/src/editors/data/redux/thunkActions/problem.test.js
@@ -0,0 +1,97 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import { actions } from '..';
+import {
+ initializeProblem,
+ switchToAdvancedEditor,
+ fetchAdvancedSettings,
+ loadProblem,
+} from './problem';
+import { checkboxesOLXWithFeedbackAndHintsOLX, advancedProblemOlX, blankProblemOLX } from '../../../containers/ProblemEditor/data/mockData/olxTestData';
+import { ProblemTypeKeys } from '../../constants/problem';
+
+const mockOlx = 'SOmEVALue';
+const mockBuildOlx = jest.fn(() => mockOlx);
+jest.mock('../../../containers/ProblemEditor/data/ReactStateOLXParser', () => jest.fn().mockImplementation(() => ({ buildOLX: mockBuildOlx })));
+
+jest.mock('../problem', () => ({
+ actions: {
+ load: () => {},
+ setEnableTypeSelection: () => {},
+ updateField: (args) => args,
+ },
+}));
+
+jest.mock('./requests', () => ({
+ fetchAdvancedSettings: (args) => ({ fetchAdvanceSettings: args }),
+}));
+
+const blockValue = {
+ data: {
+ data: checkboxesOLXWithFeedbackAndHintsOLX.rawOLX,
+ metadata: {},
+ },
+};
+
+let rawOLX = blockValue.data.data;
+const rawSettings = {};
+const defaultSettings = { max_attempts: 1 };
+
+describe('problem thunkActions', () => {
+ let dispatch;
+ let getState;
+ let dispatchedAction;
+ beforeEach(() => {
+ dispatch = jest.fn((action) => ({ dispatch: action }));
+ getState = jest.fn(() => ({
+ problem: {
+ },
+ }));
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+ test('initializeProblem visual Problem :', () => {
+ initializeProblem(blockValue)(dispatch);
+ expect(dispatch).toHaveBeenCalled();
+ });
+ test('switchToAdvancedEditor visual Problem', () => {
+ switchToAdvancedEditor()(dispatch, getState);
+ expect(dispatch).toHaveBeenCalledWith(
+ actions.problem.updateField({ problemType: ProblemTypeKeys.ADVANCED, rawOLX: mockOlx }),
+ );
+ });
+ describe('fetchAdvanceSettings', () => {
+ it('dispatches fetchAdvanceSettings action', () => {
+ fetchAdvancedSettings({ rawOLX, rawSettings })(dispatch);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ expect(dispatchedAction.fetchAdvanceSettings).not.toEqual(undefined);
+ });
+ it('dispatches actions.problem.updateField and loadProblem on success', () => {
+ dispatch.mockClear();
+ fetchAdvancedSettings({ rawOLX, rawSettings })(dispatch);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ dispatchedAction.fetchAdvanceSettings.onSuccess({ data: { key: 'test', max_attempts: 1 } });
+ expect(dispatch).toHaveBeenCalledWith(actions.problem.load());
+ });
+ it('calls loadProblem on failure', () => {
+ dispatch.mockClear();
+ fetchAdvancedSettings({ rawOLX, rawSettings })(dispatch);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ dispatchedAction.fetchAdvanceSettings.onFailure();
+ expect(dispatch).toHaveBeenCalledWith(actions.problem.load());
+ });
+ });
+ describe('loadProblem', () => {
+ test('initializeProblem advanced Problem', () => {
+ rawOLX = advancedProblemOlX.rawOLX;
+ loadProblem({ rawOLX, rawSettings, defaultSettings })(dispatch);
+ expect(dispatch).toHaveBeenCalledWith(actions.problem.load());
+ });
+ test('initializeProblem blank Problem', () => {
+ rawOLX = blankProblemOLX.rawOLX;
+ loadProblem({ rawOLX, rawSettings, defaultSettings })(dispatch);
+ expect(dispatch).toHaveBeenCalledWith(actions.problem.setEnableTypeSelection());
+ });
+ });
+});
diff --git a/src/editors/data/redux/thunkActions/requests.js b/src/editors/data/redux/thunkActions/requests.js
new file mode 100644
index 0000000000..46f9d1a03a
--- /dev/null
+++ b/src/editors/data/redux/thunkActions/requests.js
@@ -0,0 +1,339 @@
+import { StrictDict } from '../../../utils';
+
+import { RequestKeys } from '../../constants/requests';
+import api, { loadImages } from '../../services/cms/api';
+import { actions as requestsActions } from '../requests';
+import { selectors as appSelectors } from '../app';
+
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './requests';
+
+// Similar to `import { actions, selectors } from '..';` but avoid circular imports:
+const actions = { requests: requestsActions };
+const selectors = { app: appSelectors };
+
+/**
+ * Wrapper around a network request promise, that sends actions to the redux store to
+ * track the state of that promise.
+ * Tracks the promise by requestKey, and sends an action when it is started, succeeds, or
+ * fails. It also accepts onSuccess and onFailure methods to be called with the output
+ * of failure or success of the promise.
+ * @param {string} requestKey - request tracking identifier
+ * @param {Promise} promise - api event promise
+ * @param {[func]} onSuccess - onSuccess method ((response) => { ... })
+ * @param {[func]} onFailure - onFailure method ((error) => { ... })
+ */
+export const networkRequest = ({
+ requestKey,
+ promise,
+ onSuccess,
+ onFailure,
+}) => (dispatch) => {
+ dispatch(actions.requests.startRequest(requestKey));
+ return promise
+ .then((response) => {
+ if (onSuccess) {
+ onSuccess(response);
+ }
+ dispatch(actions.requests.completeRequest({ requestKey, response }));
+ })
+ .catch((error) => {
+ if (onFailure) {
+ onFailure(error);
+ }
+ dispatch(actions.requests.failRequest({ requestKey, error }));
+ });
+};
+
+/**
+ * Tracked fetchByBlockId api method.
+ * Tracked to the `fetchBlock` request key.
+ * @param {[func]} onSuccess - onSuccess method ((response) => { ... })
+ * @param {[func]} onFailure - onFailure method ((error) => { ... })
+ */
+export const fetchBlock = ({ ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.fetchBlock,
+ promise: api.fetchBlockById({
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ blockId: selectors.app.blockId(getState()),
+ }),
+ ...rest,
+ }));
+};
+
+/**
+
+ * Tracked fetchStudioView api method.
+ * Tracked to the `fetchBlock` request key.
+ * @param {[func]} onSuccess - onSuccess method ((response) => { ... })
+ * @param {[func]} onFailure - onFailure method ((error) => { ... })
+ */
+export const fetchStudioView = ({ ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.fetchStudioView,
+ promise: api.fetchStudioView({
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ blockId: selectors.app.blockId(getState()),
+ }),
+ ...rest,
+ }));
+};
+
+/**
+ * Tracked fetchByUnitId api method.
+ * Tracked to the `fetchUnit` request key.
+ * @param {[func]} onSuccess - onSuccess method ((response) => { ... })
+ * @param {[func]} onFailure - onFailure method ((error) => { ... })
+ */
+export const fetchUnit = ({ ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.fetchUnit,
+ promise: api.fetchByUnitId({
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ blockId: selectors.app.blockId(getState()),
+ }),
+ ...rest,
+ }));
+};
+
+/**
+ * Tracked saveBlock api method. Tracked to the `saveBlock` request key.
+ * @param {string} content
+ * @param {[func]} onSuccess - onSuccess method ((response) => { ... })
+ * @param {[func]} onFailure - onFailure method ((error) => { ... })
+ */
+export const saveBlock = ({ content, ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.saveBlock,
+ promise: api.saveBlock({
+ blockId: selectors.app.blockId(getState()),
+ blockType: selectors.app.blockType(getState()),
+ learningContextId: selectors.app.learningContextId(getState()),
+ content,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ title: selectors.app.blockTitle(getState()),
+ }),
+ ...rest,
+ }));
+};
+export const uploadAsset = ({ asset, ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.uploadAsset,
+ promise: api.uploadAsset({
+ learningContextId: selectors.app.learningContextId(getState()),
+ asset,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ }),
+ ...rest,
+ }));
+};
+
+export const fetchImages = ({ pageNumber, ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.fetchImages,
+ promise: api
+ .fetchImages({
+ pageNumber,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ learningContextId: selectors.app.learningContextId(getState()),
+ })
+ .then(({ data }) => ({ images: loadImages(data.assets), imageCount: data.totalCount })),
+ ...rest,
+ }));
+};
+
+export const fetchVideos = ({ ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.fetchVideos,
+ promise: api
+ .fetchVideos({
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ learningContextId: selectors.app.learningContextId(getState()),
+ }),
+ ...rest,
+ }));
+};
+
+export const allowThumbnailUpload = ({ ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.allowThumbnailUpload,
+ promise: api.allowThumbnailUpload({
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ }),
+ ...rest,
+ }));
+};
+
+export const uploadThumbnail = ({ thumbnail, videoId, ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.uploadThumbnail,
+ promise: api.uploadThumbnail({
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ learningContextId: selectors.app.learningContextId(getState()),
+ thumbnail,
+ videoId,
+ }),
+ ...rest,
+ }));
+};
+
+export const checkTranscriptsForImport = ({ videoId, youTubeId, ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.checkTranscriptsForImport,
+ promise: api.checkTranscriptsForImport({
+ blockId: selectors.app.blockId(getState()),
+ videoId,
+ youTubeId,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ }),
+ ...rest,
+ }));
+};
+
+export const importTranscript = ({ youTubeId, ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.importTranscript,
+ promise: api.importTranscript({
+ blockId: selectors.app.blockId(getState()),
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ youTubeId,
+ }),
+ ...rest,
+ }));
+};
+
+export const deleteTranscript = ({ language, videoId, ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.deleteTranscript,
+ promise: api.deleteTranscript({
+ blockId: selectors.app.blockId(getState()),
+ language,
+ videoId,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ }),
+ ...rest,
+ }));
+};
+
+export const uploadTranscript = ({
+ transcript,
+ videoId,
+ language,
+ ...rest
+}) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.uploadTranscript,
+ promise: api.uploadTranscript({
+ blockId: selectors.app.blockId(getState()),
+ transcript,
+ videoId,
+ language,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ }),
+ ...rest,
+ }));
+};
+
+export const updateTranscriptLanguage = ({
+ file,
+ languageBeforeChange,
+ newLanguageCode,
+ videoId,
+ ...rest
+}) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.updateTranscriptLanguage,
+ promise: api.uploadTranscript({
+ blockId: selectors.app.blockId(getState()),
+ transcript: file,
+ videoId,
+ language: languageBeforeChange,
+ newLanguage: newLanguageCode,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ }),
+ ...rest,
+ }));
+};
+
+export const getTranscriptFile = ({ language, videoId, ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.getTranscriptFile,
+ promise: api.getTranscript({
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ blockId: selectors.app.blockId(getState()),
+ videoId,
+ language,
+ }),
+ ...rest,
+ }));
+};
+
+export const fetchCourseDetails = ({ ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.fetchCourseDetails,
+ promise: api.fetchCourseDetails({
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ learningContextId: selectors.app.learningContextId(getState()),
+ }),
+ ...rest,
+ }));
+};
+
+export const fetchAdvancedSettings = ({ ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.fetchAdvancedSettings,
+ promise: api.fetchAdvancedSettings({
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ learningContextId: selectors.app.learningContextId(getState()),
+ }),
+ ...rest,
+ }));
+};
+
+export const fetchVideoFeatures = ({ ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.fetchVideoFeatures,
+ promise: api.fetchVideoFeatures({
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ }),
+ ...rest,
+ }));
+};
+
+export const uploadVideo = ({ data, ...rest }) => (dispatch, getState) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.uploadVideo,
+ promise: api.uploadVideo({
+ data,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
+ learningContextId: selectors.app.learningContextId(getState()),
+ }),
+ ...rest,
+ }));
+};
+
+export default StrictDict({
+ fetchBlock,
+ fetchStudioView,
+ fetchUnit,
+ saveBlock,
+ fetchImages,
+ fetchVideos,
+ uploadAsset,
+ allowThumbnailUpload,
+ uploadThumbnail,
+ deleteTranscript,
+ uploadTranscript,
+ updateTranscriptLanguage,
+ fetchCourseDetails,
+ getTranscriptFile,
+ checkTranscriptsForImport,
+ importTranscript,
+ fetchAdvancedSettings,
+ fetchVideoFeatures,
+ uploadVideo,
+});
diff --git a/src/editors/data/redux/thunkActions/requests.test.js b/src/editors/data/redux/thunkActions/requests.test.js
new file mode 100644
index 0000000000..4b5961b9eb
--- /dev/null
+++ b/src/editors/data/redux/thunkActions/requests.test.js
@@ -0,0 +1,512 @@
+import { keyStore } from '../../../utils';
+import { RequestKeys } from '../../constants/requests';
+import api from '../../services/cms/api';
+import * as requests from './requests';
+import { actions, selectors } from '../index';
+
+const testState = {
+ some: 'data',
+};
+
+jest.mock('../app/selectors', () => ({
+ simpleSelectors: {
+ studioEndpointUrl: (state) => ({ studioEndpointUrl: state }),
+ blockId: (state) => ({ blockId: state }),
+ },
+ studioEndpointUrl: (state) => ({ studioEndpointUrl: state }),
+ blockId: (state) => ({ blockId: state }),
+ blockType: (state) => ({ blockType: state }),
+ learningContextId: (state) => ({ learningContextId: state }),
+ blockTitle: (state) => ({ title: state }),
+}));
+
+jest.mock('../../services/cms/api', () => ({
+ fetchBlockById: ({ id, url }) => ({ id, url }),
+ fetchStudioView: ({ id, url }) => ({ id, url }),
+ fetchByUnitId: ({ id, url }) => ({ id, url }),
+ fetchCourseDetails: (args) => args,
+ saveBlock: (args) => args,
+ fetchImages: ({ id, url }) => ({ id, url }),
+ fetchVideos: ({ id, url }) => ({ id, url }),
+ uploadAsset: (args) => args,
+ loadImages: jest.fn(),
+ uploadThumbnail: (args) => args,
+ uploadTranscript: (args) => args,
+ deleteTranscript: (args) => args,
+ getTranscript: (args) => args,
+ checkTranscriptsForImport: (args) => args,
+ importTranscript: (args) => args,
+ fetchVideoFeatures: (args) => args,
+ uploadVideo: (args) => args,
+}));
+
+const apiKeys = keyStore(api);
+
+let dispatch;
+let onSuccess;
+let onFailure;
+
+const fetchParams = { fetchParam1: 'param1', fetchParam2: 'param2' };
+
+describe('requests thunkActions module', () => {
+ beforeEach(() => {
+ dispatch = jest.fn();
+ onSuccess = jest.fn();
+ onFailure = jest.fn();
+ });
+
+ describe('networkRequest', () => {
+ const requestKey = 'test-request';
+ const testData = ({ some: 'test data' });
+ let resolveFn;
+ let rejectFn;
+ describe('without success and failure handlers', () => {
+ beforeEach(() => {
+ requests.networkRequest({
+ requestKey,
+ promise: new Promise((resolve, reject) => {
+ resolveFn = resolve;
+ rejectFn = reject;
+ }),
+ })(dispatch);
+ });
+ test('calls startRequest action with requestKey', async () => {
+ expect(dispatch.mock.calls).toEqual([[actions.requests.startRequest(requestKey)]]);
+ });
+ describe('on success', () => {
+ beforeEach(async () => {
+ await resolveFn(testData);
+ });
+ it('dispatches completeRequest', async () => {
+ expect(dispatch.mock.calls).toEqual([
+ [actions.requests.startRequest(requestKey)],
+ [actions.requests.completeRequest({ requestKey, response: testData })],
+ ]);
+ });
+ });
+ describe('on failure', () => {
+ beforeEach(async () => {
+ await rejectFn(testData);
+ });
+ test('dispatches completeRequest', async () => {
+ expect(dispatch.mock.calls).toEqual([
+ [actions.requests.startRequest(requestKey)],
+ [actions.requests.failRequest({ requestKey, error: testData })],
+ ]);
+ });
+ });
+ });
+ describe('with handlers', () => {
+ beforeEach(() => {
+ onSuccess = jest.fn();
+ onFailure = jest.fn();
+ requests.networkRequest({
+ requestKey,
+ promise: new Promise((resolve, reject) => {
+ resolveFn = resolve;
+ rejectFn = reject;
+ }),
+ onSuccess,
+ onFailure,
+ })(dispatch);
+ });
+ test('calls startRequest action with requestKey', async () => {
+ expect(dispatch.mock.calls).toEqual([[actions.requests.startRequest(requestKey)]]);
+ });
+ describe('on success', () => {
+ beforeEach(async () => {
+ await resolveFn(testData);
+ });
+ it('dispatches completeRequest', async () => {
+ expect(dispatch.mock.calls).toEqual([
+ [actions.requests.startRequest(requestKey)],
+ [actions.requests.completeRequest({ requestKey, response: testData })],
+ ]);
+ });
+ it('calls onSuccess with response', async () => {
+ expect(onSuccess).toHaveBeenCalledWith(testData);
+ expect(onFailure).not.toHaveBeenCalled();
+ });
+ });
+ describe('on failure', () => {
+ beforeEach(async () => {
+ await rejectFn(testData);
+ });
+ test('dispatches completeRequest', async () => {
+ expect(dispatch.mock.calls).toEqual([
+ [actions.requests.startRequest(requestKey)],
+ [actions.requests.failRequest({ requestKey, error: testData })],
+ ]);
+ });
+ test('calls onFailure with response', async () => {
+ expect(onFailure).toHaveBeenCalledWith(testData);
+ expect(onSuccess).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
+
+ const testNetworkRequestAction = ({
+ action,
+ args,
+ expectedData,
+ expectedString,
+ }) => {
+ let dispatchedAction;
+ beforeEach(() => {
+ action({ ...args, onSuccess, onFailure })(dispatch, () => testState);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches networkRequest', () => {
+ expect(dispatchedAction.networkRequest).not.toEqual(undefined);
+ });
+ test('forwards onSuccess and onFailure', () => {
+ expect(dispatchedAction.networkRequest.onSuccess).toEqual(onSuccess);
+ expect(dispatchedAction.networkRequest.onFailure).toEqual(onFailure);
+ });
+ test(expectedString, () => {
+ expect(dispatchedAction.networkRequest).toEqual({
+ ...expectedData,
+ onSuccess,
+ onFailure,
+ });
+ });
+ };
+ describe('network request actions', () => {
+ beforeEach(() => {
+ // eslint-disable-next-line no-import-assign
+ requests.networkRequest = jest.fn(args => ({ networkRequest: args }));
+ });
+ describe('fetchBlock', () => {
+ testNetworkRequestAction({
+ action: requests.fetchBlock,
+ args: fetchParams,
+ expectedString: 'with fetchBlock promise',
+ expectedData: {
+ ...fetchParams,
+ requestKey: RequestKeys.fetchBlock,
+ promise: api.fetchBlockById({
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ blockId: selectors.app.blockId(testState),
+ }),
+ },
+ });
+ });
+ describe('fetchUnit', () => {
+ testNetworkRequestAction({
+ action: requests.fetchUnit,
+ args: fetchParams,
+ expectedString: 'with fetchUnit promise',
+ expectedData: {
+ ...fetchParams,
+ requestKey: RequestKeys.fetchUnit,
+ promise: api.fetchByUnitId({
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ blockId: selectors.app.blockId(testState),
+ }),
+ },
+ });
+ });
+ describe('fetchStudioView', () => {
+ testNetworkRequestAction({
+ action: requests.fetchStudioView,
+ args: fetchParams,
+ expectedString: 'with fetchStudioView promise',
+ expectedData: {
+ ...fetchParams,
+ requestKey: RequestKeys.fetchStudioView,
+ promise: api.fetchStudioView({
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ blockId: selectors.app.blockId(testState),
+ }),
+ },
+ });
+ });
+ describe('fetchCourseDetails', () => {
+ testNetworkRequestAction({
+ action: requests.fetchCourseDetails,
+ args: fetchParams,
+ expectedString: 'with fetchCourseDetails promise',
+ expectedData: {
+ ...fetchParams,
+ requestKey: RequestKeys.fetchCourseDetails,
+ promise: api.fetchCourseDetails({
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ learningContextId: selectors.app.learningContextId(testState),
+ }),
+ },
+ });
+ });
+ describe('fetchImages', () => {
+ let fetchImages;
+ let loadImages;
+ let dispatchedAction;
+ const expectedArgs = {
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ learningContextId: selectors.app.learningContextId(testState),
+ };
+ beforeEach(() => {
+ fetchImages = jest.fn((args) => new Promise((resolve) => {
+ resolve({ data: { assets: { fetchImages: args } } });
+ }));
+ jest.spyOn(api, apiKeys.fetchImages).mockImplementationOnce(fetchImages);
+ loadImages = jest.spyOn(api, apiKeys.loadImages).mockImplementationOnce(() => ({}));
+ requests.fetchImages({ ...fetchParams, onSuccess, onFailure })(dispatch, () => testState);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches networkRequest', () => {
+ expect(dispatchedAction.networkRequest).not.toEqual(undefined);
+ });
+ test('forwards onSuccess and onFailure', () => {
+ expect(dispatchedAction.networkRequest.onSuccess).toEqual(onSuccess);
+ expect(dispatchedAction.networkRequest.onFailure).toEqual(onFailure);
+ });
+ test('api.fetchImages promise called with studioEndpointUrl and learningContextId', () => {
+ expect(fetchImages).toHaveBeenCalledWith(expectedArgs);
+ });
+ test('promise is chained with api.loadImages', () => {
+ expect(loadImages).toHaveBeenCalledWith({ fetchImages: expectedArgs });
+ });
+ });
+ describe('fetchVideos', () => {
+ const expectedArgs = {
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ learningContextId: selectors.app.learningContextId(testState),
+ };
+ let fetchVideos;
+ let dispatchedAction;
+ beforeEach(() => {
+ fetchVideos = jest.fn((args) => new Promise((resolve) => {
+ resolve({ data: { videos: { fetchVideos: args } } });
+ }));
+ jest.spyOn(api, apiKeys.fetchVideos).mockImplementationOnce(fetchVideos);
+ requests.fetchVideos({ ...fetchParams, onSuccess, onFailure })(dispatch, () => testState);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches networkRequest', () => {
+ expect(dispatchedAction.networkRequest).not.toEqual(undefined);
+ });
+ test('forwards onSuccess and onFailure', () => {
+ expect(dispatchedAction.networkRequest.onSuccess).toEqual(onSuccess);
+ expect(dispatchedAction.networkRequest.onFailure).toEqual(onFailure);
+ });
+ test('api.fetchVideos promise called with studioEndpointUrl and learningContextId', () => {
+ expect(fetchVideos).toHaveBeenCalledWith(expectedArgs);
+ });
+ });
+ describe('saveBlock', () => {
+ const content = 'SoME HtMl CoNtent As String';
+ testNetworkRequestAction({
+ action: requests.saveBlock,
+ args: { content, ...fetchParams },
+ expectedString: 'with saveBlock promise',
+ expectedData: {
+ ...fetchParams,
+ requestKey: RequestKeys.saveBlock,
+ promise: api.saveBlock({
+ blockId: selectors.app.blockId(testState),
+ blockType: selectors.app.blockType(testState),
+ learningContextId: selectors.app.learningContextId(testState),
+ content,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ title: selectors.app.blockTitle(testState),
+ }),
+ },
+ });
+ });
+ describe('uploadAsset', () => {
+ const asset = 'SoME iMage CoNtent As String';
+ testNetworkRequestAction({
+ action: requests.uploadAsset,
+ args: { asset, ...fetchParams },
+ expectedString: 'with uploadAsset promise',
+ expectedData: {
+ ...fetchParams,
+ requestKey: RequestKeys.uploadAsset,
+ promise: api.uploadAsset({
+ learningContextId: selectors.app.learningContextId(testState),
+ asset,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ }),
+ },
+ });
+ });
+ describe('uploadThumbnail', () => {
+ const thumbnail = 'SoME tHumbNAil CoNtent As String';
+ const videoId = 'SoME VidEOid CoNtent As String';
+ testNetworkRequestAction({
+ action: requests.uploadThumbnail,
+ args: { thumbnail, videoId, ...fetchParams },
+ expectedString: 'with uploadThumbnail promise',
+ expectedData: {
+ ...fetchParams,
+ requestKey: RequestKeys.uploadThumbnail,
+ promise: api.uploadThumbnail({
+ learningContextId: selectors.app.learningContextId(testState),
+ thumbnail,
+ videoId,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ }),
+ },
+ });
+ });
+ describe('deleteTranscript', () => {
+ const language = 'SoME laNGUage CoNtent As String';
+ const videoId = 'SoME VidEOid CoNtent As String';
+ testNetworkRequestAction({
+ action: requests.deleteTranscript,
+ args: { language, videoId, ...fetchParams },
+ expectedString: 'with deleteTranscript promise',
+ expectedData: {
+ ...fetchParams,
+ requestKey: RequestKeys.deleteTranscript,
+ promise: api.deleteTranscript({
+ blockId: selectors.app.blockId(testState),
+ language,
+ videoId,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ }),
+ },
+ });
+ });
+ describe('checkTranscriptsForImport', () => {
+ const youTubeId = 'SoME yOUtUbEiD As String';
+ const videoId = 'SoME VidEOid As String';
+ testNetworkRequestAction({
+ action: requests.checkTranscriptsForImport,
+ args: { youTubeId, videoId, ...fetchParams },
+ expectedString: 'with checkTranscriptsForImport promise',
+ expectedData: {
+ ...fetchParams,
+ requestKey: RequestKeys.checkTranscriptsForImport,
+ promise: api.checkTranscriptsForImport({
+ blockId: selectors.app.blockId(testState),
+ youTubeId,
+ videoId,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ }),
+ },
+ });
+ });
+ describe('importTranscript', () => {
+ const youTubeId = 'SoME yOUtUbEiD As String';
+ testNetworkRequestAction({
+ action: requests.importTranscript,
+ args: { youTubeId, ...fetchParams },
+ expectedString: 'with importTranscript promise',
+ expectedData: {
+ ...fetchParams,
+ requestKey: RequestKeys.importTranscript,
+ promise: api.importTranscript({
+ blockId: selectors.app.blockId(testState),
+ youTubeId,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ }),
+ },
+ });
+ });
+ describe('getTranscriptFile', () => {
+ const language = 'SoME laNGUage CoNtent As String';
+ const videoId = 'SoME VidEOid CoNtent As String';
+ testNetworkRequestAction({
+ action: requests.getTranscriptFile,
+ args: { language, videoId, ...fetchParams },
+ expectedString: 'with getTranscriptFile promise',
+ expectedData: {
+ ...fetchParams,
+ requestKey: RequestKeys.getTranscriptFile,
+ promise: api.getTranscript({
+ blockId: selectors.app.blockId(testState),
+ language,
+ videoId,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ }),
+ },
+ });
+ });
+ describe('updateTranscriptLanguage', () => {
+ const languageBeforeChange = 'SoME laNGUage CoNtent As String';
+ const newLanguageCode = 'SoME NEW laNGUage CoNtent As String';
+ const videoId = 'SoME VidEOid CoNtent As String';
+ testNetworkRequestAction({
+ action: requests.updateTranscriptLanguage,
+ args: {
+ languageBeforeChange,
+ newLanguageCode,
+ videoId,
+ ...fetchParams,
+ },
+ expectedString: 'with uploadTranscript promise',
+ expectedData: {
+ ...fetchParams,
+ requestKey: RequestKeys.updateTranscriptLanguage,
+ promise: api.uploadTranscript({
+ blockId: selectors.app.blockId(testState),
+ videoId,
+ language: languageBeforeChange,
+ newLanguage: newLanguageCode,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ }),
+ },
+ });
+ });
+
+ describe('uploadTranscript', () => {
+ const language = 'SoME laNGUage CoNtent As String';
+ const videoId = 'SoME VidEOid CoNtent As String';
+ const transcript = 'SoME tRANscRIPt CoNtent As String';
+ testNetworkRequestAction({
+ action: requests.uploadTranscript,
+ args: {
+ transcript,
+ language,
+ videoId,
+ ...fetchParams,
+ },
+ expectedString: 'with uploadTranscript promise',
+ expectedData: {
+ ...fetchParams,
+ requestKey: RequestKeys.uploadTranscript,
+ promise: api.uploadTranscript({
+ blockId: selectors.app.blockId(testState),
+ transcript,
+ videoId,
+ language,
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ }),
+ },
+ });
+ });
+ describe('fetchVideoFeatures', () => {
+ testNetworkRequestAction({
+ action: requests.fetchVideoFeatures,
+ args: { ...fetchParams },
+ expectedString: 'with fetchVideoFeatures promise',
+ expectedData: {
+ ...fetchParams,
+ requestKey: RequestKeys.fetchVideoFeatures,
+ promise: api.fetchVideoFeatures({
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ }),
+ },
+ });
+ });
+ describe('uploadVideo', () => {
+ const data = { files: [{ file_name: 'video.mp4', content_type: 'mp4' }] };
+ testNetworkRequestAction({
+ action: requests.uploadVideo,
+ args: { ...fetchParams, data },
+ expectedString: 'with uploadVideo promise',
+ expectedData: {
+ ...fetchParams,
+ requestKey: RequestKeys.uploadVideo,
+ promise: api.uploadVideo({
+ studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
+ learningContextId: selectors.app.learningContextId(testState),
+ data,
+ }),
+ },
+ });
+ });
+ });
+});
diff --git a/src/editors/data/redux/thunkActions/testUtils.js b/src/editors/data/redux/thunkActions/testUtils.js
new file mode 100644
index 0000000000..aaaa71a985
--- /dev/null
+++ b/src/editors/data/redux/thunkActions/testUtils.js
@@ -0,0 +1,54 @@
+/* eslint-disable import/no-extraneous-dependencies */
+/* istanbul ignore file */
+import configureMockStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+
+const mockStore = configureMockStore([thunk]);
+
+/** createTestFetcher(mockedMethod, thunkAction, args, onDispatch)
+ * Creates a testFetch method, which will test a given thunkAction of the form:
+ * ```
+ * const = () => (dispatch, getState) => {
+ * ...
+ * return .then().catch();
+ * ```
+ * The returned function will take a promise handler function, a list of expected actions
+ * to have been dispatched (objects only), and an optional verifyFn method to be called after
+ * the fetch has been completed.
+ *
+ * @param {fn} mockedMethod - already-mocked api method being exercised by the thunkAction.
+ * @param {fn} thunkAction - thunkAction to call/test
+ * @param {array} args - array of args to dispatch the thunkAction with
+ * @param {[fn]} onDispatch - optional function to be called after dispatch
+ *
+ * @return {fn} testFetch method
+ * @param {fn} resolveFn - promise handler of the form (resolve, reject) => {}.
+ * should return a call to resolve or reject with response data.
+ * @param {object[]} expectedActions - array of action objects expected to have been dispatched
+ * will be verified after the thunkAction resolves
+ * @param {[fn]} verifyFn - optional function to be called after dispatch
+ */
+export const createTestFetcher = (
+ mockedMethod,
+ thunkAction,
+ args,
+ onDispatch,
+) => (
+ resolveFn,
+ expectedActions,
+) => {
+ const store = mockStore({});
+ mockedMethod.mockReturnValue(new Promise(resolve => {
+ resolve(new Promise(resolveFn));
+ }));
+ return store.dispatch(thunkAction(...args)).then(() => {
+ onDispatch();
+ if (expectedActions !== undefined) {
+ expect(store.getActions()).toEqual(expectedActions);
+ }
+ });
+};
+
+export default {
+ createTestFetcher,
+};
diff --git a/src/editors/data/redux/thunkActions/video.js b/src/editors/data/redux/thunkActions/video.js
new file mode 100644
index 0000000000..352d989737
--- /dev/null
+++ b/src/editors/data/redux/thunkActions/video.js
@@ -0,0 +1,459 @@
+import _, { isEmpty } from 'lodash';
+import { removeItemOnce } from '../../../utils';
+import * as requests from './requests';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './video';
+import { valueFromDuration } from '../../../containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks';
+import { parseYoutubeId } from '../../services/cms/api';
+import { selectors as appSelectors } from '../app';
+import { actions as videoActions, selectors as videoSelectors } from '../video';
+
+// Similar to `import { actions, selectors } from '..';` but avoid circular imports:
+const actions = { video: videoActions };
+const selectors = { app: appSelectors, video: videoSelectors };
+
+export const loadVideoData = (selectedVideoId, selectedVideoUrl) => (dispatch, getState) => {
+ const state = getState();
+ const blockValueData = state.app.blockValue.data;
+ let rawVideoData = blockValueData.metadata ? blockValueData.metadata : {};
+ const rawVideos = Object.values(selectors.app.videos(state));
+ if (selectedVideoId !== undefined && selectedVideoId !== null) {
+ const selectedVideo = _.find(rawVideos, video => {
+ if (_.has(video, 'edx_video_id')) {
+ return video.edx_video_id === selectedVideoId;
+ }
+ return false;
+ });
+
+ if (selectedVideo !== undefined && selectedVideo !== null) {
+ rawVideoData = {
+ edx_video_id: selectedVideo.edx_video_id,
+ thumbnail: selectedVideo.course_video_image_url,
+ duration: selectedVideo.duration,
+ transcriptsFromSelected: selectedVideo.transcripts,
+ selectedVideoTranscriptUrls: selectedVideo.transcript_urls,
+ };
+ }
+ }
+
+ const courseData = state.app.courseDetails.data ? state.app.courseDetails.data : {};
+ let studioView = state.app.studioView?.data?.html;
+ if (state.app.blockId.startsWith('lb:')) {
+ studioView = state.app.studioView?.data?.content;
+ }
+
+ const {
+ videoId,
+ videoUrl,
+ fallbackVideos,
+ } = module.determineVideoSources({
+ edxVideoId: rawVideoData.edx_video_id,
+ youtubeId: rawVideoData.youtube_id_1_0,
+ html5Sources: rawVideoData.html5_sources,
+ });
+
+ // Use the selected video url first
+ const videoSourceUrl = selectedVideoUrl != null ? selectedVideoUrl : videoUrl;
+ const [licenseType, licenseOptions] = module.parseLicense({ licenseData: studioView, level: 'block' });
+ // eslint-disable-next-line no-console
+ console.log(licenseType);
+ const transcripts = rawVideoData.transcriptsFromSelected ? rawVideoData.transcriptsFromSelected
+ : module.parseTranscripts({ transcriptsData: studioView });
+
+ const [courseLicenseType, courseLicenseDetails] = module.parseLicense({
+ licenseData: courseData.license,
+ level: 'course',
+ });
+ const allowVideoSharing = module.parseVideoSharingSetting({
+ courseSetting: blockValueData?.video_sharing_options,
+ blockSetting: rawVideoData.public_access,
+ });
+ dispatch(actions.video.load({
+ videoSource: videoSourceUrl || '',
+ videoId,
+ fallbackVideos,
+ allowVideoDownloads: rawVideoData.download_video,
+ allowVideoSharing,
+ videoSharingLearnMoreLink: blockValueData?.video_sharing_doc_url,
+ videoSharingEnabledForCourse: blockValueData?.video_sharing_enabled,
+ transcripts,
+ selectedVideoTranscriptUrls: rawVideoData.selectedVideoTranscriptUrls,
+ allowTranscriptDownloads: rawVideoData.download_track,
+ showTranscriptByDefault: rawVideoData.show_captions,
+ duration: { // TODO duration is not always sent so they should be calculated.
+ startTime: valueFromDuration(rawVideoData.start_time || '00:00:00'),
+ stopTime: valueFromDuration(rawVideoData.end_time || '00:00:00'),
+ total: rawVideoData.duration || 0, // TODO can we get total duration? if not, probably dropping from widget
+ },
+ handout: rawVideoData.handout,
+ licenseType,
+ licenseDetails: {
+ attribution: licenseOptions.by,
+ noncommercial: licenseOptions.nc,
+ noDerivatives: licenseOptions.nd,
+ shareAlike: licenseOptions.sa,
+ },
+ courseLicenseType,
+ courseLicenseDetails: {
+ attribution: courseLicenseDetails.by,
+ noncommercial: courseLicenseDetails.nc,
+ noDerivatives: courseLicenseDetails.nd,
+ shareAlike: courseLicenseDetails.sa,
+ },
+ thumbnail: rawVideoData.thumbnail,
+ }));
+ dispatch(requests.fetchVideoFeatures({
+ onSuccess: (response) => dispatch(actions.video.updateField({
+ allowThumbnailUpload: response.data.allowThumbnailUpload,
+ videoSharingEnabledForAll: response.data.videoSharingEnabled,
+ })),
+ }));
+ const youTubeId = parseYoutubeId(videoSourceUrl);
+ if (youTubeId) {
+ dispatch(requests.checkTranscriptsForImport({
+ videoId,
+ youTubeId,
+ onSuccess: (response) => {
+ if (response.data.command === 'import') {
+ dispatch(actions.video.updateField({
+ allowTranscriptImport: true,
+ }));
+ }
+ },
+ }));
+ }
+};
+
+export const determineVideoSources = ({
+ edxVideoId,
+ youtubeId,
+ html5Sources,
+}) => {
+ const youtubeUrl = `https://youtu.be/${youtubeId}`;
+ let videoUrl;
+ let fallbackVideos;
+ if (youtubeId) {
+ [videoUrl, fallbackVideos] = [youtubeUrl, html5Sources];
+ } else if (Array.isArray(html5Sources) && html5Sources[0]) {
+ [videoUrl, fallbackVideos] = [html5Sources[0], html5Sources.slice(1)];
+ }
+ return {
+ videoId: edxVideoId || '',
+ videoUrl: videoUrl || '',
+ fallbackVideos: fallbackVideos || [],
+ };
+};
+
+export const parseVideoSharingSetting = ({ courseSetting, blockSetting }) => {
+ switch (courseSetting) {
+ case 'all-on':
+ return { level: 'course', value: true };
+ case 'all-off':
+ return { level: 'course', value: false };
+ case 'per-video':
+ return { level: 'block', value: blockSetting };
+ default:
+ return { level: 'block', value: blockSetting };
+ }
+};
+
+export const parseTranscripts = ({ transcriptsData }) => {
+ if (!transcriptsData) {
+ return [];
+ }
+ const cleanedStr = transcriptsData.replace(/"/g, '"');
+ const startString = '"transcripts": ';
+ const endString = ', "youtube_id_0_75": ';
+ const transcriptsJson = cleanedStr.substring(
+ cleanedStr.indexOf(startString) + startString.length,
+ cleanedStr.indexOf(endString),
+ );
+ // const transcriptsObj = JSON.parse(transcriptsJson);
+ try {
+ const transcriptsObj = JSON.parse(transcriptsJson);
+ return Object.keys(transcriptsObj.value);
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ // eslint-disable-next-line no-console
+ console.error('Invalid JSON:', error.message);
+ } else {
+ throw error;
+ }
+ return [];
+ }
+};
+
+// partially copied from frontend-app-learning/src/courseware/course/course-license/CourseLicense.jsx
+export const parseLicense = ({ licenseData, level }) => {
+ if (!licenseData) {
+ return [null, {}];
+ }
+ let license = licenseData;
+ if (level === 'block') {
+ const metadataArr = licenseData.split('data-metadata');
+ metadataArr.forEach(arr => {
+ const parsedStr = arr.replace(/"/g, '"');
+ if (parsedStr.includes('license')) {
+ license = parsedStr.substring(parsedStr.indexOf('"value"'), parsedStr.indexOf(', "type"')).replace(/"value": |"/g, '');
+ }
+ });
+ }
+ if (!license || license.includes('null')) {
+ return [null, {}];
+ }
+ if (license === 'all-rights-reserved') {
+ // no options, so the entire thing is the license type
+ return [license, {}];
+ }
+ // Search for a colon character denoting the end
+ // of the license type and start of the options
+ const colonIndex = license.lastIndexOf(':');
+ // Split the license on the colon
+ const licenseType = license.slice(0, colonIndex).trim();
+ const optionStr = license.slice(colonIndex + 1).trim();
+ const options = {};
+ let version = '';
+
+ // Set the defaultVersion to 4.0
+ const defaultVersion = '4.0';
+ optionStr.split(' ').forEach(option => {
+ // Split the option into key and value
+ // Default the value to `true` if no value
+ let key = '';
+ let value = '';
+ if (option.indexOf('=') !== -1) {
+ [key, value] = option.split('=');
+ } else {
+ key = option;
+ value = true;
+ }
+
+ // Check for version
+ if (key === 'ver') {
+ version = value;
+ } else {
+ // Set the option key to lowercase to make
+ // it easier to query
+ options[key.toLowerCase()] = value;
+ }
+ });
+
+ // Set the version to whatever was included,
+ // using `defaultVersion` as a fallback if unset
+ version = version || defaultVersion;
+
+ return [licenseType, options, version];
+};
+
+export const saveVideoData = () => (dispatch, getState) => {
+ const state = getState();
+ return selectors.video.videoSettings(state);
+};
+
+export const uploadThumbnail = ({ thumbnail, emptyCanvas }) => (dispatch, getState) => {
+ const state = getState();
+ const { videoId } = state.video;
+ const { studioEndpointUrl } = state.app;
+ dispatch(requests.uploadThumbnail({
+ thumbnail,
+ videoId,
+ onSuccess: (response) => {
+ let thumbnailUrl;
+ if (response.data.image_url.startsWith('/')) {
+ // in local environments, image_url is a relative path
+ thumbnailUrl = studioEndpointUrl + response.data.image_url;
+ } else {
+ // in stage and production, image_url is an absolute path to the image
+ thumbnailUrl = response.data.image_url;
+ }
+ if (!emptyCanvas) {
+ dispatch(actions.video.updateField({
+ thumbnail: thumbnailUrl,
+ }));
+ }
+ },
+ // eslint-disable-next-line no-console
+ onFailure: (e) => console.log({ UploadFailure: e }, 'Resampling thumbnail upload'),
+ }));
+};
+
+// Handout Thunks:
+
+export const uploadHandout = ({ file }) => (dispatch) => {
+ dispatch(requests.uploadAsset({
+ asset: file,
+ onSuccess: (response) => {
+ const handout = response.data.asset.url;
+ dispatch(actions.video.updateField({ handout }));
+ },
+ }));
+};
+
+// Transcript Thunks:
+
+export const importTranscript = () => (dispatch, getState) => {
+ const state = getState();
+ const { transcripts, videoSource } = state.video;
+ // Remove the placeholder '' from the unset language from the list of transcripts.
+ const transcriptsPlaceholderRemoved = isEmpty(transcripts) ? transcripts : removeItemOnce(transcripts, '');
+
+ dispatch(requests.importTranscript({
+ youTubeId: parseYoutubeId(videoSource),
+ onSuccess: (response) => {
+ dispatch(actions.video.updateField({
+ transcripts: [
+ ...transcriptsPlaceholderRemoved,
+ 'en'],
+ }));
+
+ if (selectors.video.videoId(state) === '') {
+ dispatch(actions.video.updateField({
+ videoId: response.data.edx_video_id,
+ }));
+ }
+ },
+ }));
+};
+
+export const uploadTranscript = ({ language, file }) => (dispatch, getState) => {
+ const state = getState();
+ const { transcripts, videoId } = state.video;
+ // Remove the placeholder '' from the unset language from the list of transcripts.
+ const transcriptsPlaceholderRemoved = isEmpty(transcripts) ? transcripts : removeItemOnce(transcripts, '');
+ dispatch(requests.uploadTranscript({
+ language,
+ videoId,
+ transcript: file,
+ onSuccess: (response) => {
+ // if we aren't replacing, add the language to the redux store.
+ if (!transcriptsPlaceholderRemoved.includes(language)) {
+ dispatch(actions.video.updateField({
+ transcripts: [
+ ...transcriptsPlaceholderRemoved,
+ language],
+ }));
+ }
+
+ if (selectors.video.videoId(state) === '') {
+ dispatch(actions.video.updateField({
+ videoId: response.data.edx_video_id,
+ }));
+ }
+ },
+ }));
+};
+
+export const deleteTranscript = ({ language }) => (dispatch, getState) => {
+ const state = getState();
+ const { transcripts, videoId } = state.video;
+ dispatch(requests.deleteTranscript({
+ language,
+ videoId,
+ onSuccess: () => {
+ const updatedTranscripts = transcripts.filter((langCode) => langCode !== language);
+ dispatch(actions.video.updateField({ transcripts: updatedTranscripts }));
+ },
+ }));
+};
+
+export const updateTranscriptLanguage = ({ newLanguageCode, languageBeforeChange }) => (dispatch, getState) => {
+ const state = getState();
+ const { video: { transcripts, videoId } } = state;
+ selectors.video.getTranscriptDownloadUrl(state);
+ dispatch(requests.getTranscriptFile({
+ videoId,
+ language: languageBeforeChange,
+ onSuccess: (response) => {
+ dispatch(requests.updateTranscriptLanguage({
+ languageBeforeChange,
+ file: new File([new Blob([response.data], { type: 'text/plain' })], `${videoId}_${newLanguageCode}.srt`, { type: 'text/plain' }),
+ newLanguageCode,
+ videoId,
+ onSuccess: () => {
+ const newTranscripts = transcripts
+ .filter(transcript => transcript !== languageBeforeChange);
+ newTranscripts.push(newLanguageCode);
+ dispatch(actions.video.updateField({ transcripts: newTranscripts }));
+ },
+ }));
+ },
+ }));
+};
+
+export const replaceTranscript = ({ newFile, newFilename, language }) => (dispatch, getState) => {
+ const state = getState();
+ const { videoId } = state.video;
+ dispatch(requests.deleteTranscript({
+ language,
+ videoId,
+ onSuccess: () => {
+ dispatch(uploadTranscript({ language, file: newFile, filename: newFilename }));
+ },
+ }));
+};
+
+export const uploadVideo = ({ supportedFiles, setLoadSpinner, postUploadRedirect }) => (dispatch) => {
+ const data = { files: [] };
+ setLoadSpinner(true);
+ supportedFiles.forEach((file) => {
+ const fileData = file.get('file');
+ data.files.push({
+ file_name: fileData.name,
+ content_type: fileData.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.get('file').name === fileName);
+ if (!uploadFile) {
+ // eslint-disable-next-line no-console
+ console.error(`Could not find file object with name "${fileName}" in supportedFiles array.`);
+ return;
+ }
+ const file = uploadFile.get('file');
+ await fetch(uploadUrl, {
+ method: 'PUT',
+ headers: {
+ 'Content-Disposition': `attachment; filename="${file.name}"`,
+ 'Content-Type': file.type,
+ },
+ multipart: false,
+ body: file,
+ })
+ .then((resp) => {
+ if (!resp.ok) {
+ throw new Error('Failed to connect with server');
+ }
+ postUploadRedirect(edxVideoId);
+ })
+ // eslint-disable-next-line no-console
+ .catch((error) => console.error('Error uploading file:', error));
+ }));
+ setLoadSpinner(false);
+ },
+ }));
+};
+
+export default {
+ loadVideoData,
+ determineVideoSources,
+ parseLicense,
+ saveVideoData,
+ uploadThumbnail,
+ importTranscript,
+ uploadTranscript,
+ deleteTranscript,
+ updateTranscriptLanguage,
+ replaceTranscript,
+ uploadHandout,
+ uploadVideo,
+};
diff --git a/src/editors/data/redux/thunkActions/video.test.js b/src/editors/data/redux/thunkActions/video.test.js
new file mode 100644
index 0000000000..75a668d0bc
--- /dev/null
+++ b/src/editors/data/redux/thunkActions/video.test.js
@@ -0,0 +1,802 @@
+import { actions } from '..';
+import keyStore from '../../../utils/keyStore';
+import * as thunkActions from './video';
+
+jest.mock('../video', () => ({
+ ...jest.requireActual('../video'),
+ actions: {
+ load: (args) => ({ load: args }),
+ updateField: (args) => ({ updateField: args }),
+ },
+ selectors: {
+ videoId: (state) => ({ videoId: state }),
+ videoSettings: (state) => ({ videoSettings: state }),
+ getTranscriptDownloadUrl: (state) => ({ getTranscriptDownloadUrl: state }),
+ },
+}));
+jest.mock('../app', () => ({
+ ...jest.requireActual('../app'),
+ selectors: {
+ courseDetails: (state) => ({ courseDetails: state }),
+ videos: (state) => ({ videos: state.app.videos }),
+ },
+}));
+jest.mock('./requests', () => ({
+ uploadAsset: (args) => ({ uploadAsset: args }),
+ allowThumbnailUpload: (args) => ({ allowThumbnailUpload: args }),
+ uploadThumbnail: (args) => ({ uploadThumbnail: args }),
+ deleteTranscript: (args) => ({ deleteTranscript: args }),
+ uploadTranscript: (args) => ({ uploadTranscript: args }),
+ getTranscriptFile: (args) => ({ getTranscriptFile: args }),
+ updateTranscriptLanguage: (args) => ({ updateTranscriptLanguage: args }),
+ checkTranscriptsForImport: (args) => ({ checkTranscriptsForImport: args }),
+ importTranscript: (args) => ({ importTranscript: args }),
+ fetchVideoFeatures: (args) => ({ fetchVideoFeatures: args }),
+ uploadVideo: (args) => ({ uploadVideo: args }),
+}));
+
+jest.mock('../../../utils', () => ({
+ ...jest.requireActual('../../../utils'),
+ removeItemOnce: (args) => (args),
+}));
+
+jest.mock('../../services/cms/api', () => ({
+ parseYoutubeId: (args) => (args),
+}));
+
+const thunkActionsKeys = keyStore(thunkActions);
+
+const mockLanguage = 'en';
+const mockFile = 'soMEtRANscRipT';
+const mockFilename = 'soMEtRANscRipT.srt';
+const mockThumbnail = 'sOMefILE';
+const mockThumbnailResponse = { data: { image_url: 'soMEimAGEUrL' } };
+const thumbnailUrl = 'soMEimAGEUrL';
+const mockAllowTranscriptImport = { data: { command: 'import' } };
+const mockVideoFeatures = {
+ data: {
+ allowThumbnailUpload: 'soMEbOolEAn',
+ videoSharingEnabled: 'soMEbOolEAn',
+ },
+};
+const mockSelectedVideoId = 'ThisIsAVideoId';
+const mockSelectedVideoUrl = 'ThisIsAYoutubeUrl';
+
+const testMetadata = {
+ download_track: 'dOWNlOAdTraCK',
+ download_video: 'downLoaDViDEo',
+ edx_video_id: 'soMEvIDEo',
+ end_time: 0,
+ handout: 'hANdoUT',
+ html5_sources: [],
+ license: 'liCENse',
+ show_captions: 'shOWcapTIONS',
+ start_time: 0,
+ transcripts: ['do', 're', 'mi'],
+ thumbnail: 'thuMBNaIl',
+};
+const videoSharingData = {
+ video_sharing_doc_url: 'SomEUrL.Com',
+ video_sharing_options: 'OpTIOns',
+};
+const testState = {
+ transcripts: ['la'],
+ thumbnail: 'sOMefILE',
+ originalThumbnail: null,
+ videoId: 'soMEvIDEo',
+};
+const testVideosState = {
+ edx_video_id: mockSelectedVideoId,
+ thumbnail: 'thumbnail',
+ course_video_image_url: 'course_video_image_url',
+ duration: 60,
+ transcripts: ['es'],
+ transcript_urls: { es: 'url' },
+};
+const testUpload = { transcripts: ['la', 'en'] };
+const testReplaceUpload = {
+ file: mockFile,
+ language: mockLanguage,
+ filename: mockFilename,
+};
+
+describe('video thunkActions', () => {
+ let dispatch;
+ let getState;
+ let dispatchedAction;
+ beforeEach(() => {
+ dispatch = jest.fn((action) => ({ dispatch: action }));
+ getState = jest.fn(() => ({
+ app: {
+ blockId: 'soMEBloCk',
+ blockValue: { data: { metadata: { ...testMetadata }, ...videoSharingData } },
+ studioEndpointUrl: 'soMEeNDPoiNT',
+ courseDetails: { data: { license: null } },
+ studioView: { data: { html: 'sOMeHTml' } },
+ },
+ video: testState,
+ }));
+ });
+ describe('loadVideoData', () => {
+ let dispatchedLoad;
+ let dispatchedAction1;
+ let dispatchedAction2;
+ beforeEach(() => {
+ jest.spyOn(thunkActions, thunkActionsKeys.determineVideoSources).mockReturnValue({
+ videoUrl: 'videOsOurce',
+ videoId: 'videOiD',
+ fallbackVideos: 'fALLbACKvIDeos',
+ });
+ jest.spyOn(thunkActions, thunkActionsKeys.parseVideoSharingSetting).mockReturnValue({
+ level: 'course',
+ value: true,
+ });
+ jest.spyOn(thunkActions, thunkActionsKeys.parseLicense).mockReturnValue([
+ 'liCENSEtyPe',
+ {
+ by: true,
+ nc: true,
+ nd: true,
+ sa: false,
+ },
+ ]);
+ jest.spyOn(thunkActions, thunkActionsKeys.parseTranscripts).mockReturnValue(
+ testMetadata.transcripts,
+ );
+ });
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+ it('dispatches fetchVideoFeatures action', () => {
+ thunkActions.loadVideoData()(dispatch, getState);
+ [
+ [dispatchedLoad],
+ [dispatchedAction1],
+ [dispatchedAction2],
+ ] = dispatch.mock.calls;
+ expect(dispatchedLoad).not.toEqual(undefined);
+ expect(dispatchedAction1.fetchVideoFeatures).not.toEqual(undefined);
+ });
+ it('dispatches checkTranscriptsForImport action', () => {
+ thunkActions.loadVideoData()(dispatch, getState);
+ [
+ [dispatchedLoad],
+ [dispatchedAction1],
+ [dispatchedAction2],
+ ] = dispatch.mock.calls;
+ expect(dispatchedLoad).not.toEqual(undefined);
+ expect(dispatchedAction2.checkTranscriptsForImport).not.toEqual(undefined);
+ });
+ it('dispatches actions.video.load', () => {
+ thunkActions.loadVideoData()(dispatch, getState);
+ [
+ [dispatchedLoad],
+ [dispatchedAction1],
+ [dispatchedAction2],
+ ] = dispatch.mock.calls;
+ expect(dispatchedLoad.load).toEqual({
+ videoSource: 'videOsOurce',
+ videoId: 'videOiD',
+ fallbackVideos: 'fALLbACKvIDeos',
+ allowVideoDownloads: testMetadata.download_video,
+ allowVideoSharing: {
+ level: 'course',
+ value: true,
+ },
+ videoSharingLearnMoreLink: videoSharingData.video_sharing_doc_url,
+ videoSharingEnableForCourse: videoSharingData.video_sharing_enabled,
+ transcripts: testMetadata.transcripts,
+ allowTranscriptDownloads: testMetadata.download_track,
+ showTranscriptByDefault: testMetadata.show_captions,
+ duration: {
+ startTime: testMetadata.start_time,
+ stopTime: testMetadata.end_time,
+ total: 0,
+ },
+ handout: testMetadata.handout,
+ licenseType: 'liCENSEtyPe',
+ licenseDetails: {
+ attribution: true,
+ noncommercial: true,
+ noDerivatives: true,
+ shareAlike: false,
+ },
+ courseLicenseType: 'liCENSEtyPe',
+ courseLicenseDetails: {
+ attribution: true,
+ noncommercial: true,
+ noDerivatives: true,
+ shareAlike: false,
+ },
+ thumbnail: testMetadata.thumbnail,
+ });
+ });
+ it('dispatches actions.video.load with selectedVideoId', () => {
+ getState = jest.fn(() => ({
+ app: {
+ blockId: 'soMEBloCk',
+ studioEndpointUrl: 'soMEeNDPoiNT',
+ blockValue: { data: { metadata: {} } },
+ courseDetails: { data: { license: null } },
+ studioView: { data: { html: 'sOMeHTml' } },
+ videos: testVideosState,
+ },
+ }));
+ thunkActions.loadVideoData(mockSelectedVideoId, null)(dispatch, getState);
+ [
+ [dispatchedLoad],
+ [dispatchedAction1],
+ [dispatchedAction2],
+ ] = dispatch.mock.calls;
+ expect(dispatchedLoad.load).toEqual({
+ videoSource: 'videOsOurce',
+ videoId: 'videOiD',
+ fallbackVideos: 'fALLbACKvIDeos',
+ allowVideoDownloads: undefined,
+ transcripts: testVideosState.transcripts,
+ selectedVideoTranscriptUrls: testVideosState.transcript_urls,
+ allowTranscriptDownloads: undefined,
+ allowVideoSharing: {
+ level: 'course',
+ value: true,
+ },
+ showTranscriptByDefault: undefined,
+ duration: {
+ startTime: testMetadata.start_time,
+ stopTime: 0,
+ total: testVideosState.duration,
+ },
+ handout: undefined,
+ licenseType: 'liCENSEtyPe',
+ licenseDetails: {
+ attribution: true,
+ noncommercial: true,
+ noDerivatives: true,
+ shareAlike: false,
+ },
+ videoSharingEnabledForCourse: undefined,
+ videoSharingLearnMoreLink: undefined,
+ courseLicenseType: 'liCENSEtyPe',
+ courseLicenseDetails: {
+ attribution: true,
+ noncommercial: true,
+ noDerivatives: true,
+ shareAlike: false,
+ },
+ thumbnail: testVideosState.course_video_image_url,
+ });
+ });
+ it('dispatches actions.video.load with different selectedVideoId', () => {
+ getState = jest.fn(() => ({
+ app: {
+ blockId: 'lb:soMEBloCk',
+ studioEndpointUrl: 'soMEeNDPoiNT',
+ blockValue: { data: { metadata: {} } },
+ courseDetails: { data: { license: null } },
+ studioView: { data: { content: 'sOMeHTml' } },
+ videos: testVideosState,
+ },
+ }));
+ thunkActions.loadVideoData('ThisIsAVideoId2', null)(dispatch, getState);
+ [
+ [dispatchedLoad],
+ [dispatchedAction1],
+ [dispatchedAction2],
+ ] = dispatch.mock.calls;
+ expect(dispatchedLoad.load).toEqual({
+ videoSource: 'videOsOurce',
+ videoId: 'videOiD',
+ fallbackVideos: 'fALLbACKvIDeos',
+ allowVideoDownloads: undefined,
+ transcripts: testMetadata.transcripts,
+ selectedVideoTranscriptUrls: testMetadata.transcript_urls,
+ allowTranscriptDownloads: undefined,
+ allowVideoSharing: {
+ level: 'course',
+ value: true,
+ },
+ showTranscriptByDefault: undefined,
+ duration: {
+ startTime: testMetadata.start_time,
+ stopTime: 0,
+ total: 0,
+ },
+ handout: undefined,
+ licenseType: 'liCENSEtyPe',
+ licenseDetails: {
+ attribution: true,
+ noncommercial: true,
+ noDerivatives: true,
+ shareAlike: false,
+ },
+ videoSharingEnabledForCourse: undefined,
+ videoSharingLearnMoreLink: undefined,
+ courseLicenseType: 'liCENSEtyPe',
+ courseLicenseDetails: {
+ attribution: true,
+ noncommercial: true,
+ noDerivatives: true,
+ shareAlike: false,
+ },
+ thumbnail: undefined,
+ });
+ });
+ it('dispatches actions.video.load with selectedVideoUrl', () => {
+ thunkActions.loadVideoData(null, mockSelectedVideoUrl)(dispatch, getState);
+ [
+ [dispatchedLoad],
+ [dispatchedAction1],
+ [dispatchedAction2],
+ ] = dispatch.mock.calls;
+ expect(dispatchedLoad.load).toEqual({
+ videoSource: mockSelectedVideoUrl,
+ videoId: 'videOiD',
+ fallbackVideos: 'fALLbACKvIDeos',
+ allowVideoDownloads: testMetadata.download_video,
+ transcripts: testMetadata.transcripts,
+ allowTranscriptDownloads: testMetadata.download_track,
+ showTranscriptByDefault: testMetadata.show_captions,
+ duration: {
+ startTime: testMetadata.start_time,
+ stopTime: testMetadata.end_time,
+ total: 0,
+ },
+ allowVideoSharing: {
+ level: 'course',
+ value: true,
+ },
+ handout: testMetadata.handout,
+ licenseType: 'liCENSEtyPe',
+ licenseDetails: {
+ attribution: true,
+ noncommercial: true,
+ noDerivatives: true,
+ shareAlike: false,
+ },
+ selectedVideoTranscriptUrls: undefined,
+ videoSharingEnabledForCourse: undefined,
+ videoSharingLearnMoreLink: 'SomEUrL.Com',
+ courseLicenseType: 'liCENSEtyPe',
+ courseLicenseDetails: {
+ attribution: true,
+ noncommercial: true,
+ noDerivatives: true,
+ shareAlike: false,
+ },
+ thumbnail: testMetadata.thumbnail,
+ });
+ });
+ it('dispatches actions.video.updateField on success', () => {
+ thunkActions.loadVideoData()(dispatch, getState);
+ [
+ [dispatchedLoad],
+ [dispatchedAction1],
+ [dispatchedAction2],
+ ] = dispatch.mock.calls;
+ dispatch.mockClear();
+ dispatchedAction1.fetchVideoFeatures.onSuccess(mockVideoFeatures);
+ expect(dispatch).toHaveBeenCalledWith(actions.video.updateField({
+ allowThumbnailUpload: mockVideoFeatures.data.allowThumbnailUpload,
+ videoSharingEnabledForAll: mockVideoFeatures.data.videoSharingEnabled,
+ }));
+ dispatch.mockClear();
+ dispatchedAction2.checkTranscriptsForImport.onSuccess(mockAllowTranscriptImport);
+ expect(dispatch).toHaveBeenCalledWith(actions.video.updateField({
+ allowTranscriptImport: true,
+ }));
+ });
+ });
+ describe('determineVideoSources', () => {
+ const edxVideoId = 'EDxviDEoiD';
+ const youtubeId = 'yOuTuBEiD';
+ const youtubeUrl = `https://youtu.be/${youtubeId}`;
+ const html5Sources = ['htmLOne', 'hTMlTwo', 'htMLthrEE'];
+ describe('when edx id, youtube id and source values are null', () => {
+ it('returns empty strings for ids and an empty array for sources', () => {
+ expect(thunkActions.determineVideoSources({
+ edxVideoId: null,
+ youtubeId: null,
+ html5Sources: null,
+ })).toEqual({
+ videoUrl: '',
+ videoId: '',
+ fallbackVideos: [],
+ });
+ });
+ });
+ describe('when there is an edx video id, youtube id and html5 sources', () => {
+ it('returns all three with the youtube id wrapped in url', () => {
+ expect(thunkActions.determineVideoSources({
+ edxVideoId,
+ youtubeId,
+ html5Sources,
+ })).toEqual({
+ videoUrl: youtubeUrl,
+ videoId: edxVideoId,
+ fallbackVideos: html5Sources,
+ });
+ });
+ });
+ describe('when there is only an edx video id', () => {
+ it('returns the edx video id for video source', () => {
+ expect(thunkActions.determineVideoSources({
+ edxVideoId,
+ youtubeId: '',
+ html5Sources: '',
+ })).toEqual({
+ videoUrl: '',
+ videoId: edxVideoId,
+ fallbackVideos: [],
+ });
+ });
+ });
+ describe('when there is no edx video id', () => {
+ it('returns the youtube url for video source and html5 sources for fallback videos', () => {
+ expect(thunkActions.determineVideoSources({
+ edxVideoId: '',
+ youtubeId,
+ html5Sources,
+ })).toEqual({
+ videoUrl: youtubeUrl,
+ videoId: '',
+ fallbackVideos: html5Sources,
+ });
+ });
+ });
+ describe('when there is no edx video id and no youtube id', () => {
+ it('returns the first html5 source for video url and the rest for fallback videos', () => {
+ expect(thunkActions.determineVideoSources({
+ edxVideoId: '',
+ youtubeId: '',
+ html5Sources,
+ })).toEqual({
+ videoUrl: 'htmLOne',
+ videoId: '',
+ fallbackVideos: ['hTMlTwo', 'htMLthrEE'],
+ });
+ });
+ it('returns the html5 source for video source and an array with 2 empty values for fallback videos', () => {
+ expect(thunkActions.determineVideoSources({
+ edxVideoId: '',
+ youtubeId: '',
+ html5Sources: ['htmlOne'],
+ })).toEqual({
+ videoUrl: 'htmlOne',
+ videoId: '',
+ fallbackVideos: [],
+ });
+ });
+ });
+ describe('when there is no edx video id, no youtube id and no html5 sources', () => {
+ it('returns an empty string for video source and an array with 2 empty values for fallback videos', () => {
+ expect(thunkActions.determineVideoSources({
+ edxVideoId: '',
+ youtubeId: '',
+ html5Sources: [],
+ })).toEqual({
+ videoUrl: '',
+ videoId: '',
+ fallbackVideos: [],
+ });
+ });
+ });
+ });
+ describe('parseTranscripts', () => {
+ const testStudioViewDataWithTranscripts = 'de descarga debajo del video.", "value": "", "type": "Generic", "options": []}, "transcripts": {"explicitly_set": false, "default_value": {}, "field_name": "transcripts", "display_name": "Idiomas de transcripci\\u00f3n", "help": "A\\u00f1ada transcripciones en diferentes idiomas. Haga clic a continuaci\\u00f3n para especificar un idioma y subir un archivo .srt de transcripci\\u00f3n para dicho idioma.", "value": {"aa": "non_existent_dummy_file_name", "ab": "non_existent_dummy_file_name", "ba": "non_existent_dummy_file_name", "en": "external video-en.txt"}, "type": "VideoTranslations", "options": [], "custom": true, "languages": [{"label": "Abkhazian", "code": "ab"}], "urlRoot": "/xblock/block-v1:GalileoX+XS_Mate001+3T2022+type@video+block@20bc09f5522d430f8e43c2bc377b348c/handler/studio_transcript/translation"}, "youtube_id_0_75": {';
+ const testStudioViewData = 'de descarga debajo del video.", "value": "", "type": "Generic", "options": []}, "transcripts": {"explicitly_set": false, "default_value": {}, "field_name": "transcripts", "display_name": "Idiomas de transcripci\\u00f3n", "help": "A\\u00f1ada transcripciones en diferentes idiomas. Haga clic a continuaci\\u00f3n para especificar un idioma y subir un archivo .srt de transcripci\\u00f3n para dicho idioma.", "value": {}, "type": "VideoTranslations", "options": [], "custom": true, "languages": [{"label": "Abkhazian", "code": "ab"}], "urlRoot": "/xblock/block-v1:GalileoX+XS_Mate001+3T2022+type@video+block@20bc09f5522d430f8e43c2bc377b348c/handler/studio_transcript/translation"}, "youtube_id_0_75": {';
+ const testBadStudioViewData = 'tHiSiSaBAdDaTa';
+ it('returns an array of languages given a JSON string', () => {
+ expect(thunkActions.parseTranscripts({
+ transcriptsData: testStudioViewDataWithTranscripts,
+ })).toEqual(['aa', 'ab', 'ba', 'en']);
+ });
+ it('returns an empty array when there are no transcripts', () => {
+ expect(thunkActions.parseTranscripts({
+ transcriptsData: testStudioViewData,
+ })).toEqual([]);
+ });
+ it('returns an empty array given an unparsable JSON string', () => {
+ expect(thunkActions.parseTranscripts({
+ transcriptsData: testBadStudioViewData,
+ })).toEqual([]);
+ });
+ });
+ describe('parseLicense', () => {
+ const emptyLicenseData = null;
+ const noLicense = 'sOMeHTml data-metadata "license" "value"= null, "type"';
+ it('returns expected values for a license with no course license', () => {
+ expect(thunkActions.parseLicense({
+ licenseData: emptyLicenseData,
+ level: 'sOMElevEL',
+ })).toEqual([
+ null,
+ {},
+ ]);
+ });
+ it('returns expected values for a license with no block license', () => {
+ expect(thunkActions.parseLicense({
+ licenseData: noLicense,
+ level: 'block',
+ })).toEqual([
+ null,
+ {},
+ ]);
+ });
+ it('returns expected values for a license with all rights reserved', () => {
+ const license = 'sOMeHTml data-metadata "license" "value": "all-rights-reserved", "type"';
+ expect(thunkActions.parseLicense({
+ licenseData: license,
+ level: 'block',
+ })).toEqual([
+ 'all-rights-reserved',
+ {},
+ ]);
+ });
+ it('returns expected type and options for creative commons', () => {
+ const license = 'sOMeHTml data-metadata "license" "value": "creative-commons: ver=4.0 BY NC ND", "type"';
+ expect(thunkActions.parseLicense({
+ licenseData: license,
+ level: 'block',
+ })).toEqual([
+ 'creative-commons',
+ {
+ by: true,
+ nc: true,
+ nd: true,
+ },
+ '4.0',
+ ]);
+ });
+ });
+ describe('parseVideoShareSetting', () => {
+ describe('has no course setting or block setting for video sharing', () => {
+ it('should return an object with level equal to block and value equal to null', () => {
+ const videoSharingSetting = thunkActions.parseVideoSharingSetting({
+ courseSetting: null,
+ blockSetting: null,
+ });
+ expect(videoSharingSetting).toEqual({ level: 'block', value: null });
+ });
+ });
+ describe('has no course setting and block setting defined for video sharing', () => {
+ it('should return an object with level equal to block and value equal to true', () => {
+ const videoSharingSetting = thunkActions.parseVideoSharingSetting({
+ courseSetting: null,
+ blockSetting: true,
+ });
+ expect(videoSharingSetting).toEqual({ level: 'block', value: true });
+ });
+ });
+ describe('has course setting defined for video sharing', () => {
+ describe('course setting equals all-on', () => {
+ it('should return an object with level equal to course and value equal to true', () => {
+ const videoSharingSetting = thunkActions.parseVideoSharingSetting({
+ courseSetting: 'all-on',
+ blockSetting: true,
+ });
+ expect(videoSharingSetting).toEqual({ level: 'course', value: true });
+ });
+ });
+ describe('course setting equals all-off', () => {
+ it('should return an object with level equal to course and value equal to false', () => {
+ const videoSharingSetting = thunkActions.parseVideoSharingSetting({
+ courseSetting: 'all-off',
+ blockSetting: true,
+ });
+ expect(videoSharingSetting).toEqual({ level: 'course', value: false });
+ });
+ });
+ describe('course setting equals per-video', () => {
+ it('should return an object with level equal to block and value equal to block setting', () => {
+ const videoSharingSetting = thunkActions.parseVideoSharingSetting({
+ courseSetting: 'per-video',
+ blockSetting: false,
+ });
+ expect(videoSharingSetting).toEqual({ level: 'block', value: false });
+ });
+ });
+ });
+ });
+ describe('uploadHandout', () => {
+ beforeEach(() => {
+ thunkActions.uploadHandout({ file: mockFilename })(dispatch);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches uploadAsset action', () => {
+ expect(dispatchedAction.uploadAsset).not.toBe(undefined);
+ });
+ test('passes file as image prop', () => {
+ expect(dispatchedAction.uploadAsset.asset).toEqual(mockFilename);
+ });
+ test('onSuccess: calls setSelection with camelized response.data.asset', () => {
+ const handout = mockFilename;
+ dispatchedAction.uploadAsset.onSuccess({ data: { asset: { url: mockFilename } } });
+ expect(dispatch).toHaveBeenCalledWith(actions.video.updateField({ handout }));
+ });
+ });
+ describe('uploadThumbnail', () => {
+ beforeEach(() => {
+ thunkActions.uploadThumbnail({ thumbnail: mockThumbnail })(dispatch, getState);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches uploadThumbnail action', () => {
+ expect(dispatchedAction.uploadThumbnail).not.toEqual(undefined);
+ });
+ it('dispatches actions.video.updateField on success', () => {
+ dispatch.mockClear();
+ dispatchedAction.uploadThumbnail.onSuccess(mockThumbnailResponse);
+ expect(dispatch).toHaveBeenCalledWith(
+ actions.video.updateField({
+ thumbnail: thumbnailUrl,
+ }),
+ );
+ });
+ });
+ describe('uploadThumbnail - emptyCanvas', () => {
+ beforeEach(() => {
+ thunkActions.uploadThumbnail({ thumbnail: mockThumbnail, emptyCanvas: true })(dispatch, getState);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches uploadThumbnail action', () => {
+ expect(dispatchedAction.uploadThumbnail).not.toEqual(undefined);
+ });
+ });
+ describe('importTranscript', () => {
+ beforeEach(() => {
+ thunkActions.importTranscript()(dispatch, getState);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches uploadTranscript action', () => {
+ expect(dispatchedAction.importTranscript).not.toEqual(undefined);
+ });
+ it('dispatches actions.video.updateField on success', () => {
+ dispatch.mockClear();
+ dispatchedAction.importTranscript.onSuccess();
+ expect(dispatch).toHaveBeenCalledWith(actions.video.updateField(testUpload));
+ });
+ });
+ describe('deleteTranscript', () => {
+ beforeEach(() => {
+ thunkActions.deleteTranscript({ language: 'la' })(dispatch, getState);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches deleteTranscript action', () => {
+ expect(dispatchedAction.deleteTranscript).not.toEqual(undefined);
+ });
+ it('dispatches actions.video.updateField on success', () => {
+ dispatch.mockClear();
+ dispatchedAction.deleteTranscript.onSuccess();
+ expect(dispatch).toHaveBeenCalledWith(actions.video.updateField({ transcripts: [] }));
+ });
+ });
+ describe('uploadTranscript', () => {
+ beforeEach(() => {
+ thunkActions.uploadTranscript({
+ language: mockLanguage,
+ filename: mockFilename,
+ file: mockFile,
+ })(dispatch, getState);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches uploadTranscript action', () => {
+ expect(dispatchedAction.uploadTranscript).not.toEqual(undefined);
+ });
+ it('dispatches actions.video.updateField on success', () => {
+ dispatch.mockClear();
+ dispatchedAction.uploadTranscript.onSuccess();
+ expect(dispatch).toHaveBeenCalledWith(actions.video.updateField(testUpload));
+ });
+ });
+ describe('updateTranscriptLanguage', () => {
+ beforeEach(() => {
+ thunkActions.updateTranscriptLanguage({
+ newLanguageCode: mockLanguage,
+ languageBeforeChange: `${mockLanguage}i`,
+ })(dispatch, getState);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches uploadTranscript action', () => {
+ expect(dispatchedAction.getTranscriptFile).not.toEqual(undefined);
+ });
+ it('dispatches actions.video.updateField on success', () => {
+ dispatch.mockClear();
+ dispatchedAction.getTranscriptFile.onSuccess({ data: 'sOme StRinG Data' });
+ expect(dispatch).toHaveBeenCalled();
+ });
+ });
+ describe('replaceTranscript', () => {
+ const spies = {};
+ beforeEach(() => {
+ spies.uploadTranscript = jest.spyOn(thunkActions, 'uploadTranscript')
+ .mockReturnValue(testReplaceUpload).mockName('uploadTranscript');
+ thunkActions.replaceTranscript({
+ newFile: mockFile,
+ newFilename: mockFilename,
+ language: mockLanguage,
+ })(dispatch, getState, spies.uploadTranscript);
+ [[dispatchedAction]] = dispatch.mock.calls;
+ });
+ it('dispatches deleteTranscript action', () => {
+ expect(dispatchedAction.deleteTranscript).not.toEqual(undefined);
+ });
+ it('dispatches actions.video.updateField and replaceTranscript success', () => {
+ dispatch.mockClear();
+ dispatchedAction.deleteTranscript.onSuccess();
+ expect(dispatch).toHaveBeenCalled();
+ });
+ });
+});
+
+describe('uploadVideo', () => {
+ let dispatch;
+ let setLoadSpinner;
+ let postUploadRedirect;
+ let dispatchedAction;
+ const fileData = new FormData();
+ fileData.append('file', new File(['content1'], 'file1.mp4', { type: 'video/mp4' }));
+ const supportedFiles = [fileData];
+
+ 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' },
+ ],
+ };
+
+ 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, ok: true });
+ global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
+ const response = {
+ files: [
+ { file_name: 'file1.mp4', upload_url: 'http://example.com/put_video1' },
+ ],
+ };
+ const mockRequestResponse = { data: response };
+ thunkActions.uploadVideo({ supportedFiles, setLoadSpinner, postUploadRedirect })(dispatch);
+ [[dispatchedAction]] = dispatch.mock.calls;
+
+ dispatchedAction.uploadVideo.onSuccess(mockRequestResponse);
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+ response.files.forEach(({ upload_url: uploadUrl }, index) => {
+ expect(fetch.mock.calls[index][0]).toEqual(uploadUrl);
+ });
+ supportedFiles.forEach((file, index) => {
+ const fileDataTest = file.get('file');
+ expect(fetch.mock.calls[index][1].body).toBe(fileDataTest);
+ });
+ });
+
+ 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, setLoadSpinner, postUploadRedirect })(dispatch);
+ dispatchedAction.uploadVideo.onSuccess(mockRequestResponse);
+ expect(spyConsoleError).toHaveBeenCalledWith('Could not find file object with name "file2.gif" in supportedFiles array.');
+ });
+});
diff --git a/src/editors/data/redux/video/index.js b/src/editors/data/redux/video/index.js
new file mode 100644
index 0000000000..8abd5f91d9
--- /dev/null
+++ b/src/editors/data/redux/video/index.js
@@ -0,0 +1,2 @@
+export { actions, reducer } from './reducer';
+export { default as selectors } from './selectors';
diff --git a/src/editors/data/redux/video/reducer.js b/src/editors/data/redux/video/reducer.js
new file mode 100644
index 0000000000..04bfbcd99c
--- /dev/null
+++ b/src/editors/data/redux/video/reducer.js
@@ -0,0 +1,72 @@
+import { createSlice } from '@reduxjs/toolkit';
+
+import { StrictDict } from '../../../utils';
+
+const initialState = {
+ videoSource: '',
+ videoId: '',
+ fallbackVideos: [
+ '',
+ '',
+ ],
+ allowVideoDownloads: false,
+ allowVideoSharing: {
+ level: 'block',
+ value: false,
+ },
+ videoSharingEnabledForAll: false,
+ videoSharingEnabledForCourse: false,
+ videoSharingLearnMoreLink: '',
+ thumbnail: null,
+ transcripts: [],
+ selectedVideoTranscriptUrls: {},
+ allowTranscriptDownloads: false,
+ duration: {
+ startTime: '00:00:00',
+ stopTime: '00:00:00',
+ total: '00:00:00',
+ },
+ showTranscriptByDefault: false,
+ handout: null,
+ licenseType: null,
+ licenseDetails: {
+ attribution: true,
+ noncommercial: false,
+ noDerivatives: false,
+ shareAlike: false,
+ },
+ courseLicenseType: null,
+ courseLicenseDetails: {
+ attribution: true,
+ noncommercial: false,
+ noDerivatives: false,
+ shareAlike: false,
+ },
+ allowThumbnailUpload: null,
+ allowTranscriptImport: false,
+};
+
+// eslint-disable-next-line no-unused-vars
+const video = createSlice({
+ name: 'video',
+ initialState,
+ reducers: {
+ updateField: (state, { payload }) => ({
+ ...state,
+ ...payload,
+ }),
+ load: (state, { payload }) => ({
+ ...payload,
+ }),
+ },
+});
+
+const actions = StrictDict(video.actions);
+
+const { reducer } = video;
+
+export {
+ actions,
+ initialState,
+ reducer,
+};
diff --git a/src/editors/data/redux/video/selectors.js b/src/editors/data/redux/video/selectors.js
new file mode 100644
index 0000000000..f71c014cf7
--- /dev/null
+++ b/src/editors/data/redux/video/selectors.js
@@ -0,0 +1,140 @@
+import { createSelector } from 'reselect';
+
+import { keyStore } from '../../../utils';
+import { videoTranscriptLanguages } from '../../constants/video';
+
+import { initialState } from './reducer';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './selectors';
+import * as AppSelectors from '../app/selectors';
+import { downloadVideoTranscriptURL, downloadVideoHandoutUrl, mediaTranscriptURL } from '../../services/cms/urls';
+
+const stateKeys = keyStore(initialState);
+
+export const video = (state) => state.video;
+
+export const simpleSelectors = [
+ stateKeys.videoSource,
+ stateKeys.videoId,
+ stateKeys.fallbackVideos,
+ stateKeys.allowVideoDownloads,
+ stateKeys.videoSharingEnabledForCourse,
+ stateKeys.videoSharingLearnMoreLink,
+ stateKeys.videoSharingEnabledForAll,
+ stateKeys.allowVideoSharing,
+ stateKeys.thumbnail,
+ stateKeys.transcripts,
+ stateKeys.selectedVideoTranscriptUrls,
+ stateKeys.allowTranscriptDownloads,
+ stateKeys.duration,
+ stateKeys.showTranscriptByDefault,
+ stateKeys.handout,
+ stateKeys.licenseType,
+ stateKeys.licenseDetails,
+ stateKeys.courseLicenseType,
+ stateKeys.courseLicenseDetails,
+ stateKeys.allowThumbnailUpload,
+ stateKeys.allowTranscriptImport,
+].reduce((obj, key) => ({ ...obj, [key]: state => state.video[key] }), {});
+
+export const openLanguages = createSelector(
+ [module.simpleSelectors.transcripts],
+ (transcripts) => {
+ if (!transcripts) {
+ return videoTranscriptLanguages;
+ }
+ const open = Object.keys(videoTranscriptLanguages).filter(
+ (lang) => !transcripts.includes(lang),
+ );
+ return open;
+ },
+);
+
+export const getTranscriptDownloadUrl = createSelector(
+ [AppSelectors.simpleSelectors.studioEndpointUrl, AppSelectors.simpleSelectors.blockId],
+ (studioEndpointUrl, blockId) => ({ language }) => downloadVideoTranscriptURL({
+ studioEndpointUrl,
+ blockId,
+ language,
+ }),
+);
+
+export const buildTranscriptUrl = createSelector(
+ [AppSelectors.simpleSelectors.studioEndpointUrl],
+ (studioEndpointUrl) => ({ transcriptUrl }) => mediaTranscriptURL({
+ studioEndpointUrl,
+ transcriptUrl,
+ }),
+);
+
+export const getHandoutDownloadUrl = createSelector(
+ [AppSelectors.simpleSelectors.studioEndpointUrl],
+ (studioEndpointUrl) => ({ handout }) => downloadVideoHandoutUrl({
+ studioEndpointUrl,
+ handout,
+ }),
+);
+
+export const videoSettings = createSelector(
+ [
+ module.simpleSelectors.videoSource,
+ module.simpleSelectors.videoId,
+ module.simpleSelectors.fallbackVideos,
+ module.simpleSelectors.allowVideoDownloads,
+ module.simpleSelectors.allowVideoSharing,
+ module.simpleSelectors.thumbnail,
+ module.simpleSelectors.transcripts,
+ module.simpleSelectors.selectedVideoTranscriptUrls,
+ module.simpleSelectors.allowTranscriptDownloads,
+ module.simpleSelectors.duration,
+ module.simpleSelectors.showTranscriptByDefault,
+ module.simpleSelectors.handout,
+ module.simpleSelectors.licenseType,
+ module.simpleSelectors.licenseDetails,
+ ],
+ (
+ videoSource,
+ videoId,
+ fallbackVideos,
+ allowVideoDownloads,
+ allowVideoSharing,
+ thumbnail,
+ transcripts,
+ selectedVideoTranscriptUrls,
+ allowTranscriptDownloads,
+ duration,
+ showTranscriptByDefault,
+ handout,
+ licenseType,
+ licenseDetails,
+ ) => (
+ {
+ videoSource,
+ videoId,
+ fallbackVideos,
+ allowVideoDownloads,
+ allowVideoSharing,
+ thumbnail,
+ transcripts,
+ selectedVideoTranscriptUrls,
+ allowTranscriptDownloads,
+ duration,
+ showTranscriptByDefault,
+ handout,
+ licenseType,
+ licenseDetails,
+ }
+ ),
+);
+
+export default {
+ ...simpleSelectors,
+ openLanguages,
+ getTranscriptDownloadUrl,
+ buildTranscriptUrl,
+ getHandoutDownloadUrl,
+ videoSettings,
+};
diff --git a/src/editors/data/services/cms/api.js b/src/editors/data/services/cms/api.js
new file mode 100644
index 0000000000..7d732c15d4
--- /dev/null
+++ b/src/editors/data/services/cms/api.js
@@ -0,0 +1,320 @@
+import { camelizeKeys } from '../../../utils';
+import * as urls from './urls';
+import { get, post, deleteObject } from './utils';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './api';
+import * as mockApi from './mockApi';
+import { durationStringFromValue } from '../../../containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks';
+
+const fetchByUnitIdOptions = {};
+
+// For some reason, the local webpack-dev-server of library-authoring does not accept the normal Accept header.
+// This is a workaround only for that specific case; the idea is to only do this locally and only for library-authoring.
+if (process.env.NODE_ENV === 'development' && process.env.MFE_NAME === 'frontend-app-library-authoring') {
+ fetchByUnitIdOptions.headers = {
+ Accept: '*/*',
+ };
+}
+
+export const apiMethods = {
+ fetchBlockById: ({ blockId, studioEndpointUrl }) => get(
+ urls.block({ blockId, studioEndpointUrl }),
+ ),
+ fetchByUnitId: ({ blockId, studioEndpointUrl }) => get(
+ urls.blockAncestor({ studioEndpointUrl, blockId }),
+ fetchByUnitIdOptions,
+ ),
+ fetchStudioView: ({ blockId, studioEndpointUrl }) => get(
+ urls.blockStudioView({ studioEndpointUrl, blockId }),
+ ),
+ fetchImages: ({ learningContextId, studioEndpointUrl, pageNumber }) => {
+ const params = {
+ asset_type: 'Images',
+ page: pageNumber,
+ };
+ return get(
+ `${urls.courseAssets({ studioEndpointUrl, learningContextId })}`,
+ { params },
+ );
+ },
+ fetchVideos: ({ studioEndpointUrl, learningContextId }) => get(
+ urls.courseVideos({ studioEndpointUrl, learningContextId }),
+ ),
+ fetchCourseDetails: ({ studioEndpointUrl, learningContextId }) => get(
+ urls.courseDetailsUrl({ studioEndpointUrl, learningContextId }),
+ ),
+ fetchAdvancedSettings: ({ studioEndpointUrl, learningContextId }) => get(
+ urls.courseAdvanceSettings({ studioEndpointUrl, learningContextId }),
+ ),
+ uploadAsset: ({
+ learningContextId,
+ studioEndpointUrl,
+ asset,
+ }) => {
+ const data = new FormData();
+ data.append('file', asset);
+ return post(
+ urls.courseAssets({ studioEndpointUrl, learningContextId }),
+ data,
+ );
+ },
+ uploadThumbnail: ({
+ studioEndpointUrl,
+ learningContextId,
+ videoId,
+ thumbnail,
+ }) => {
+ const data = new FormData();
+ data.append('file', thumbnail);
+ return post(
+ urls.thumbnailUpload({ studioEndpointUrl, learningContextId, videoId }),
+ data,
+ );
+ },
+ checkTranscriptsForImport: ({
+ studioEndpointUrl,
+ blockId,
+ youTubeId,
+ videoId,
+ }) => {
+ const getJSON = `{"locator":"${blockId}","videos":[{"mode":"youtube","video":"${youTubeId}","type":"youtube"},{"mode":"edx_video_id","type":"edx_video_id","video":"${videoId}"}]}`;
+ return get(
+ urls.checkTranscriptsForImport({
+ studioEndpointUrl,
+ parameters: encodeURIComponent(getJSON),
+ }),
+ );
+ },
+ importTranscript: ({
+ studioEndpointUrl,
+ blockId,
+ youTubeId,
+ }) => {
+ const getJSON = `{"locator":"${blockId}","videos":[{"mode":"youtube","video":"${youTubeId}","type":"youtube"}]}`;
+ return get(
+ urls.replaceTranscript({
+ studioEndpointUrl,
+ parameters: encodeURIComponent(getJSON),
+ }),
+ );
+ },
+ getTranscript: ({
+ studioEndpointUrl,
+ language,
+ blockId,
+ videoId,
+ }) => {
+ const getJSON = { data: { lang: language, edx_video_id: videoId } };
+ return get(
+ `${urls.videoTranscripts({ studioEndpointUrl, blockId })}?language_code=${language}`,
+ getJSON,
+ );
+ },
+
+ deleteTranscript: ({
+ studioEndpointUrl,
+ language,
+ blockId,
+ videoId,
+ }) => {
+ const deleteJSON = { data: { lang: language, edx_video_id: videoId } };
+ return deleteObject(
+ urls.videoTranscripts({ studioEndpointUrl, blockId }),
+ deleteJSON,
+ );
+ },
+ uploadTranscript: ({
+ blockId,
+ studioEndpointUrl,
+ transcript,
+ videoId,
+ language,
+ newLanguage = null,
+ }) => {
+ const data = new FormData();
+ data.append('file', transcript);
+ data.append('edx_video_id', videoId);
+ data.append('language_code', language);
+ data.append('new_language_code', newLanguage || language);
+ return post(
+ urls.videoTranscripts({ studioEndpointUrl, blockId }),
+ data,
+ );
+ },
+ normalizeContent: ({
+ blockId,
+ blockType,
+ content,
+ learningContextId,
+ title,
+ }) => {
+ let response = {};
+ if (blockType === 'html') {
+ response = {
+ category: blockType,
+ courseKey: learningContextId,
+ data: content,
+ has_changes: true,
+ id: blockId,
+ metadata: { display_name: title },
+ };
+ } else if (blockType === 'problem') {
+ response = {
+ data: content.olx,
+ category: blockType,
+ courseKey: learningContextId,
+ has_changes: true,
+ id: blockId,
+ metadata: { display_name: title, ...content.settings },
+ };
+ } else if (blockType === 'video') {
+ const {
+ html5Sources,
+ edxVideoId,
+ youtubeId,
+ } = module.processVideoIds({
+ videoId: content.videoId,
+ videoUrl: content.videoSource,
+ fallbackVideos: content.fallbackVideos,
+ });
+ response = {
+ category: blockType,
+ courseKey: learningContextId,
+ display_name: title,
+ id: blockId,
+ metadata: {
+ display_name: title,
+ download_video: content.allowVideoDownloads,
+ public_access: content.allowVideoSharing.value,
+ edx_video_id: edxVideoId,
+ html5_sources: html5Sources,
+ youtube_id_1_0: youtubeId,
+ thumbnail: content.thumbnail,
+ download_track: content.allowTranscriptDownloads,
+ track: '', // TODO Downloadable Transcript URL. Backend expects a file name, for example: "something.srt"
+ show_captions: content.showTranscriptByDefault,
+ handout: content.handout,
+ start_time: durationStringFromValue(content.duration.startTime),
+ end_time: durationStringFromValue(content.duration.stopTime),
+ license: module.processLicense(content.licenseType, content.licenseDetails),
+ },
+ };
+ } else {
+ throw new TypeError(`No Block in V2 Editors named /"${blockType}/", Cannot Save Content.`);
+ }
+ return { ...response };
+ },
+ saveBlock: ({
+ blockId,
+ blockType,
+ content,
+ learningContextId,
+ studioEndpointUrl,
+ title,
+ }) => post(
+ urls.block({ studioEndpointUrl, blockId }),
+ module.apiMethods.normalizeContent({
+ blockType,
+ content,
+ blockId,
+ learningContextId,
+ title,
+ }),
+ ),
+ fetchVideoFeatures: ({
+ studioEndpointUrl,
+ }) => get(
+ urls.videoFeatures({ studioEndpointUrl }),
+ ),
+ uploadVideo: ({
+ data,
+ studioEndpointUrl,
+ learningContextId,
+ }) => post(
+ urls.courseVideos({ studioEndpointUrl, learningContextId }),
+ data,
+ ),
+};
+
+export const loadImage = (imageData) => ({
+ ...imageData,
+ dateAdded: new Date(imageData.dateAdded.replace(' at', '')).getTime(),
+});
+
+export const loadImages = (rawImages) => camelizeKeys(rawImages).reduce(
+ (obj, image) => ({ ...obj, [image.id]: module.loadImage(image) }),
+ {},
+);
+
+export const processVideoIds = ({
+ videoId,
+ videoUrl,
+ fallbackVideos,
+}) => {
+ let youtubeId = '';
+ const html5Sources = [];
+
+ if (videoUrl) {
+ if (module.parseYoutubeId(videoUrl)) {
+ youtubeId = module.parseYoutubeId(videoUrl);
+ } else {
+ html5Sources.push(videoUrl);
+ }
+ }
+
+ if (fallbackVideos) {
+ fallbackVideos.forEach((src) => (src ? html5Sources.push(src) : null));
+ }
+
+ return {
+ edxVideoId: videoId,
+ html5Sources,
+ youtubeId,
+ };
+};
+
+export const isEdxVideo = (src) => {
+ const uuid4Regex = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/;
+ if (src && src.match(uuid4Regex)) {
+ return true;
+ }
+ return false;
+};
+
+export const parseYoutubeId = (src) => {
+ const youtubeRegex = /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/;
+ if (!src.match(youtubeRegex)) {
+ return null;
+ }
+ return src.match(youtubeRegex)[5];
+};
+
+export const processLicense = (licenseType, licenseDetails) => {
+ if (licenseType === 'creative-commons') {
+ return 'creative-commons: ver=4.0'.concat(
+ (licenseDetails.attribution ? ' BY' : ''),
+ (licenseDetails.noncommercial ? ' NC' : ''),
+ (licenseDetails.noDerivatives ? ' ND' : ''),
+ (licenseDetails.shareAlike ? ' SA' : ''),
+ );
+ }
+ if (licenseType === 'all-rights-reserved') {
+ return 'all-rights-reserved';
+ }
+ return '';
+};
+
+export const checkMockApi = (key) => {
+ if (process.env.REACT_APP_DEVGALLERY) {
+ return mockApi[key] ? mockApi[key] : mockApi.emptyMock;
+ }
+ return module.apiMethods[key];
+};
+
+export default Object.keys(apiMethods).reduce(
+ (obj, key) => ({ ...obj, [key]: checkMockApi(key) }),
+ {},
+);
diff --git a/src/editors/data/services/cms/api.test.js b/src/editors/data/services/cms/api.test.js
new file mode 100644
index 0000000000..7abec953cc
--- /dev/null
+++ b/src/editors/data/services/cms/api.test.js
@@ -0,0 +1,641 @@
+/* eslint-disable no-import-assign */
+import * as utils from '../../../utils';
+import * as api from './api';
+import * as mockApi from './mockApi';
+import * as urls from './urls';
+import { get, post, deleteObject } from './utils';
+
+jest.mock('../../../utils', () => {
+ const camelizeMap = (obj) => ({ ...obj, camelized: true });
+ return {
+ ...jest.requireActual('../../../utils'),
+ camelize: camelizeMap,
+ camelizeKeys: (list) => list.map(camelizeMap),
+ };
+});
+
+jest.mock('./urls', () => ({
+ block: jest.fn().mockReturnValue('urls.block'),
+ blockAncestor: jest.fn().mockReturnValue('urls.blockAncestor'),
+ blockStudioView: jest.fn().mockReturnValue('urls.StudioView'),
+ courseAssets: jest.fn().mockReturnValue('urls.courseAssets'),
+ videoTranscripts: jest.fn().mockReturnValue('urls.videoTranscripts'),
+ allowThumbnailUpload: jest.fn().mockReturnValue('urls.allowThumbnailUpload'),
+ thumbnailUpload: jest.fn().mockReturnValue('urls.thumbnailUpload'),
+ checkTranscriptsForImport: jest.fn().mockReturnValue('urls.checkTranscriptsForImport'),
+ courseDetailsUrl: jest.fn().mockReturnValue('urls.courseDetailsUrl'),
+ courseAdvanceSettings: jest.fn().mockReturnValue('urls.courseAdvanceSettings'),
+ replaceTranscript: jest.fn().mockReturnValue('urls.replaceTranscript'),
+ videoFeatures: jest.fn().mockReturnValue('urls.videoFeatures'),
+ courseVideos: jest.fn()
+ .mockName('urls.courseVideos')
+ .mockImplementation(
+ ({ studioEndpointUrl, learningContextId }) => `${studioEndpointUrl}/some_video_upload_url/${learningContextId}`,
+ ),
+}));
+
+jest.mock('./utils', () => ({
+ get: jest.fn().mockName('get'),
+ post: jest.fn().mockName('post'),
+ deleteObject: jest.fn().mockName('deleteObject'),
+}));
+
+const { camelize } = utils;
+
+const { apiMethods } = api;
+
+const blockId = 'block-v1-coursev1:2uX@4345432';
+const learningContextId = 'demo2uX';
+const studioEndpointUrl = 'hortus.coa';
+const title = 'remember this needs to go into metadata to save';
+
+describe('cms api', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ describe('apiMethods', () => {
+ describe('fetchBlockId', () => {
+ it('should call get with url.blocks', () => {
+ apiMethods.fetchBlockById({ blockId, studioEndpointUrl });
+ expect(get).toHaveBeenCalledWith(urls.block({ blockId, studioEndpointUrl }));
+ });
+ });
+
+ describe('fetchByUnitId', () => {
+ it('should call get with url.blockAncestor', () => {
+ apiMethods.fetchByUnitId({ blockId, studioEndpointUrl });
+ expect(get).toHaveBeenCalledWith(urls.blockAncestor({ studioEndpointUrl, blockId }), {});
+ });
+
+ describe('when called in different contexts', () => {
+ // To mock env variables, you need to use dynamic imports for the tested methods
+ // and then reset the env variables afterwards.
+ const OLD_ENV = process.env;
+
+ beforeEach(() => {
+ jest.resetModules();
+ process.env = { ...OLD_ENV };
+ });
+
+ afterEach(() => {
+ process.env = OLD_ENV;
+ });
+
+ it('should call get with normal accept header for prod', async () => {
+ process.env.NODE_ENV = 'production';
+ process.env.MFE_NAME = 'frontend-app-library-authoring';
+ // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow
+ const { apiMethods } = await import('./api');
+ // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow
+ const utils = await import('./utils');
+ const getSpy = jest.spyOn(utils, 'get');
+ apiMethods.fetchByUnitId({ blockId, studioEndpointUrl });
+ expect(getSpy).toHaveBeenCalledWith(urls.blockAncestor({ studioEndpointUrl, blockId }), {});
+ });
+
+ it('should call get with normal accept header for course-authoring', async () => {
+ process.env.NODE_ENV = 'development';
+ process.env.MFE_NAME = 'frontend-app-course-authoring';
+ // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow
+ const { apiMethods } = await import('./api');
+ // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow
+ const utils = await import('./utils');
+ const getSpy = jest.spyOn(utils, 'get');
+ apiMethods.fetchByUnitId({ blockId, studioEndpointUrl });
+ expect(getSpy).toHaveBeenCalledWith(urls.blockAncestor({ studioEndpointUrl, blockId }), {});
+ });
+
+ it('should call get with special accept header "*/*" for course-authoring', async () => {
+ process.env.NODE_ENV = 'development';
+ process.env.MFE_NAME = 'frontend-app-library-authoring';
+ // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow
+ const { apiMethods } = await import('./api');
+ // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow
+ const utils = await import('./utils');
+ const getSpy = jest.spyOn(utils, 'get');
+ apiMethods.fetchByUnitId({ blockId, studioEndpointUrl });
+ expect(getSpy).toHaveBeenCalledWith(urls.blockAncestor({ studioEndpointUrl, blockId }), { headers: { Accept: '*/*' } });
+ });
+ });
+ });
+
+ describe('fetchStudioView', () => {
+ it('should call get with url.blockStudioView', () => {
+ apiMethods.fetchStudioView({ blockId, studioEndpointUrl });
+ expect(get).toHaveBeenCalledWith(urls.blockStudioView({ studioEndpointUrl, blockId }));
+ });
+ });
+
+ describe('fetchImages', () => {
+ it('should call get with url.courseAssets', () => {
+ apiMethods.fetchImages({ learningContextId, studioEndpointUrl, pageNumber: 0 });
+ const params = {
+ asset_type: 'Images',
+ page: 0,
+ };
+ expect(get).toHaveBeenCalledWith(
+ urls.courseAssets({ studioEndpointUrl, learningContextId }),
+ { params },
+ );
+ });
+ });
+
+ describe('fetchCourseDetails', () => {
+ it('should call get with url.courseDetailsUrl', () => {
+ apiMethods.fetchCourseDetails({ learningContextId, studioEndpointUrl });
+ expect(get).toHaveBeenCalledWith(urls.courseDetailsUrl({ studioEndpointUrl, learningContextId }));
+ });
+ });
+
+ describe('fetchVideos', () => {
+ it('should call get with url.courseVideos', () => {
+ apiMethods.fetchVideos({ learningContextId, studioEndpointUrl });
+ expect(get).toHaveBeenCalledWith(urls.courseVideos({ studioEndpointUrl, learningContextId }));
+ });
+ });
+
+ describe('fetchAdvancedSettings', () => {
+ it('should call get with url.courseAdvanceSettings', () => {
+ apiMethods.fetchAdvancedSettings({ learningContextId, studioEndpointUrl });
+ expect(get).toHaveBeenCalledWith(urls.courseAdvanceSettings({ studioEndpointUrl, learningContextId }));
+ });
+ });
+
+ describe('normalizeContent', () => {
+ test('return value for blockType: html', () => {
+ const content = 'Im baby palo santo ugh celiac fashion axe. La croix lo-fi venmo whatever. Beard man braid migas single-origin coffee forage ramps.';
+ expect(apiMethods.normalizeContent({
+ blockId,
+ blockType: 'html',
+ content,
+ learningContextId,
+ title,
+ })).toEqual({
+ category: 'html',
+ courseKey: learningContextId,
+ data: content,
+ has_changes: true,
+ id: blockId,
+ metadata: { display_name: title },
+ });
+ });
+ test('return value for blockType: video', () => {
+ const content = {
+ videoSource: 'viDeOSouRCE',
+ fallbackVideos: 'FalLBacKVidEOs',
+ allowVideoDownloads: 'alLOwViDeodownLOads',
+ allowVideoSharing: {
+ level: 'sOMeStRInG',
+ value: 'alloWviDeOshArinG',
+ },
+ thumbnail: 'THUmbNaIL',
+ transcripts: 'traNScRiPts',
+ allowTranscriptDownloads: 'aLloWTRaNScriPtdoWnlOADS',
+ duration: {
+ startTime: '00:00:00',
+ stopTime: '00:00:00',
+ },
+ showTranscriptByDefault: 'ShOWtrANscriPTBYDeFAulT',
+ handout: 'HAnDOuT',
+ licenseType: 'LiCeNsETYpe',
+ licenseDetails: 'liCENSeDetAIls',
+ };
+ const html5Sources = 'hTML5souRCES';
+ const edxVideoId = 'eDXviDEOid';
+ const youtubeId = 'yOUtUBeid';
+ const license = 'LiCEnsE';
+ jest.spyOn(api, 'processVideoIds').mockReturnValue({
+ html5Sources,
+ edxVideoId,
+ youtubeId,
+ });
+ jest.spyOn(api, 'processLicense').mockReturnValue(license);
+ expect(apiMethods.normalizeContent({
+ blockId,
+ blockType: 'video',
+ content,
+ learningContextId,
+ title,
+ })).toEqual({
+ category: 'video',
+ courseKey: learningContextId,
+ display_name: title,
+ id: blockId,
+ metadata: {
+ display_name: title,
+ download_video: content.allowVideoDownloads,
+ public_access: content.allowVideoSharing.value,
+ edx_video_id: edxVideoId,
+ html5_sources: html5Sources,
+ youtube_id_1_0: youtubeId,
+ thumbnail: content.thumbnail,
+ download_track: content.allowTranscriptDownloads,
+ track: '',
+ show_captions: content.showTranscriptByDefault,
+ handout: content.handout,
+ start_time: content.duration.startTime,
+ end_time: content.duration.stopTime,
+ license,
+ },
+ });
+ jest.restoreAllMocks();
+ });
+ test('throw error for invalid blockType', () => {
+ expect(() => { apiMethods.normalizeContent({ blockType: 'somethingINVALID' }); })
+ .toThrow(TypeError);
+ });
+ });
+
+ describe('saveBlock', () => {
+ const content = 'Im baby palo santo ugh celiac fashion axe. La croix lo-fi venmo whatever. Beard man braid migas single-origin coffee forage ramps.';
+ it('should call post with urls.block and normalizeContent', () => {
+ apiMethods.saveBlock({
+ blockId,
+ blockType: 'html',
+ content,
+ learningContextId,
+ studioEndpointUrl,
+ title,
+ });
+ expect(post).toHaveBeenCalledWith(
+ urls.block({ studioEndpointUrl }),
+ apiMethods.normalizeContent({
+ blockType: 'html',
+ content,
+ blockId,
+ learningContextId,
+ title,
+ }),
+ );
+ });
+ });
+
+ describe('uploadAsset', () => {
+ const asset = { photo: 'dAta' };
+ it('should call post with urls.courseAssets and imgdata', () => {
+ const mockFormdata = new FormData();
+ mockFormdata.append('file', asset);
+ apiMethods.uploadAsset({
+ learningContextId,
+ studioEndpointUrl,
+ asset,
+ });
+ expect(post).toHaveBeenCalledWith(
+ urls.courseAssets({ studioEndpointUrl, learningContextId }),
+ mockFormdata,
+ );
+ });
+ });
+
+ describe('uploadVideo', () => {
+ it('should call post with urls.courseVideos and data', () => {
+ const data = { files: [{ file_name: 'video.mp4', content_type: 'mp4' }] };
+
+ apiMethods.uploadVideo({ data, studioEndpointUrl, learningContextId });
+
+ expect(urls.courseVideos).toHaveBeenCalledWith({ studioEndpointUrl, learningContextId });
+ expect(post).toHaveBeenCalledWith(
+ urls.courseVideos({ studioEndpointUrl, learningContextId }),
+ data,
+ );
+ });
+ });
+ });
+ describe('loadImage', () => {
+ it('loads incoming image data, replacing the dateAdded with a date field', () => {
+ const [date, time] = ['Jan 20, 2022', '9:30 PM'];
+ const imageData = { some: 'image data', dateAdded: `${date} at ${time}` };
+ expect(api.loadImage(imageData)).toEqual({
+ ...imageData,
+ dateAdded: new Date(`${date} ${time}`).getTime(),
+ });
+ });
+ });
+ describe('loadImages', () => {
+ it('loads a list of images into an object by id, using loadImage to translate', () => {
+ const ids = ['id0', 'Id1', 'ID2', 'iD3'];
+ const testData = [
+ { id: ids[0], some: 'data' },
+ { id: ids[1], other: 'data' },
+ { id: ids[2], some: 'DATA' },
+ { id: ids[3], other: 'DATA' },
+ ];
+ const oldLoadImage = api.loadImage;
+ api.loadImage = (imageData) => ({ loadImage: imageData });
+ const out = api.loadImages(testData);
+ expect(out).toEqual({
+ [ids[0]]: api.loadImage(camelize(testData[0])),
+ [ids[1]]: api.loadImage(camelize(testData[1])),
+ [ids[2]]: api.loadImage(camelize(testData[2])),
+ [ids[3]]: api.loadImage(camelize(testData[3])),
+ });
+ api.loadImage = oldLoadImage;
+ });
+ });
+ describe('uploadThumbnail', () => {
+ describe('uploadThumbnail', () => {
+ const thumbnail = 'dAta';
+ const videoId = 'sOmeVIDeoiD';
+ it('should call post with urls.thumbnailUpload and thumbnail data', () => {
+ const mockFormdata = new FormData();
+ mockFormdata.append('file', thumbnail);
+ apiMethods.uploadThumbnail({
+ studioEndpointUrl,
+ learningContextId,
+ videoId,
+ thumbnail,
+ });
+ expect(post).toHaveBeenCalledWith(
+ urls.thumbnailUpload({ studioEndpointUrl, learningContextId, videoId }),
+ mockFormdata,
+ );
+ });
+ });
+ });
+ describe('videoTranscripts', () => {
+ const language = 'la';
+ const videoId = 'sOmeVIDeoiD';
+ const youTubeId = 'SOMeyoutUBeid';
+ describe('checkTranscriptsForImport', () => {
+ const getJSON = `{"locator":"${blockId}","videos":[{"mode":"youtube","video":"${youTubeId}","type":"youtube"},{"mode":"edx_video_id","type":"edx_video_id","video":"${videoId}"}]}`;
+ it('should call get with url.checkTranscriptsForImport', () => {
+ apiMethods.checkTranscriptsForImport({
+ studioEndpointUrl,
+ blockId,
+ videoId,
+ youTubeId,
+ });
+ expect(get).toHaveBeenCalledWith(urls.checkTranscriptsForImport({
+ studioEndpointUrl,
+ parameters: encodeURIComponent(getJSON),
+ }));
+ });
+ });
+ describe('importTranscript', () => {
+ const getJSON = `{"locator":"${blockId}","videos":[{"mode":"youtube","video":"${youTubeId}","type":"youtube"}]}`;
+ it('should call get with url.replaceTranscript', () => {
+ apiMethods.importTranscript({ studioEndpointUrl, blockId, youTubeId });
+ expect(get).toHaveBeenCalledWith(urls.replaceTranscript({
+ studioEndpointUrl,
+ parameters: encodeURIComponent(getJSON),
+ }));
+ });
+ });
+ describe('uploadTranscript', () => {
+ const transcript = { transcript: 'dAta' };
+ it('should call post with urls.videoTranscripts and transcript data', () => {
+ const mockFormdata = new FormData();
+ mockFormdata.append('file', transcript);
+ mockFormdata.append('edx_video_id', videoId);
+ mockFormdata.append('language_code', language);
+ mockFormdata.append('new_language_code', language);
+ apiMethods.uploadTranscript({
+ blockId,
+ studioEndpointUrl,
+ transcript,
+ videoId,
+ language,
+ });
+ expect(post).toHaveBeenCalledWith(
+ urls.videoTranscripts({ studioEndpointUrl, blockId }),
+ mockFormdata,
+ );
+ });
+ });
+ describe('transcript delete', () => {
+ it('should call deleteObject with urls.videoTranscripts and transcript data', () => {
+ const mockDeleteJSON = { data: { lang: language, edx_video_id: videoId } };
+ apiMethods.deleteTranscript({
+ blockId,
+ studioEndpointUrl,
+ videoId,
+ language,
+ });
+ expect(deleteObject).toHaveBeenCalledWith(
+ urls.videoTranscripts({ studioEndpointUrl, blockId }),
+ mockDeleteJSON,
+ );
+ });
+ });
+ describe('transcript get', () => {
+ it('should call get with urls.videoTranscripts and transcript data', () => {
+ const mockJSON = { data: { lang: language, edx_video_id: videoId } };
+ apiMethods.getTranscript({
+ blockId,
+ studioEndpointUrl,
+ videoId,
+ language,
+ });
+ expect(get).toHaveBeenCalledWith(
+ `${urls.videoTranscripts({ studioEndpointUrl, blockId })}?language_code=${language}`,
+ mockJSON,
+ );
+ });
+ });
+ });
+ describe('processVideoIds', () => {
+ const edxVideoId = 'eDXviDEoid';
+ const youtubeId = 'yOuTuBeUrL';
+ const youtubeUrl = `https://youtu.be/${youtubeId}`;
+ const html5Sources = [
+ 'sOuRce1',
+ 'sourCE2',
+ ];
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+ describe('if there is a video id', () => {
+ beforeEach(() => {
+ jest.spyOn(api, 'isEdxVideo').mockReturnValue(true);
+ jest.spyOn(api, 'parseYoutubeId').mockReturnValue(youtubeId);
+ });
+ it('returns edxVideoId when there are no fallbackVideos', () => {
+ expect(api.processVideoIds({
+ videoUrl: '',
+ fallbackVideos: [],
+ videoId: edxVideoId,
+ })).toEqual({
+ edxVideoId,
+ html5Sources: [],
+ youtubeId: '',
+ });
+ });
+ it('returns edxVideoId and html5Sources when there are fallbackVideos', () => {
+ expect(api.processVideoIds({
+ videoUrl: youtubeUrl,
+ fallbackVideos: html5Sources,
+ videoId: edxVideoId,
+ })).toEqual({
+ edxVideoId,
+ html5Sources,
+ youtubeId,
+ });
+ });
+ });
+ describe('if there is a youtube url', () => {
+ beforeEach(() => {
+ jest.spyOn(api, 'isEdxVideo').mockReturnValue(false);
+ jest.spyOn(api, 'parseYoutubeId').mockReturnValue(youtubeId);
+ });
+ it('returns youtubeId when there are no fallbackVideos', () => {
+ expect(api.processVideoIds({
+ videoUrl: youtubeUrl,
+ fallbackVideos: [],
+ videoId: '',
+ })).toEqual({
+ edxVideoId: '',
+ html5Sources: [],
+ youtubeId,
+ });
+ });
+ it('returns youtubeId and html5Sources when there are fallbackVideos', () => {
+ expect(api.processVideoIds({
+ videoUrl: youtubeUrl,
+ fallbackVideos: html5Sources,
+ videoId: '',
+ })).toEqual({
+ edxVideoId: '',
+ html5Sources,
+ youtubeId,
+ });
+ });
+ });
+ describe('if the videoSource is an html5 source', () => {
+ beforeEach(() => {
+ jest.spyOn(api, 'isEdxVideo').mockReturnValue(false);
+ jest.spyOn(api, 'parseYoutubeId').mockReturnValue(null);
+ });
+ it('returns html5Sources when there are no fallbackVideos', () => {
+ expect(api.processVideoIds({
+ videoUrl: html5Sources[0],
+ fallbackVideos: [],
+ videoId: '',
+ })).toEqual({
+ edxVideoId: '',
+ html5Sources: [html5Sources[0]],
+ youtubeId: '',
+ });
+ });
+ it('returns html5Sources when there are fallbackVideos', () => {
+ expect(api.processVideoIds({
+ videoUrl: html5Sources[0],
+ fallbackVideos: [html5Sources[1]],
+ videoId: '',
+ })).toEqual({
+ edxVideoId: '',
+ html5Sources,
+ youtubeId: '',
+ });
+ });
+ });
+ });
+ describe('isEdxVideo', () => {
+ it('returns true if id is in uuid4 format', () => {
+ const id = 'c2afd8c8-3329-4dfc-95be-4ee6d986c3e5';
+ expect(api.isEdxVideo(id)).toEqual(true);
+ });
+ it('returns false if id is not in uuid4 format', () => {
+ const id = 'someB-ad-Id';
+ expect(api.isEdxVideo(id)).toEqual(false);
+ });
+ });
+ describe('parseYoutubeId', () => {
+ it('returns the youtube id in an url', () => {
+ const id = '3_yD_cEKoCk';
+ const testURLs = [
+ 'https://www.youtube.com/watch?v=3_yD_cEKoCk&feature=featured',
+ 'https://www.youtube.com/watch?v=3_yD_cEKoCk',
+ 'http://www.youtube.com/watch?v=3_yD_cEKoCk',
+ '//www.youtube.com/watch?v=3_yD_cEKoCk',
+ 'www.youtube.com/watch?v=3_yD_cEKoCk',
+ 'https://youtube.com/watch?v=3_yD_cEKoCk',
+ 'http://youtube.com/watch?v=3_yD_cEKoCk',
+ '//youtube.com/watch?v=3_yD_cEKoCk',
+ 'youtube.com/watch?v=3_yD_cEKoCk',
+ 'https://m.youtube.com/watch?v=3_yD_cEKoCk',
+ 'http://m.youtube.com/watch?v=3_yD_cEKoCk',
+ '//m.youtube.com/watch?v=3_yD_cEKoCk',
+ 'm.youtube.com/watch?v=3_yD_cEKoCk',
+ 'https://www.youtube.com/v/3_yD_cEKoCk?fs=1&hl=en_US',
+ 'http://www.youtube.com/v/3_yD_cEKoCk?fs=1&hl=en_US',
+ '//www.youtube.com/v/3_yD_cEKoCk?fs=1&hl=en_US',
+ 'www.youtube.com/v/3_yD_cEKoCk?fs=1&hl=en_US',
+ 'youtube.com/v/3_yD_cEKoCk?fs=1&hl=en_US',
+ 'https://www.youtube.com/embed/3_yD_cEKoCk?autoplay=1',
+ 'https://www.youtube.com/embed/3_yD_cEKoCk',
+ 'http://www.youtube.com/embed/3_yD_cEKoCk',
+ '//www.youtube.com/embed/3_yD_cEKoCk',
+ 'www.youtube.com/embed/3_yD_cEKoCk',
+ 'https://youtube.com/embed/3_yD_cEKoCk',
+ 'http://youtube.com/embed/3_yD_cEKoCk',
+ '//youtube.com/embed/3_yD_cEKoCk',
+ 'youtube.com/embed/3_yD_cEKoCk',
+ 'https://youtu.be/3_yD_cEKoCk?t=120',
+ 'https://youtu.be/3_yD_cEKoCk',
+ 'http://youtu.be/3_yD_cEKoCk',
+ '//youtu.be/3_yD_cEKoCk',
+ 'youtu.be/3_yD_cEKoCk',
+ ];
+ testURLs.forEach((url) => {
+ expect(api.parseYoutubeId(url)).toEqual(id);
+ });
+ });
+ it('returns null if the url is not a youtube url', () => {
+ const badURL = 'https://someothersite.com/3_yD_cEKoCk';
+ expect(api.parseYoutubeId(badURL)).toEqual(null);
+ });
+ });
+ describe('processLicense', () => {
+ it('returns empty string when licenseType is empty or not a valid licnese type', () => {
+ expect(api.processLicense('', {})).toEqual('');
+ expect(api.processLicense('LiCeNsETYpe', {})).toEqual('');
+ });
+ it('returns empty string when licenseType equals creative commons', () => {
+ const licenseType = 'creative-commons';
+ const licenseDetails = {
+ attribution: true,
+ noncommercial: false,
+ noDerivatives: true,
+ shareAlike: false,
+ };
+ expect(api.processLicense(licenseType, licenseDetails)).toEqual('creative-commons: ver=4.0 BY ND');
+ });
+ it('returns empty string when licenseType equals creative commons', () => {
+ const licenseType = 'all-rights-reserved';
+ const licenseDetails = {};
+ expect(api.processLicense(licenseType, licenseDetails)).toEqual('all-rights-reserved');
+ });
+ });
+ describe('checkMockApi', () => {
+ const envTemp = process.env;
+ beforeEach(() => {
+ jest.resetModules();
+ process.env = { ...envTemp };
+ });
+ afterEach(() => {
+ process.env = envTemp;
+ });
+ describe('if REACT_APP_DEVGALLERY is true', () => {
+ it('should return the mockApi version of a call when it exists', () => {
+ process.env.REACT_APP_DEVGALLERY = true;
+ expect(api.checkMockApi('fetchBlockById')).toEqual(mockApi.fetchBlockById);
+ });
+ it('should return an empty mock when the call does not exist', () => {
+ process.env.REACT_APP_DEVGALLERY = true;
+ expect(api.checkMockApi('someRAnDomThINg')).toEqual(mockApi.emptyMock);
+ });
+ });
+ describe('if REACT_APP_DEVGALLERY is not true', () => {
+ it('should return the appropriate call', () => {
+ expect(api.checkMockApi('fetchBlockById')).toEqual(apiMethods.fetchBlockById);
+ });
+ });
+ });
+ describe('fetchVideoFeatures', () => {
+ it('should call get with url.videoFeatures', () => {
+ const args = { studioEndpointUrl };
+ apiMethods.fetchVideoFeatures({ ...args });
+ expect(get).toHaveBeenCalledWith(urls.videoFeatures({ ...args }));
+ });
+ });
+});
diff --git a/src/editors/data/services/cms/mockApi.js b/src/editors/data/services/cms/mockApi.js
new file mode 100644
index 0000000000..ca50e0c0a6
--- /dev/null
+++ b/src/editors/data/services/cms/mockApi.js
@@ -0,0 +1,297 @@
+/* istanbul ignore file */
+import * as urls from './urls';
+
+const mockPromise = (returnValue) => new Promise(resolve => { resolve(returnValue); });
+
+// TODO: update to return block data appropriate per block ID, which will equal block type
+// eslint-disable-next-line
+export const fetchBlockById = ({ blockId, studioEndpointUrl }) => {
+ let data = {};
+ if (blockId === 'html-block-id') {
+ data = {
+ data: `
+ `,
+ display_name: 'My Text Prompt',
+ metadata: {
+ display_name: 'Welcome!',
+ download_track: true,
+ download_video: true,
+ edx_video_id: 'f36f06b5-92e5-47c7-bb26-bcf986799cb7',
+ html5_sources: [
+ 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ ],
+ show_captions: true,
+ sub: '',
+ track: '',
+ transcripts: {
+ en: { filename: 'my-transcript-url' },
+ },
+ xml_attributes: {
+ source: '',
+ },
+ youtube_id_1_0: 'dQw4w9WgXcQ',
+ },
+ };
+ } else if (blockId === 'problem-block-id') {
+ data = {
+ data: `
+ `,
+ 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.
+ >>Add the question text, or prompt, here. This text is required.||You can add an optional tip or note related to the prompt like this. <<
+ [[
+ an incorrect answer
+ (the correct answer)
+ an incorrect answer
+ ]]`,
+ attempts_before_showanswer_button: 7,
+ max_attempts: 5,
+ show_reset_button: true,
+ showanswer: 'after_attempts',
+ submission_wait_seconds: 15,
+ weight: 29,
+ },
+ };
+ } else if (blockId === 'game-block-id') {
+ data = {
+ display_name: 'Game Block',
+ // TODO: insert mock data from backend here
+ };
+ }
+ return mockPromise({ data: { ...data } });
+};
+
+// TODO: update to return block data appropriate per block ID, which will equal block type
+// eslint-disable-next-line
+export const fetchByUnitId = ({ blockId, studioEndpointUrl }) => mockPromise({
+ data: { ancestors: [{ id: 'unitUrl' }] },
+});
+// eslint-disable-next-line
+export const fetchImages = ({ learningContextId, studioEndpointUrl }) => mockPromise({
+ data: {
+ assets: [
+ {
+ displayName: 'shahrukh.jpg',
+ contentType: 'image/jpeg',
+ dateAdded: 'Jan 05, 2022 at 17:38 UTC',
+ url: '/asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
+ externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
+ portableUrl: '/static/shahrukh.jpg',
+ thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@shahrukh.jpg',
+ locked: false,
+ id: 'asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
+ },
+ {
+ displayName: 'IMG_5899.jpg',
+ contentType: 'image/jpeg',
+ dateAdded: 'Nov 16, 2021 at 18:55 UTC',
+ url: '/asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
+ externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
+ portableUrl: '/static/IMG_5899.jpg',
+ thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@IMG_5899.jpg',
+ locked: false,
+ id: 'asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
+ },
+ {
+ displayName: 'ccexample.srt',
+ contentType: 'application/octet-stream',
+ dateAdded: 'Nov 01, 2021 at 15:42 UTC',
+ url: '/asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
+ externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
+ portableUrl: '/static/ccexample.srt',
+ thumbnail: null,
+ locked: false,
+ id: 'asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
+ },
+ {
+ displayName: 'Tennis Ball.jpeg',
+ contentType: 'image/jpeg',
+ dateAdded: 'Aug 04, 2021 at 16:52 UTC',
+ url: '/asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
+ externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
+ portableUrl: '/static/Tennis_Ball.jpeg',
+ thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@Tennis_Ball-jpeg.jpg',
+ locked: false,
+ id: 'asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
+ },
+ ],
+ },
+});
+// eslint-disable-next-line
+export const fetchCourseDetails = ({ studioEndpointUrl, learningContextId }) => mockPromise({
+ data: {
+ // license: "creative-commons: ver=4.0 BY NC",
+ license: 'all-rights-reserved',
+ },
+});
+// eslint-disable-next-line
+export const checkTranscripts = ({youTubeId, studioEndpointUrl, blockId, videoId}) => mockPromise({
+ data: {
+ command: 'import',
+ },
+});
+// eslint-disable-next-line
+export const importTranscript = ({youTubeId, studioEndpointUrl, blockId}) => mockPromise({
+ data: {
+ edx_video_id: 'f36f06b5-92e5-47c7-bb26-bcf986799cb7',
+ },
+});
+// eslint-disable-next-line
+export const fetchAdvanceSettings = ({ studioEndpointUrl, learningContextId }) => mockPromise({
+ data: { allow_unsupported_xblocks: { value: true } },
+});
+// eslint-disable-next-line
+export const fetchVideoFeatures = ({ studioEndpointUrl }) => mockPromise({
+ data: {
+ allowThumbnailUpload: true,
+ videoSharingEnabledForCourse: true,
+ },
+});
+
+export const normalizeContent = ({
+ blockId,
+ blockType,
+ content,
+ learningContextId,
+ title,
+}) => {
+ let response = {};
+ if (blockType === 'html') {
+ response = {
+ category: blockType,
+ couseKey: learningContextId,
+ data: content,
+ has_changes: true,
+ id: blockId,
+ metadata: { display_name: title },
+ };
+ } else if (blockType === 'problem') {
+ response = {
+ data: content.olx,
+ category: blockType,
+ couseKey: learningContextId,
+ has_changes: true,
+ id: blockId,
+ metadata: { display_name: title, ...content.settings },
+ };
+ } else {
+ throw new TypeError(`No Block in V2 Editors named /"${blockType}/", Cannot Save Content.`);
+ }
+ return { ...response };
+};
+
+export const saveBlock = ({
+ blockId,
+ blockType,
+ content,
+ learningContextId,
+ studioEndpointUrl,
+ title,
+}) => mockPromise({
+ url: urls.block({ studioEndpointUrl, blockId }),
+ content: normalizeContent({
+ blockType,
+ content,
+ blockId,
+ learningContextId,
+ title,
+ }),
+});
+
+export const uploadAsset = ({
+ learningContextId,
+ studioEndpointUrl,
+ // image,
+}) => mockPromise({
+ url: urls.courseAssets({ studioEndpointUrl, learningContextId }),
+ asset: {
+ asset: {
+ display_name: 'journey_escape.jpg',
+ content_type: 'image/jpeg',
+ date_added: 'Jan 05, 2022 at 21:26 UTC',
+ url: '/asset-v1:edX+test101+2021_T1+type@asset+block@journey_escape.jpg',
+ external_url: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@journey_escape.jpg',
+ portable_url: '/static/journey_escape.jpg',
+ thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@journey_escape.jpg',
+ locked: false,
+ id: 'asset-v1:edX+test101+2021_T1+type@asset+block@journey_escape.jpg',
+ },
+ msg: 'Upload completed',
+ },
+});
+
+// TODO: update to return block data appropriate per block ID, which will equal block type
+// eslint-disable-next-line
+export const fetchStudioView = ({ blockId, studioEndpointUrl }) => {
+ let data = {};
+ if (blockId === 'html-block-id') {
+ data = {
+ data: 'Test prompt content
',
+ display_name: 'My Text Prompt',
+ metadata: {
+ display_name: 'Welcome!',
+ download_track: true,
+ download_video: true,
+ edx_video_id: 'f36f06b5-92e5-47c7-bb26-bcf986799cb7',
+ html5_sources: [
+ 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ ],
+ show_captions: true,
+ sub: '',
+ track: '',
+ transcripts: {
+ en: { filename: 'my-transcript-url' },
+ },
+ xml_attributes: {
+ source: '',
+ },
+ youtube_id_1_0: 'dQw4w9WgXcQ',
+ },
+ };
+ } else if (blockId === 'problem-block-id') {
+ data = {
+ data: `
+
+ 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.
+ Add the question text, or prompt, here. This text is required.
+ You can add an optional tip or note related to the prompt like this.
+
+ an incorrect answer
+ the correct answer
+ an incorrect answer
+
+
+ `,
+ 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.
+ >>Add the question text, or prompt, here. This text is required.||You can add an optional tip or note related to the prompt like this. <<
+ [[
+ an incorrect answer
+ (the correct answer)
+ an incorrect answer
+ ]]`,
+ attempts_before_showanswer_button: 7,
+ max_attempts: 5,
+ rerandomize: 'per_student',
+ show_reset_button: true,
+ showanswer: 'after_attempts',
+ submission_wait_seconds: 15,
+ weight: 29,
+ },
+ };
+ }
+
+ return mockPromise({
+ data: {
+ // The following is sent for 'raw' editors.
+ html: blockId.includes('mockRaw') ? 'data-editor="raw"' : '',
+ ...data,
+ },
+ });
+};
+
+export const emptyMock = () => mockPromise({});
diff --git a/src/editors/data/services/cms/mockVideoData.js b/src/editors/data/services/cms/mockVideoData.js
new file mode 100644
index 0000000000..a5a03da960
--- /dev/null
+++ b/src/editors/data/services/cms/mockVideoData.js
@@ -0,0 +1,57 @@
+import PropTypes from 'prop-types';
+import { LicenseTypes } from '../../constants/licenses';
+
+export const videoDataProps = {
+ videoSource: PropTypes.string,
+ videoId: PropTypes.string,
+ fallbackVideos: PropTypes.arrayOf(PropTypes.string),
+ allowVideoDownloads: PropTypes.bool,
+ allowVideoSharing: PropTypes.bool,
+ thumbnail: PropTypes.string,
+ transcripts: PropTypes.objectOf(PropTypes.string),
+ allowTranscriptDownloads: PropTypes.bool,
+ duration: PropTypes.shape({
+ startTime: PropTypes.number,
+ stopTime: PropTypes.number,
+ total: PropTypes.number,
+ }),
+ showTranscriptByDefult: PropTypes.bool,
+ handout: PropTypes.string,
+ licenseType: PropTypes.string,
+ licenseDetails: PropTypes.shape({
+ attribution: PropTypes.bool,
+ noncommercial: PropTypes.bool,
+ noDerivatives: PropTypes.bool,
+ shareAlike: PropTypes.bool,
+ }),
+};
+
+export const singleVideoData = {
+ videoSource: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ videoId: '7c12381b-6503-4d52-82bd-6ad01b902220',
+ fallbackVideos: [
+ 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ ],
+ allowVideoDownloads: true,
+ allowVideoSharing: true,
+ thumbnail: 'someString', // filename
+ transcripts: {
+ en: { filename: 'my-transcript-url' },
+ },
+ allowTranscriptDownloads: false,
+ duration: {
+ startTime: 0,
+ stopTime: 0,
+ total: 0,
+ },
+ showTranscriptByDefault: false,
+ handout: 'my-handout-url',
+ licenseType: LicenseTypes.creativeCommons,
+ licenseDetails: {
+ attribution: true,
+ noncommercial: false,
+ noDerivatives: false,
+ shareAlike: false,
+ },
+};
diff --git a/src/editors/data/services/cms/types.js b/src/editors/data/services/cms/types.js
new file mode 100644
index 0000000000..f35ec2441d
--- /dev/null
+++ b/src/editors/data/services/cms/types.js
@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import { ProblemTypes, ShowAnswerTypes } from '../../constants/problem';
+
+export const videoDataProps = {
+ videoSource: PropTypes.string,
+ videoId: PropTypes.string,
+ fallbackVideos: PropTypes.arrayOf(PropTypes.string),
+ allowVideoDownloads: PropTypes.bool,
+ allowVideoSharing: PropTypes.bool,
+ thumbnail: PropTypes.string,
+ transcripts: PropTypes.objectOf(PropTypes.string),
+ allowTranscriptDownloads: PropTypes.bool,
+ duration: PropTypes.shape({
+ startTime: PropTypes.number,
+ stopTime: PropTypes.number,
+ total: PropTypes.number,
+ }),
+ showTranscriptByDefult: PropTypes.bool,
+ handout: PropTypes.string,
+ licenseType: PropTypes.string,
+ licenseDetails: PropTypes.shape({
+ attribution: PropTypes.bool,
+ noncommercial: PropTypes.bool,
+ noDerivatives: PropTypes.bool,
+ shareAlike: PropTypes.bool,
+ }),
+};
+
+export const answerOptionProps = PropTypes.shape({
+ id: PropTypes.string,
+ title: PropTypes.string,
+ correct: PropTypes.bool,
+ feedback: PropTypes.string,
+ selectedFeedback: PropTypes.string,
+ unselectedFeedback: PropTypes.string,
+});
+
+export const problemDataProps = {
+ rawOLX: PropTypes.string,
+ problemType: PropTypes.instanceOf(ProblemTypes),
+ question: PropTypes.string,
+ answers: PropTypes.arrayOf(
+ answerOptionProps,
+ ),
+ settings: PropTypes.shape({
+ scoring: PropTypes.shape({
+ advanced: PropTypes.bool,
+ scoring: PropTypes.shape({
+ weight: PropTypes.number,
+ attempts: PropTypes.shape({
+ unlimited: PropTypes.bool,
+ number: PropTypes.number,
+ }),
+ }),
+ }),
+ hints: PropTypes.arrayOf(PropTypes.string),
+ timeBetween: PropTypes.number,
+ showAnswer: PropTypes.shape({
+ on: PropTypes.instanceOf(ShowAnswerTypes),
+ afterAtempts: PropTypes.number,
+ }),
+ showResetButton: PropTypes.bool,
+ defaultSettings: PropTypes.shape({
+ max_attempts: PropTypes.number,
+ showanswer: PropTypes.string,
+ show_reset_button: PropTypes.bool,
+ rerandomize: PropTypes.string,
+ }),
+ }),
+};
+
+export default {
+ videoDataProps,
+ problemDataProps,
+ answerOptionProps,
+};
diff --git a/src/editors/data/services/cms/urls.js b/src/editors/data/services/cms/urls.js
new file mode 100644
index 0000000000..de4e9f158a
--- /dev/null
+++ b/src/editors/data/services/cms/urls.js
@@ -0,0 +1,100 @@
+export const libraryV1 = ({ studioEndpointUrl, learningContextId }) => (
+ `${studioEndpointUrl}/library/${learningContextId}`
+);
+
+export const unit = ({ studioEndpointUrl, unitUrl, blockId }) => (
+ `${studioEndpointUrl}/container/${unitUrl.data.ancestors[0]?.id}#${blockId}`
+);
+
+export const returnUrl = ({
+ studioEndpointUrl, unitUrl, learningContextId, blockId,
+}) => {
+ 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)
+ // (temporary) don't throw error, just return empty url. it will fail it's network connection but otherwise
+ // the app will run
+ // throw new Error('Return url not available (or needed) for V2 libraries');
+ return '';
+ }
+ // when the learning context is a course, return to the unit page
+ // only do this for v1 blocks
+ if (unitUrl && blockId.includes('block-v1')) {
+ return unit({ studioEndpointUrl, unitUrl, blockId });
+ }
+ return '';
+};
+
+export const block = ({ studioEndpointUrl, blockId }) => (
+ blockId.startsWith('lb:')
+ ? `${studioEndpointUrl}/api/xblock/v2/xblocks/${blockId}/fields/`
+ : `${studioEndpointUrl}/xblock/${blockId}`
+);
+
+export const blockAncestor = ({ studioEndpointUrl, blockId }) => {
+ if (blockId.includes('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
+ // (temporary) don't throw error, just return empty url. it will fail it's network connection but otherwise
+ // the app will run
+ // throw new Error('Block ancestor not available (and not needed) for V2 blocks');
+ return '';
+};
+
+export const blockStudioView = ({ studioEndpointUrl, blockId }) => (
+ blockId.includes('block-v1')
+ ? `${block({ studioEndpointUrl, blockId })}/studio_view`
+ : `${studioEndpointUrl}/api/xblock/v2/xblocks/${blockId}/view/studio_view/`
+);
+
+export const courseAssets = ({ studioEndpointUrl, learningContextId }) => (
+ `${studioEndpointUrl}/assets/${learningContextId}/`
+);
+
+export const thumbnailUpload = ({ studioEndpointUrl, learningContextId, videoId }) => (
+ `${studioEndpointUrl}/video_images/${learningContextId}/${videoId}`
+);
+
+export const videoTranscripts = ({ studioEndpointUrl, blockId }) => (
+ `${block({ studioEndpointUrl, blockId })}/handler/studio_transcript/translation`
+);
+
+export const downloadVideoTranscriptURL = ({ studioEndpointUrl, blockId, language }) => (
+ `${videoTranscripts({ studioEndpointUrl, blockId })}?language_code=${language}`
+);
+
+export const mediaTranscriptURL = ({ studioEndpointUrl, transcriptUrl }) => (
+ `${studioEndpointUrl}${transcriptUrl}`
+);
+
+export const downloadVideoHandoutUrl = ({ studioEndpointUrl, handout }) => (
+ `${studioEndpointUrl}${handout}`
+);
+
+export const courseDetailsUrl = ({ studioEndpointUrl, learningContextId }) => (
+ `${studioEndpointUrl}/settings/details/${learningContextId}`
+);
+
+export const checkTranscriptsForImport = ({ studioEndpointUrl, parameters }) => (
+ `${studioEndpointUrl}/transcripts/check?data=${parameters}`
+);
+
+export const replaceTranscript = ({ studioEndpointUrl, parameters }) => (
+ `${studioEndpointUrl}/transcripts/replace?data=${parameters}`
+);
+
+export const courseAdvanceSettings = ({ studioEndpointUrl, learningContextId }) => (
+ `${studioEndpointUrl}/api/contentstore/v0/advanced_settings/${learningContextId}`
+);
+
+export const videoFeatures = ({ studioEndpointUrl }) => (
+ `${studioEndpointUrl}/video_features/`
+);
+
+export const courseVideos = ({ studioEndpointUrl, learningContextId }) => (
+ `${studioEndpointUrl}/videos/${learningContextId}`
+);
diff --git a/src/editors/data/services/cms/urls.test.js b/src/editors/data/services/cms/urls.test.js
new file mode 100644
index 0000000000..bbaf1cbcff
--- /dev/null
+++ b/src/editors/data/services/cms/urls.test.js
@@ -0,0 +1,192 @@
+import {
+ returnUrl,
+ unit,
+ libraryV1,
+ block,
+ blockAncestor,
+ blockStudioView,
+ courseAssets,
+ thumbnailUpload,
+ downloadVideoTranscriptURL,
+ videoTranscripts,
+ downloadVideoHandoutUrl,
+ courseDetailsUrl,
+ checkTranscriptsForImport,
+ replaceTranscript,
+ courseAdvanceSettings,
+ mediaTranscriptURL,
+ videoFeatures,
+ courseVideos,
+} from './urls';
+
+describe('cms url methods', () => {
+ const studioEndpointUrl = 'urLgoeStOstudiO';
+ const blockId = 'block-v1-blOckIDTeST123';
+ const v2BlockId = 'lb:blOckIDTeST123';
+ const learningContextId = 'lEarnIngCOntextId123';
+ const libraryLearningContextId = 'library-v1:libaryId123';
+ const courseId = 'course-v1:courseId123';
+ const libraryV1Id = 'lib-block-v1:libaryId123';
+ const libraryV2Id = 'lib:libaryId123';
+ const language = 'la';
+ const handout = '/aSSet@hANdoUt';
+ const videoId = '123-SOmeVidEOid-213';
+ const parameters = 'SomEParAMEterS';
+
+ describe('return to learning context urls', () => {
+ const unitUrl = {
+ data: {
+ ancestors: [
+ {
+ id: 'unItUrlTEST',
+ },
+ ],
+ },
+ };
+ it('returns the library page when given the v1 library', () => {
+ expect(returnUrl({ studioEndpointUrl, unitUrl, learningContextId: libraryLearningContextId }))
+ .toEqual(`${studioEndpointUrl}/library/${libraryLearningContextId}`);
+ });
+ // 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 empty url when given the v2 library', () => {
+ expect(returnUrl({ studioEndpointUrl, unitUrl, learningContextId: libraryV2Id }))
+ .toEqual('');
+ });
+ it('returnUrl function should return url with studioEndpointUrl, unitUrl, and blockId', () => {
+ expect(returnUrl({
+ studioEndpointUrl, unitUrl, learningContextId: courseId, blockId,
+ }))
+ .toEqual(`${studioEndpointUrl}/container/${unitUrl.data.ancestors[0].id}#${blockId}`);
+ });
+ it('returns blank url for v2 block', () => {
+ expect(returnUrl({
+ studioEndpointUrl, unitUrl, learningContextId: courseId, blockId: v2BlockId,
+ }))
+ .toEqual('');
+ });
+ it('throws error if no unit url', () => {
+ expect(returnUrl({ studioEndpointUrl, unitUrl: null, learningContextId: courseId }))
+ .toEqual('');
+ });
+ it('returns the library page when given the library', () => {
+ expect(libraryV1({ studioEndpointUrl, learningContextId: libraryV1Id }))
+ .toEqual(`${studioEndpointUrl}/library/${libraryV1Id}`);
+ });
+ it('unit function should return url with studioEndpointUrl, unitUrl, and blockId', () => {
+ expect(unit({ studioEndpointUrl, unitUrl, blockId }))
+ .toEqual(`${studioEndpointUrl}/container/${unitUrl.data.ancestors[0].id}#${blockId}`);
+ });
+ });
+ describe('block', () => {
+ it('returns v1 url with studioEndpointUrl and blockId', () => {
+ expect(block({ studioEndpointUrl, blockId }))
+ .toEqual(`${studioEndpointUrl}/xblock/${blockId}`);
+ });
+ it('returns v2 url with studioEndpointUrl and v2BlockId', () => {
+ expect(block({ studioEndpointUrl, blockId: v2BlockId }))
+ .toEqual(`${studioEndpointUrl}/api/xblock/v2/xblocks/${v2BlockId}/fields/`);
+ });
+ });
+ describe('blockAncestor', () => {
+ it('returns url with studioEndpointUrl, blockId and ancestor query', () => {
+ expect(blockAncestor({ studioEndpointUrl, blockId }))
+ .toEqual(`${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`);
+ });
+ // This test will probably be used in the future
+ // 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');
+ // });
+ it('returns blank url with studioEndpointUrl, v2 blockId and ancestor query', () => {
+ expect(blockAncestor({ studioEndpointUrl, blockId: v2BlockId }))
+ .toEqual('');
+ });
+ });
+ describe('blockStudioView', () => {
+ 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', () => {
+ it('returns url with studioEndpointUrl and learningContextId', () => {
+ expect(courseAssets({ studioEndpointUrl, learningContextId }))
+ .toEqual(`${studioEndpointUrl}/assets/${learningContextId}/`);
+ });
+ });
+ describe('thumbnailUpload', () => {
+ it('returns url with studioEndpointUrl, learningContextId, and videoId', () => {
+ expect(thumbnailUpload({ studioEndpointUrl, learningContextId, videoId }))
+ .toEqual(`${studioEndpointUrl}/video_images/${learningContextId}/${videoId}`);
+ });
+ });
+ describe('videoTranscripts', () => {
+ it('returns url with studioEndpointUrl and blockId', () => {
+ expect(videoTranscripts({ studioEndpointUrl, blockId }))
+ .toEqual(`${block({ studioEndpointUrl, blockId })}/handler/studio_transcript/translation`);
+ });
+ });
+ describe('downloadVideoTranscriptURL', () => {
+ it('returns url with studioEndpointUrl, blockId and language query', () => {
+ expect(downloadVideoTranscriptURL({ studioEndpointUrl, blockId, language }))
+ .toEqual(`${videoTranscripts({ studioEndpointUrl, blockId })}?language_code=${language}`);
+ });
+ });
+ describe('downloadVideoHandoutUrl', () => {
+ it('returns url with studioEndpointUrl and handout', () => {
+ expect(downloadVideoHandoutUrl({ studioEndpointUrl, handout }))
+ .toEqual(`${studioEndpointUrl}${handout}`);
+ });
+ });
+ describe('courseDetailsUrl', () => {
+ it('returns url with studioEndpointUrl and courseKey', () => {
+ expect(courseDetailsUrl({ studioEndpointUrl, learningContextId }))
+ .toEqual(`${studioEndpointUrl}/settings/details/${learningContextId}`);
+ });
+ });
+ describe('checkTranscriptsForImport', () => {
+ it('returns url with studioEndpointUrl and parameters', () => {
+ expect(checkTranscriptsForImport({ studioEndpointUrl, parameters }))
+ .toEqual(`${studioEndpointUrl}/transcripts/check?data=${parameters}`);
+ });
+ });
+ describe('replaceTranscript', () => {
+ it('returns url with studioEndpointUrl and parameters', () => {
+ expect(replaceTranscript({ studioEndpointUrl, parameters }))
+ .toEqual(`${studioEndpointUrl}/transcripts/replace?data=${parameters}`);
+ });
+ });
+ describe('courseAdvanceSettings', () => {
+ it('returns url with studioEndpointUrl and learningContextId', () => {
+ expect(courseAdvanceSettings({ studioEndpointUrl, learningContextId }))
+ .toEqual(`${studioEndpointUrl}/api/contentstore/v0/advanced_settings/${learningContextId}`);
+ });
+ });
+ describe('videoFeatures', () => {
+ it('returns url with studioEndpointUrl and learningContextId', () => {
+ expect(videoFeatures({ studioEndpointUrl }))
+ .toEqual(`${studioEndpointUrl}/video_features/`);
+ });
+ });
+ describe('courseVideos', () => {
+ it('returns url with studioEndpointUrl and learningContextId', () => {
+ expect(courseVideos({ studioEndpointUrl, learningContextId }))
+ .toEqual(`${studioEndpointUrl}/videos/${learningContextId}`);
+ });
+ });
+ describe('mediaTranscriptURL', () => {
+ it('returns url with studioEndpointUrl', () => {
+ const transcriptUrl = 'this-is-a-transcript';
+ expect(mediaTranscriptURL({ studioEndpointUrl, transcriptUrl }))
+ .toEqual(`${studioEndpointUrl}${transcriptUrl}`);
+ });
+ });
+});
diff --git a/src/editors/data/services/cms/utils.js b/src/editors/data/services/cms/utils.js
new file mode 100644
index 0000000000..ec9c567417
--- /dev/null
+++ b/src/editors/data/services/cms/utils.js
@@ -0,0 +1,24 @@
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+/**
+ * get(url)
+ * simple wrapper providing an authenticated Http client get action
+ * @param {string} url - target url
+ */
+export const get = (...args) => getAuthenticatedHttpClient().get(...args);
+/**
+ * post(url, data)
+ * simple wrapper providing an authenticated Http client post action
+ * @param {string} url - target url
+ * @param {object|string} data - post payload
+ */
+export const post = (...args) => getAuthenticatedHttpClient().post(...args);
+/**
+ * delete(url, data)
+ * simple wrapper providing an authenticated Http client delete action
+ * @param {string} url - target url
+ * @param {object|string} data - delete payload
+ */
+export const deleteObject = (...args) => getAuthenticatedHttpClient().delete(...args);
+
+export const client = getAuthenticatedHttpClient;
diff --git a/src/editors/data/services/cms/utils.test.js b/src/editors/data/services/cms/utils.test.js
new file mode 100644
index 0000000000..19aef7a360
--- /dev/null
+++ b/src/editors/data/services/cms/utils.test.js
@@ -0,0 +1,33 @@
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import * as utils from './utils';
+
+jest.mock('@edx/frontend-platform/auth', () => ({
+ getAuthenticatedHttpClient: jest.fn(),
+}));
+
+describe('cms service utils', () => {
+ describe('get', () => {
+ it('forwards arguments to authenticatedHttpClient().get', () => {
+ const get = jest.fn((...args) => ({ get: args }));
+ getAuthenticatedHttpClient.mockReturnValue({ get });
+ const args = ['some', 'args', 'for', 'the', 'test'];
+ expect(utils.get(...args)).toEqual(get(...args));
+ });
+ });
+ describe('post', () => {
+ it('forwards arguments to authenticatedHttpClient().post', () => {
+ const post = jest.fn((...args) => ({ post: args }));
+ getAuthenticatedHttpClient.mockReturnValue({ post });
+ const args = ['some', 'args', 'for', 'the', 'test'];
+ expect(utils.post(...args)).toEqual(post(...args));
+ });
+ });
+ // describe('deleteObject', () => {
+ // it('forwards arguments to authenticatedHttpClient().delete', () => {
+ // const deleteObject = jest.fn((...args) => ({ delete: args }));
+ // getAuthenticatedHttpClient.mockReturnValue({ deleteObject });
+ // const args = ['some', 'args', 'for', 'the', 'test'];
+ // expect(utils.deleteObject(...args)).toEqual(deleteObject(...args));
+ // });
+ // });
+});
diff --git a/src/editors/data/store.js b/src/editors/data/store.js
new file mode 100755
index 0000000000..a10b1ba28f
--- /dev/null
+++ b/src/editors/data/store.js
@@ -0,0 +1,32 @@
+import * as redux from 'redux';
+import thunkMiddleware from 'redux-thunk';
+import { composeWithDevToolsLogOnlyInProduction } from '@redux-devtools/extension';
+import { createLogger } from 'redux-logger';
+
+import reducer, { actions, selectors } from './redux';
+
+export const createStore = () => {
+ const loggerMiddleware = createLogger();
+
+ const middleware = [thunkMiddleware, loggerMiddleware];
+
+ const store = redux.createStore(
+ reducer,
+ composeWithDevToolsLogOnlyInProduction(redux.applyMiddleware(...middleware)),
+ );
+
+ /**
+ * Dev tools for redux work
+ */
+ if (process.env.NODE_ENV === 'development') {
+ window.store = store;
+ window.actions = actions;
+ window.selectors = selectors;
+ }
+
+ return store;
+};
+
+const store = createStore();
+
+export default store;
diff --git a/src/editors/data/store.test.js b/src/editors/data/store.test.js
new file mode 100644
index 0000000000..6b8c851d94
--- /dev/null
+++ b/src/editors/data/store.test.js
@@ -0,0 +1,66 @@
+import { applyMiddleware } from 'redux';
+import thunkMiddleware from 'redux-thunk';
+import { composeWithDevToolsLogOnlyInProduction } from '@redux-devtools/extension';
+import { createLogger } from 'redux-logger';
+
+import rootReducer, { actions, selectors } from './redux';
+
+import exportedStore, { createStore } from './store';
+
+jest.mock('./redux', () => ({
+ __esModule: true,
+ default: 'REDUCER',
+ actions: 'ACTIONS',
+ selectors: 'SELECTORS',
+}));
+
+jest.mock('redux-logger', () => ({
+ createLogger: () => 'logger',
+}));
+jest.mock('redux-thunk', () => 'thunkMiddleware');
+jest.mock('redux', () => ({
+ applyMiddleware: (...middleware) => ({ applied: middleware }),
+ createStore: (reducer, middleware) => ({ reducer, middleware }),
+}));
+jest.mock('@redux-devtools/extension', () => ({
+ composeWithDevToolsLogOnlyInProduction: (middleware) => ({ withDevTools: middleware }),
+}));
+
+describe('store aggregator module', () => {
+ describe('exported store', () => {
+ it('is generated by createStore', () => {
+ expect(exportedStore).toEqual(createStore());
+ });
+ it('creates store with connected reducers', () => {
+ expect(createStore().reducer).toEqual(rootReducer);
+ });
+ describe('middleware', () => {
+ it('exports thunk and logger middleware, composed and applied with dev tools', () => {
+ expect(createStore().middleware).toEqual(
+ composeWithDevToolsLogOnlyInProduction(applyMiddleware(thunkMiddleware, createLogger())),
+ );
+ });
+ });
+ });
+ describe('dev exposed tools', () => {
+ beforeEach(() => {
+ window.store = undefined;
+ window.actions = undefined;
+ window.selectors = undefined;
+ });
+ it('exposes redux tools if in development env', () => {
+ process.env.NODE_ENV = 'development';
+ const store = createStore();
+ expect(window.store).toEqual(store);
+ expect(window.actions).toEqual(actions);
+ expect(window.selectors).toEqual(selectors);
+ });
+ it('does not expose redux tools if in production env', () => {
+ process.env.NODE_ENV = 'production';
+ createStore();
+ expect(window.store).toEqual(undefined);
+ expect(window.actions).toEqual(undefined);
+ expect(window.selectors).toEqual(undefined);
+ });
+ });
+});
diff --git a/src/editors/decisions/0001-library-adr.rst b/src/editors/decisions/0001-library-adr.rst
new file mode 100644
index 0000000000..911bda1056
--- /dev/null
+++ b/src/editors/decisions/0001-library-adr.rst
@@ -0,0 +1,10 @@
+This library is meant for high-level components related to "content" types, which sometimes correspond to xblocks --- so videos, html, problems, etc.
+ - This is a higher-level layer on top of paragon.
+
+The components may be shared across multiple frontends.
+ - For example, components may be used by both the learning MFE, the course authoring MFE, and the shared content MFE.
+ - Video-playing components may be used for previewing in editing contexts, etc.
+
+The components cover many types of content, not just a single one.
+ - For example, HTML editing frontend might share code and style with video editing or problem editing.
+ - This will also help keep style and accessibility consistent across different content types.
diff --git a/src/editors/decisions/0002-text-editor-adr.md b/src/editors/decisions/0002-text-editor-adr.md
new file mode 100644
index 0000000000..34b3cc1d59
--- /dev/null
+++ b/src/editors/decisions/0002-text-editor-adr.md
@@ -0,0 +1,69 @@
+# Status
+
+Accepted
+
+# Context
+
+We seek to hoist the legacy text (HTML) text XBlock editor out of the monolith into a decoupled React application
+without reducing editor capabilities in the process.
+
+The legacy editor delegates the bulk of its operations to the [TinyMCE](https://www.tiny.cloud/docs/tinymce/6/) editor.
+Per the link, TinyMCE is "a rich-text editor that allows users to create formatted content within
+a user-friendly interface".
+
+# Decision set (each decision small)
+
+1. Rely on cross-editor XBlock network access support, per "Network Request Layer" ADR in this repo
+2. Continue using the tinyMCE editor
+3. Wrap the tinyMCE editor so as to be able to handle it as a vanilla React component
+4. Use a mix of configuration and tinyMCE API calls to customize the editor as needed
+ * (e.g., offer image upload and image gallery selection)
+
+# Consequences
+
+## Rely on editors-wide XBlock API (network access) decision
+No complexity associated with CRUD operations on XBlock content, irrespective of where the XBlocks are stored.
+
+## Continue using the tinyMCE editor
+
+Future customizations might not be possible if look-and-feel configurations offered by tinyMCE prove inadequate, or if the tinyMCE API falls short. For current requirements this was not a showstopper.
+
+# Wrap the tinyMCE editor for React use
+
+Wrapping of the tinyMCE editor to behave like a react component is done by the simple expedient of having `TextEditor.jsx` import the [tinymce/tinymce-react](https://github.com/tinymce/tinymce-react) repo and then interacting with the Editor component defined there.
+
+Interaction with the tinyMCE editor is via a reference, saved at editor initialization.
+
+When wrapping the tinyMCE component we assign a `ref` to the created component so that we can access its state and apis to update the content and draw it back out.
+
+* initialize with html content from xblock
+
+# Editor customization
+
+Because the tinyMCE instance is the core of the TextEditor experience (pretty much the whole thing), and it is not a custom-configuratble UI,
+the configuration hook provides much of our control of the UI.
+However, because TinyMCE provides extensible controls, we have a few internal-written widgets we have embedded into the workflow,
+specifically including the image upload/settings modal.
+
+Much of the editor configuration is actually defined in `TextEditor/pluginConfig.js`, and the editorHook mostly draws from this configuration to populate the editor."
+
+## Image upload/gallery selection
+The tinyMCE editor is extended to offer an image upload/select button. When the end-user pushes this button, the editor's window is occluded by a modal dialog capable of uploading an image. The end-user can also interact with this modal window to toggle between uploading a new image, or selecting from among previously uploaded images (aka, the image gallery), to save, or to cancel out.
+
+On its initialization, the tinyMCE editor is provided with an icon and a callback to associate with the image upload button.
+
+* Tiny MCE needs a "configured" button to open the modal dialog to select/upload an image.
+ * This verbiage is somewhat important distinction, because this is a configuration, not a coding feature
+
+Later, on invocation of the modal dialog, this window is initialized with a reference to the tinyMCE editor.
+If the modal window is driven to a save operation on an uploaded or a selected image, the window uses this reference to provide an image tag and image metadata to the tinyMCE editor, for inclusion at the current cursor location.
+
+The wrapping modal around upload/settings has a hook that calls the editor execCommand to insert the image tag on a button click.
+The hook runs before everything renders and produces a button callback that will save a passed image to the (_sic_ tinyMCE) editor context
+
+* [on image upload or gallery selection] insert image tag at cursor location with source, dimensions, and alt text
+* [on image update] Update and replace a given image tag selection with a new one, updating source, dimensions, and/or alt-text
+ * Update and replace are utilizing exposed tinyMCE editor api accessed from the ref associated with the created component.
+* Modal must have a "Save" option that inserts appropriately formatted tags into the tinyMCE editor context.
+ * Does not always update on relinquishing control, and communicates nothing on cancel
+
diff --git a/src/editors/decisions/0003-V2-Content-Editors.rst b/src/editors/decisions/0003-V2-Content-Editors.rst
new file mode 100644
index 0000000000..40318ba01f
--- /dev/null
+++ b/src/editors/decisions/0003-V2-Content-Editors.rst
@@ -0,0 +1,68 @@
+V2 Content Editors
+
+Synopsis
+--------
+
+We have created a framework for creating improved editor experiences for existing xblocks. We call these new editors V2 Content Editors.
+V2 Content Editors replace existing xblock editing experiences using redirection.
+The V2 Editor framework allows for the easy creation and configuration of new editors through automated boilerplate generation, simple networking and state abstractions, and premade components for basic editing views.
+
+Decisions
+------
+
+I. All V2 content editors shall live in this repository. This choice was made as the existing xblock framework is not amenable to running modern React applications for studio views, and cannot be upgraded to do so without herculanean effort.
+
+II. These editors will be served to the user as if an overlay on the existing editing experience for a particular xblock. This shall occur by this library's editor comoponent being served from a learning context MFE (Library or Course Authoring).
+
+III. The Editor component is loaded into a learning context authoring tool (eg. Course or Library Authoring) from this JS library. This component then serves the correct editor type based on the xblock id it is provided.
+
+IV. Editors for a specific xblock are then provided with the relevant data and metadata of that xblock instance, and their code is run to provide the experience.
+
+V. The following process was implemented to inject this flow into Studio.
+
+ For entering an editor page: Users click on the "edit xblock" button, are redirected to the course authoring MFE, where they are presented with the relevant editor.
+
+ .. image:: https://user-images.githubusercontent.com/49422820/166940630-51dfc25e-c760-4118-b4dd-ae1fa7fa73b9.png
+
+ For saving content: Once inside the editor, clicking save saves the content to the xblock api and returns the user to the course authoring context.
+
+ .. image:: https://user-images.githubusercontent.com/49422820/166940624-068e8446-0c86-4c24-a2dd-3eb474984f08.png
+
+ For exiting without saving: The user is simply transported back to the course authoring context.
+
+ .. image:: https://user-images.githubusercontent.com/49422820/166940617-80455ade-0a5e-4e61-94b0-b9e2d7a0531e.png
+
+VI. The library provides prebuilt components and features to accomplish common editor tasks.
+ - The EditorContainer component makes for easy saving, canceling changes, and xblock title editing.
+ - An app-level abstraction for network requests and handling thier states. This is the /Requests layer in the redux store. More information will be contained in ADR 0004 Network Request Layer
+VII. There are several patterns and principles along which the V2 editors are built. Additional editors are not required to follow these, but it is strongly encouraged. Theses are:
+ - Following the Testing and Implementation ADR.
+ - Generalize components for reuse when possible.
+ - Use Redux for global state management.
+
+VIII. How to create, configure, and enable a new editor experience will exist in other documentation, but should rely on automated configuration.
+
+Status
+------
+
+Adopted
+
+Context
+-------
+
+We need self-contained xblock editing and configuration experiences. Changing requirements require that that experience be modernized to use Paragon, work across authoring for different learning contexts (course authoring and library authoring), and be flexible, extensible and repeatable.
+
+Carving experiences out of Studio is an architectural imperative. Editing, as xblocks are discrete pieces of content, can exist in a context independent of the learning context, so having a learning-context agnostic environment for editing makes sense.
+
+Consequences
+------------
+
+This design has several consequences. These consequences are the result of the favoring of incremental changes, which can be iterated upon as other improvements in the openedx ecosystem occur.
+
+The majority of the impactful consequences have to do with the architectural choice to NOT simply upgrade the capabilities of xblock rendering, and instead serve the new experiences from a separate library. The fallout of these design choices leads to architectural complexity, but also the ability to deliver value in new ways.
+
+For example, locating the V2 editor in frontend-lib-content-components outside of the xblock code leaves no clear solution for easy open-source extension of V2 editors. This choice, however, also allows us to easily serve library and course contexts and leads to the easier creation of common content react components.
+
+In addition, this also allows developers to add value to editors, without having to rewrite the course-authoring experience to leverage React. Indeed, even when course authoring moves into an MFE, it will be trivial to place the editor inside the editor.
+
+This choice, however, is not intended to be final. Instead, this library can become merely a set of tools and common components, and once xblock editor views are Reactified, we can very easily restore the abstraction that all xblock code lives with the xblock. It is in this spirit of providing incremental value that we provided this choice.
diff --git a/src/editors/decisions/0004-Network-Request-Layer.rst b/src/editors/decisions/0004-Network-Request-Layer.rst
new file mode 100644
index 0000000000..2c384827b6
--- /dev/null
+++ b/src/editors/decisions/0004-Network-Request-Layer.rst
@@ -0,0 +1,51 @@
+Network and Requests Layer
+
+Synopsis
+--------
+
+For V2 Content Editors, we have defined a general abstraction for basic editor actions and content retrieval. This abstraction is twofold: a defined set of general “app” actions for basic editor actions, and a Requests Layer to track the status of ALL network requests.
+
+This will be a powerful tool to speed up the creation of new editors.
+
+
+
+Decision
+------
+
+The common actions required for any V2 content editor are as follows:
+Retrieve an xblock
+Save an xblock to the xblock api in the CMS.
+Return to your learning context (Studio, Course Authoring, Library Authoring)
+Obtain content (video, files, images) from the contentstore associated with a learning context.
+
+We have implemented actions to perform those tasks in src/editors/data/redux/thunkActions/app.js. These actions are decoupled from the code of a specific editor, and are easily portable across editors.
+
+We have also defined an atomic method to track the lifecycle of a network action. This abstraction applies to these common actions, as well as any actions defined in the data layer of a particular V2 editor.
+
+The lifecycle of the acquisition of data from network and the updating of the global state with that data is termed to be a "request." The "states" of the lifecycle associated with a request are [inactive, pending, completed, failed]. This lifecycle provides information to the Redux consumer as to the status of their data.
+
+Each unique request instance is given a key in `src/editors/data/constants/requests`. This key can be queried to ascertain the status of the request using a Redux selector by a consumer of the redux state. This allows for easy conditional rendering. By following this pattern, additional async actions will be easy to write.
+
+The individual api methods are all defined in `data/services/cms/api`. The goal of the `requests` thunkActions is to first route the appropriate store data to the api request based on how they are being called.
+
+The actual chain the an example request to save an xblock code is:
+
+`thunkActions/app:saveBlock` -> `thunkActions/requests:saveBlock` `services/cms/api:saveBlock`
+
+* The "app" thunk action updates the local block content, and then dispatches the request thunkAction
+* The "request" thunkAction then loads relevant redux data for the save event and calls the api.saveBlock method, wrapped such that the UI can track the request state
+* The "api" method provides the specifics for the actual network request, including prop format and url."
+
+Status
+------
+Adopted
+
+Context
+-------
+
+In building React Redux applications, asynchronous actions require a set of "Thunk" actions dispatched at relevant points. A common standard around the lifecycle helps prevent the boilerplate for these actions to spiral. In addition, it allows for the faster development of new V2 editors, as developers have easily usable Redux actions to dispatch, as well as Redux selectors to track the status of their requests.
+
+Consequences
+------------
+
+Network-based CRUD actions have a common language of lifecycle, as well as a common pattern to implement, allowing developers to use ready-made requests without issue for common actions, like xblock saving, content store retrieval, and even outside api access. This improves ease of use, as well as readability and uniformity.
diff --git a/src/editors/decisions/0005-internal-editor-testability-decisions.md b/src/editors/decisions/0005-internal-editor-testability-decisions.md
new file mode 100644
index 0000000000..dc6e9e76bd
--- /dev/null
+++ b/src/editors/decisions/0005-internal-editor-testability-decisions.md
@@ -0,0 +1,136 @@
+# Internal editor testability decision
+
+# Increased complexity for the sake of testability
+The internally-managed editors in this repo (as of now planned to include text, video, and problem types) follow a number of patterns that increase the complexity of parts of the code slightly, in favor of providing increased testability around their behavior.
+
+## Note - Strict Dictionaries
+Javacript is generally fairly lackadaisical with regards to dictionary access of missing/invalid keys. This is fine and expected in many cases, but also prevents us from using dictionary access on something like a key store to ensure we are calling something that actually exists.
+
+For this purpose, there are a pair of utilities in this repo called `StrictDict` and `keyStore`.
+
+`StrictDict` takes an object and returns a version that will complain (throw an error) if called with an invalid key.
+
+`keyStore` takes an object and returns a StrictDict of just the keys of that object. (this is useful particularly for mocking and spying on specific methods and fields)
+
+## Note - Self imports
+Javascript webpack imports can be problematic around the specific issue of attempting to mock a single method being used in another method in the same file.
+
+Problem: File A includes methodA and methodB (which calls methodA). We want to be able to test methodA and then test methodB *without* having to re-test methodA as part of that test. We want to be able to mock methodA *only* while we are testing methodB.
+
+Solution: Self-imports. By importing the module into itself (which webpack handles nicely, don't worry), we provide tests the ability to spy on and mock individual methods from that module separately on a per-test basis.
+
+Ex:
+```javascript
+// myFile.js
+import * as module from './myFile';
+
+export const methodA = (val) => // do something complex with val and return a number
+export const methodB = (val) => module.methodA(val) * 2;
+
+// myFile.test.js
+import * as module from './myFile';
+import { keyStore } from './utils';
+
+cosnt moduleKeys = keyStore(module);
+
+describe('myFile', () => {
+ describe('methodA', () => ...);
+ describe('methodB', () => {
+ const mockMethodA = (val) => val + 3
+ const testValue = 23;
+ beforeEach(() => {
+ jest.spyOn(module, moduleKeys).mockImplementationValueOnce(mockMethodA);
+ });
+ it('returns double the output of methodA with the given value', () => {
+ expect(module.methodB(testValue)).toEqual(mockMethodA(testValue) + 3);
+ });
+ });
+});
+```
+
+## Hooks and Snapshots - Separation from components for increased viability of snapshots
+As part of the testing of these internal editors, we are relying on snapshot testing to ensure stability of the display of the components themselves. This can be a fragile solution in certain situations where components are too large or complex to adequately snapshot and verify.
+
+For this purpose, we have opted for a general pattern of separating all of the behavior of components withing these editors into separate `hooks` files.
+
+These hook files contain methods that utilize both `react` and `react-redux` hooks, along with arguments passed directly into the component, in order to generate the resulting desired behaviors.
+
+From there, components are tested by mocking out the behavior of these hooks to return verifyable data in the snapshots.
+
+As part of this separation, there are a number of additional patterns that are followed
+
+### Snapshot considerations
+#### Callbacks
+Any callback that is included in render in a component should be separated such that is is either passed in as a prop or derived from a hook, and should be mocked with a `mockName` using jest, to ensure that they are uniquely identifyable in the snapshots.
+
+Ex:
+```javascript
+const props = {
+ onClick: jest.fn().mockName('props.onClick');
+}
+expect(shallow( )).toMatchSnapshot();
+```
+
+#### Imported components
+Imported compoents are mocked to return simple string components based on their existing name. This results in shallow renders that display the components by name, with passed props, but do not attempt to render *any* logic from those components.
+
+This is a bit more complex for components with sub-components, but we have provided a test utility in `src/testUtils` called `mockNestedComponent` that will allow you to easily mock these for your snapshots as well.
+
+Ex:
+```javascript
+jest.mock('componentModule', () => {
+ const { mockNestedComponent } = jest.requireActual('./testUtils');
+ return {
+ SimpleComponents: () => 'SimpleComponent',
+ NestedComponent: mockNestedComponent('NestedComponent', {
+ NestedChild1: 'NestedComponent.NestedChild1',
+ NestedChild2: 'NestedComponent.NestedChild2',
+ }),
+});
+```
+#### Top-level mocked imports
+We have mocked out all paragon components and icons being used in the repo, as well as a number of other common methods, hooks, and components in our module's `setupTests` file, which will ensure that those components show up reasonably in snapshots.
+
+### Hook Considerations
+#### useState and mockUseState
+React's useState hook is a very powerful alternative to class components, but is also somewhat problematic to test, as it returns different values based on where it is called in a hook, as well as based on previous runs of that hook.
+
+To resolve this, we are using a custom test utility to mock a hook modules state values for easy testing.
+
+This requires a particular structure to hook modules that use the useState, for the sake of enabling the mock process (which is documented with the utility).
+
+Ex:
+```javascript
+import * as module from './hooks';
+const state = {
+ myStateValue: (val) => useState(val),
+};
+const myHook = () => {
+ const [myStateValue, setMyStateValue] = module.state.myStateValue('initialValue');
+};
+```
+Examples on how to use this for testing are included with the mock class in `src/testUtils`
+
+#### useCallback, useEffect
+These hooks provide behavior that calls a method based on given prerequisite behaviors.
+For this reason, we use general-purpose mocks for these hooks that return an object containing the passed callback and prerequisites for easy test access.
+
+#### Additional Considrations
+*useIntl not available*
+
+We are using react-intl under the hood for our i18n support, but do not have access to some of the more recent features in that library due to the pinned version in frontend-platform. Specifically, this includes a `useIntl` hook available in later versions that is still unavailable to us, requiring us to use the older `injectIntl` pattern.
+
+*useDispatch*
+
+React-redux's `useDispatch` hook does not play nicely with being called in a method called by a component, and really wants to be called *in* the component. For this reason, the dispatch method is generated in components and passed through to hook components.
+
+## Notes for integration testing
+Because of the top-level mocks in setupTest, any integration tests will need to be sure to unmock most of these.
+
+Ex:
+```javascript
+jest.unmock('@edx/frontend-platform/i18n');
+jest.unmock('@openedx/paragon');
+jest.unmock('@openedx/paragon/icons');
+jest.unmock('react-redux');
+```
diff --git a/src/editors/decisions/0006-test-names.rst b/src/editors/decisions/0006-test-names.rst
new file mode 100644
index 0000000000..e079bcab96
--- /dev/null
+++ b/src/editors/decisions/0006-test-names.rst
@@ -0,0 +1,57 @@
+Test Names
+
+Synopsis
+--------
+
+Tests with descriptive names are a great way to document what a function does and what can go wrong.
+On the other hand, unclear test names can pose a risk, because if a test is faulty, you cannot see from the test name
+what the test intends, so it is difficult to identify faulty tests.
+
+We are setting up some conventions for naming of tests.
+
+Decisions
+---------
+
+1. The name of your test should consist of three parts:
+
+ - The name of the unit and/or method being tested.
+ - The scenario / context under which it's being tested.
+ - The expected behavior when the scenario is invoked.
+
+2. Use nested `describe` blocks to describe, unit, method, and scenario under test.
+3. Use a `test` statement for the expected behavior.
+4. A good test statement tests a single behavior in a single scenario for a single method.
+5. Avoid the word "test" in your test name.
+6. A test name describes an expectation. Use either an expectational statement using the "should" keyword or a factual statement. Do not use patterns like imperatives.
+
+ - Good: "function add() should calculate the sum of two numbers".
+ - Good: "function add() calculcates the sum of two numbers".
+ - Bad: "test function add() for two numbers". (Imperative voice)
+
+7. Aim to write test names as full meaningful sentences. A test name consists of a few pieces: some describe statements and a test statements.
+When running tests, they will be concatenated to the test name. An example: ::
+
+ // name of the unit under test
+ describe('calculator', () => {
+ ...
+ // name of the method under test
+ describe('add() function', () => {
+ ...
+ // The scenario / context under which it's being tested
+ describe('with invalid settings', () => {
+ ...
+ // The expected behavior when the scenario is invoked
+ it('should throw a descriptive error', () => {
+ ...
+ }
+ }
+ }
+ }
+
+
+This results in the full meaningful sentence: "calculator add() function with invalid settings should throw a descriptive error".
+
+Further reading:
+----------------
+
+https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-best-practices
\ No newline at end of file
diff --git a/src/editors/decisions/0007-feature-based-application-organization.rst b/src/editors/decisions/0007-feature-based-application-organization.rst
new file mode 100644
index 0000000000..559becda8b
--- /dev/null
+++ b/src/editors/decisions/0007-feature-based-application-organization.rst
@@ -0,0 +1,212 @@
+7. Feature-based Application Organization
+-----------------------------------------
+
+Status
+------
+
+Accepted
+
+Context
+-------
+
+The common, naive approach to organizing React/Redux applications says that code should be grouped into folders by type, putting like-types of code together. This means having directories like:
+
+- components
+- actions
+- reducers
+- constants
+- selectors
+- sagas
+- services
+
+This is often referred to as a "Ruby on Rails" approach, which organizes applications similarly.
+
+As applications grow, it's acknowledged by the community that this organization starts to fall down and become difficult to maintain. It does nothing to help engineers keep their code modular and decoupled, as it groups code by how it looks and not how it's used. Code that functions as part of a unit is spread out over 7+ directories.
+
+This ADR documents an approach and rules of thumb for organizing code modularly by feature, informed by articles and prior art.
+
+Note on terminology: "feature" and "module" are used interchangeably in this ADR. In general, the feature refers to the semantically significant thing, whereas the module refers to the directory of code pertaining to that feature.
+
+Decision
+--------
+
+**Following the spirit of these principles is more important than following them to the letter.**
+
+These rules are guidelines. It won't always be reasonable or necessary to follow them to the letter. They provide a set of tools for dealing with complexity. It follows, then, that if you don't have complexity, then you may not need the tools.
+
+Primary guiding principles
+==========================
+
+**1. Code is organized into feature directories.**
+
+A feature is a logical or semantically significant grouping of code; what comprises a feature is subjective. It also may not be obvious at first - if code tends to be related or change together, then it's probably part of the same feature.
+
+It's unlikely to be worth agonizing over your feature breakdown; time will tell what's correct moreso than overthinking it. That said, a sufficiently complex set of features will need a similarly robust taxonomy and organizational hierarchy. (This document endeavors to help inform that hierarchy.) A nice rule of thumb is that a feature should conceptually be able to be extracted into its own npm package with minimal effort.
+
+**2. Create strict module boundaries**
+
+A module should have a public interface exposed via an index.js file in the module directory. Consumers of a feature should limit themselves to importing only from the public exports.
+
+::
+
+ import { MyComponent, reducer as myComponentReducer } from './submodule'; // Good
+ import MyComponent from './submodule/MyComponent'; // Bad
+ import reducer from './submodule/data/reducers'; // Bad
+
+Modules are configured by their parent. Generally a module will expose a few things which need to be configured make use of them in the consuming code. The reason for doing this is so that the module doesn't make assumptions about it's context (effectively dependency injection).
+
+Examples:
+
+* For React components, this involves including them in JSX and giving them props.
+* For services, this is calling their "configure" method and providing them apiClient/configuration, etc.
+* For reducers, this is mounting the reducer at an agreed-upon place in the redux store's state tree.
+
+**3. Avoid circular dependencies**
+
+Circular dependencies are unresolvable in webpack, and will result in something being imported as 'undefined'. They're also incredibly difficult and frustrating to track down. Properly factoring your features and supporting modules should help avoid these sorts of issues. In general, a feature should never need to import from its parent or grandparents, and a more "general purpose" module should never be importing from a more specific one. If you find yourself importing from a domain-specific feature in your general utility module, then something is probably ill-factored.
+
+File and directory naming
+=========================
+
+This section details a specific taxonomy and hierarchy to help make code modular, approachable and maintainable.
+
+**A. Separate data management from components.**
+
+In order to isolate our view layer (React) from the management of our data, global state, APIs, and side effects, we want to adopt the "ducks" organization (see references). This involves isolating data management into a
+sub directory of a feature. We'll use the directory name "data" rather than the traditional "ducks".
+
+**C. React components will be named semantically.**
+
+The convention for React components is for the file to be named for what the component does, so we will preserve this. A given feature may break up its view layer into multiple sub-components without a sub-feature being present.
+
+**B. Files in a module's data directory are named by function.**
+
+In the data sub-directory, the file names describe what each piece of code does. Unlike React components, all of the data handlers (actions, reducers, sagas, selectors, services, etc.) are generally short functions, and so we put them all in the same file together with others of their kind.
+
+::
+
+ /profile
+ /index.js // public interface
+ /ProfilePage.jsx // view
+ /ProfilePhotoUploader.jsx // supporting view
+ /data // Note: most files here are named with a plural, as they contain many of the things in question.
+ /actions.js
+ /constants.js
+ /reducers.js
+ /sagas.js
+ /selectors.js
+ /service.js // Note: singular - there's one 'service' here that provides many methods.
+
+If you find yourself desiring to have multiple files of a particular type in the data directory, this is a strong sign that you actually need a sub-feature instead.
+
+**C. Sub-features follow the same naming scheme.**
+
+Sub-features should follow the same rules as any other module.
+
+A module with a sub-module:
+
+::
+
+ /profile
+ /index.js // public interface
+ /ProfilePage.jsx
+ /Avatar.jsx // additional components for a feature reside here at the top level, not in a "components" subdirectory.
+ /data
+ /actions.js
+ /reducers.js
+ /sagas.js
+ /service.js
+ /profile-photo
+ /index.js // public interface
+ /ProfilePhoto.jsx
+ /data
+ /actions.js
+ /reducers.js
+ /selectors.js
+ /education // Sparse sub-module
+ /index.js // public interface
+ /Education.jsx
+ /site-language // No view layer sub-module
+ /index.js // public interface
+ /data
+ /actions.js
+ /reducers.js
+
+Note that a given feature need not contain files of all types, nor is having files of all types a prerequisite for having a feature. A feature may not contain a view (Component) layer, or in contrast to that, may not need a data directory at all!
+
+Importing rules of thumb
+========================
+
+It can be difficult to figure out where it's okay to import from. Following these rules of thumb will help maintain a healthy code organization and should prevent the possibility of circular dependencies.
+
+**I. A feature may not import from its parentage.**
+
+As described above in "Avoid circular dependencies", features should not import from their parent, grandparent, etc. A feature should be agnostic to the context in which it is used. If a module is importing from its parent or grandparent, that implies something is ill-factored.
+
+**II. A feature may import from its children, but not its grandchildren.**
+
+The feature may only import from the exports of its child, which may include exports of the grandchildren. Importing directly from grandchildren (or great grandchildren, etc.) would violate the strict module boundary of the child.
+
+**II. Features may import from their siblings.**
+
+It's acceptable to import from a module's siblings, or the siblings of their parents, grandparents, etc. This is necessary to support code re-use. As an example, assume we have a sub-module with common code to support our web forms.
+
+::
+
+ /feature1
+ /sub-form-1
+ /sub-form-2
+ /forms-common-code
+
+The sub-form modules can import from forms-common-code. The latter has its own strict module boundary and could conceptually be extracted into its own repository/completely independent module as far as they're concerned. They're unaware, conceptually, that it's a child of feature1, and they don't care.
+
+**III. Features may import from the siblings of their parentage.**
+
+This is less intuitive, but is not really any different than the above.
+
+If another feature (feature2) also needs forms-common-code, it should be brought up a level so it's available to feature2, as feature2 cannot "reach into" feature1:
+
+::
+
+ /feature1
+ /sub-form-1
+ /sub-form-2
+ /forms-common-code
+ /feature2 // can now use forms-common-code
+
+In a complex app, you could imagine that forms-common-code needs to be brought up several levels, in which case our imports might look like:
+
+::
+
+ import { formStuff } from '../../../forms-common-code';
+
+This is okay. Conceptually it's no different than importing from a third party npm package, we just happen to know the code we want is up a few directories nearby, rather than using the syntactic sugar of a pathless import from node_modules.
+
+At some point, if forms-common-code is general purpose enough, we may want to extract it from this repository/set of features all together.
+
+Consequences
+------------
+
+This organization has been implemented in several of our micro-frontends so far (frontend-app-account and frontend-app-payment most significantly) and we feel it has improved the organization and approachability of the apps. When converting frontend-app-account to use this organization, it took 2-3 days to refactor the code.
+
+It's worth noting that to get this right, it may actually involve changing the way the modules interact with each other. It isn't as simple as just moving files around and copy/pasting code. For instance, in frontend-app-account, it became obvious very quickly that to create strict module boundaries, we had to change the way that our service layers (server requests) were configured to keep them from importing their own configuration from their parent/grandparent. Similarly, our redux store tree of reducers became more complex and deeply nested.
+
+References
+----------
+
+Articles on react/redux application organization:
+
+* Primary reference:
+
+ - https://jaysoo.ca/2016/02/28/organizing-redux-application/
+
+* Ducks references:
+
+ - https://github.com/erikras/ducks-modular-redux
+ - https://medium.freecodecamp.org/scaling-your-redux-app-with-ducks-6115955638be
+
+* Other reading:
+
+ - https://hackernoon.com/fractal-a-react-app-structure-for-infinite-scale-4dab943092af
+ - https://marmelab.com/blog/2015/12/17/react-directory-structure.html
+ - https://redux.js.org/faq/code-structure
diff --git a/src/editors/example.jsx b/src/editors/example.jsx
new file mode 100644
index 0000000000..98d238f688
--- /dev/null
+++ b/src/editors/example.jsx
@@ -0,0 +1,100 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+/* 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
+ */
+
+/* eslint-disable no-unused-vars */
+
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+
+import { Spinner } from '@openedx/paragon';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+
+import EditorContainer from '../EditorContainer';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+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,
+ // inject
+ intl,
+}) => (
+
+
+ {!blockFinished
+ ? (
+
+
+
+ )
+ : (
+
+ 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)}
+
+ )}
+
+
+);
+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 }),
+});
+
+export const mapDispatchToProps = {
+ initializeEditor: actions.app.initializeEditor,
+};
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(thumbEditor));
diff --git a/src/editors/hooks.js b/src/editors/hooks.js
new file mode 100644
index 0000000000..c0d1b70a87
--- /dev/null
+++ b/src/editors/hooks.js
@@ -0,0 +1,76 @@
+import { useEffect } from 'react';
+
+import { sendTrackEvent } from '@edx/frontend-platform/analytics';
+import analyticsEvt from './data/constants/analyticsEvt';
+
+import { actions, thunkActions } from './data/redux';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+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],
+);
+
+export const navigateTo = (destination) => {
+ window.location.assign(destination);
+};
+
+export const navigateCallback = ({
+ returnFunction,
+ destination,
+ analyticsEvent,
+ analytics,
+}) => (response) => {
+ if (process.env.NODE_ENV !== 'development' && analyticsEvent && analytics) {
+ sendTrackEvent(analyticsEvent, analytics);
+ }
+ if (returnFunction) {
+ returnFunction()(response);
+ return;
+ }
+ module.navigateTo(destination);
+};
+
+export const nullMethod = () => ({});
+
+export const saveBlock = ({
+ analytics,
+ content,
+ destination,
+ dispatch,
+ returnFunction,
+ validateEntry,
+}) => {
+ if (!content) {
+ return;
+ }
+ let attemptSave = false;
+ if (validateEntry) {
+ if (validateEntry()) {
+ attemptSave = true;
+ }
+ } else {
+ attemptSave = true;
+ }
+ if (attemptSave) {
+ dispatch(thunkActions.app.saveBlock(
+ content,
+ module.navigateCallback({
+ destination,
+ analyticsEvent: analyticsEvt.editorSaveClick,
+ analytics,
+ returnFunction,
+ }),
+ ));
+ }
+};
+
+export const clearSaveError = ({
+ dispatch,
+}) => () => dispatch(actions.requests.clearRequest({ requestKey: RequestKeys.saveBlock }));
diff --git a/src/editors/hooks.test.jsx b/src/editors/hooks.test.jsx
new file mode 100644
index 0000000000..4a247a802c
--- /dev/null
+++ b/src/editors/hooks.test.jsx
@@ -0,0 +1,169 @@
+import { useEffect } from 'react';
+
+import { sendTrackEvent } from '@edx/frontend-platform/analytics';
+
+import analyticsEvt from './data/constants/analyticsEvt';
+import { RequestKeys } from './data/constants/requests';
+import { actions, thunkActions } from './data/redux';
+import { keyStore } from './utils';
+import * as hooks from './hooks';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useRef: jest.fn(val => ({ current: val })),
+ useEffect: jest.fn(),
+ useCallback: (cb, prereqs) => ({ cb, prereqs }),
+}));
+
+jest.mock('./data/redux', () => ({
+ actions: {
+ requests: {
+ clearRequest: (args) => ({ clearRequest: args }),
+ },
+ },
+ thunkActions: {
+ app: {
+ initialize: (args) => ({ initializeApp: args }),
+ saveBlock: (args) => ({ saveBlock: args }),
+ },
+ },
+}));
+
+jest.mock('@edx/frontend-platform/analytics', () => ({
+ sendTrackEvent: jest.fn(),
+}));
+
+const hookKeys = keyStore(hooks);
+
+describe('hooks', () => {
+ const locationTemp = window.location;
+ beforeAll(() => {
+ delete window.location;
+ window.location = {
+ assign: jest.fn(),
+ };
+ });
+ afterAll(() => {
+ window.location = locationTemp;
+ });
+
+ describe('initializeApp', () => {
+ test('calls provided function with provided data as args when useEffect is called', () => {
+ const dispatch = jest.fn();
+ const fakeData = { some: 'data' };
+ hooks.initializeApp({ dispatch, data: fakeData });
+ expect(dispatch).not.toHaveBeenCalledWith(fakeData);
+ const [cb, prereqs] = useEffect.mock.calls[0];
+ expect(prereqs).toStrictEqual([fakeData]);
+ cb();
+ expect(dispatch).toHaveBeenCalledWith(thunkActions.app.initialize(fakeData));
+ });
+ });
+
+ describe('navigateTo', () => {
+ const destination = 'HoME';
+ beforeEach(() => {
+ hooks.navigateTo(destination);
+ });
+ test('it calls window assign', () => {
+ expect(window.location.assign).toHaveBeenCalled();
+ });
+ });
+
+ describe('navigateCallback', () => {
+ let output;
+ const SAVED_ENV = process.env;
+ const destination = 'hOmE';
+ beforeEach(() => {
+ jest.resetModules();
+ process.env = { ...SAVED_ENV };
+ output = hooks.navigateCallback({ destination });
+ });
+ afterAll(() => {
+ process.env = SAVED_ENV;
+ });
+ test('it calls sendTrackEvent if given analyticsEvent and analytics', () => {
+ process.env.NODE_ENV = 'prod';
+ const analyticsEvent = 'iThapPeneDEVent';
+ const analytics = 'dATAonEveNT';
+ output = hooks.navigateCallback({
+ destination,
+ analyticsEvent,
+ analytics,
+ });
+ output();
+ expect(sendTrackEvent).toHaveBeenCalledTimes(1);
+ expect(sendTrackEvent).toHaveBeenCalledWith(analyticsEvent, analytics);
+ });
+ test('it calls navigateTo with output destination', () => {
+ const spy = jest.spyOn(hooks, hookKeys.navigateTo);
+ output();
+ expect(spy).toHaveBeenCalledWith(destination);
+ });
+ it('should call returnFunction and return null', () => {
+ const returnFunction = jest.fn(() => (response) => response);
+ output = hooks.navigateCallback({
+ destination,
+ returnFunction,
+ });
+ const returnedOutput = output();
+ expect(returnFunction).toHaveBeenCalled();
+ expect(returnedOutput).toEqual(undefined);
+ });
+ });
+
+ describe('nullMethod', () => {
+ it('returns an empty object', () => {
+ expect(hooks.nullMethod()).toEqual({});
+ });
+ });
+
+ describe('saveBlock', () => {
+ const navigateCallback = (args) => ({ navigateCallback: args });
+ const dispatch = jest.fn();
+ const destination = 'uRLwhENsAved';
+ const analytics = 'dATAonEveNT';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.spyOn(hooks, hookKeys.navigateCallback).mockImplementationOnce(navigateCallback);
+ });
+ it('returns null when content is null', () => {
+ const content = null;
+ const expected = hooks.saveBlock({
+ content,
+ destination,
+ analytics,
+ dispatch,
+ });
+ expect(expected).toEqual(undefined);
+ });
+ it('dispatches thunkActions.app.saveBlock with navigateCallback, and passed content', () => {
+ const content = 'myContent';
+ hooks.saveBlock({
+ content,
+ destination,
+ analytics,
+ dispatch,
+ });
+ expect(dispatch).toHaveBeenCalledWith(thunkActions.app.saveBlock(
+ content,
+ navigateCallback({
+ destination,
+ analyticsEvent: analyticsEvt.editorSaveClick,
+ analytics,
+ }),
+ ));
+ });
+ });
+
+ describe('clearSaveError', () => {
+ it('dispatches actions.requests.clearRequest with saveBlock requestKey', () => {
+ const dispatch = jest.fn();
+ hooks.clearSaveError({ dispatch })();
+ expect(dispatch).toHaveBeenCalledWith(actions.requests.clearRequest({
+ requestKey: RequestKeys.saveBlock,
+ }));
+ });
+ });
+});
diff --git a/src/editors/messages.js b/src/editors/messages.js
new file mode 100644
index 0000000000..19ee3c0228
--- /dev/null
+++ b/src/editors/messages.js
@@ -0,0 +1,27 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+
+ couldNotFindEditor: {
+ id: 'authoring.editorpage.selecteditor.error',
+ defaultMessage: 'Error: Could Not find Editor',
+ description: 'Error Message Dispayed When An unsopported Editor is desired in V2',
+ },
+ dropVideoFileHere: {
+ defaultMessage: 'Drag and drop video here or click to upload',
+ id: 'VideoUploadEditor.dropVideoFileHere',
+ description: 'Display message for Drag and Drop zone',
+ },
+ browse: {
+ defaultMessage: 'Browse files',
+ id: 'VideoUploadEditor.browse',
+ description: 'Display message for browse files button',
+ },
+ info: {
+ id: 'VideoUploadEditor.uploadInfo',
+ defaultMessage: 'Upload MP4 or MOV files (5 GB max)',
+ description: 'Info message for supported formats',
+ },
+});
+
+export default messages;
diff --git a/src/editors/setupEditorTest.js b/src/editors/setupEditorTest.js
new file mode 100644
index 0000000000..47df9429a2
--- /dev/null
+++ b/src/editors/setupEditorTest.js
@@ -0,0 +1,123 @@
+// These additional mocks and setup are required for some tests in src/editors/
+// and are imported on an as-needed basis.
+// eslint-disable-next-line import/no-extraneous-dependencies
+import 'jest-canvas-mock';
+
+jest.mock('@edx/frontend-platform/i18n', () => {
+ const i18n = jest.requireActual('@edx/frontend-platform/i18n');
+ const PropTypes = jest.requireActual('prop-types');
+ return {
+ ...i18n,
+ useIntl: () => ({ formatMessage: (m) => m.defaultMessage }),
+ intlShape: PropTypes.shape({
+ formatMessage: PropTypes.func,
+ }),
+ defineMessages: m => m,
+ getLocale: () => 'getLocale',
+ FormattedDate: () => 'FormattedDate',
+ FormattedMessage: () => 'FormattedMessage',
+ FormattedTime: () => 'FormattedTime',
+ };
+});
+
+jest.mock('@openedx/paragon', () => jest.requireActual('./testUtils').mockNestedComponents({
+ Alert: {
+ Heading: 'Alert.Heading',
+ },
+ AlertModal: 'AlertModal',
+ ActionRow: {
+ Spacer: 'ActionRow.Spacer',
+ },
+ Badge: 'Badge',
+ Button: 'Button',
+ ButtonGroup: 'ButtonGroup',
+ Collapsible: {
+ Advanced: 'Advanced',
+ Body: 'Body',
+ Trigger: 'Trigger',
+ Visible: 'Visible',
+ },
+ Card: {
+ Header: 'Card.Header',
+ Section: 'Card.Section',
+ Footer: 'Card.Footer',
+ Body: 'Card.Body',
+ },
+ CheckboxControl: 'CheckboxControl',
+ Col: 'Col',
+ Container: 'Container',
+ Dropdown: {
+ Item: 'Dropdown.Item',
+ Menu: 'Dropdown.Menu',
+ Toggle: 'Dropdown.Toggle',
+ },
+ ErrorContext: {
+ Provider: 'ErrorContext.Provider',
+ },
+ Hyperlink: 'Hyperlink',
+ Icon: 'Icon',
+ IconButton: 'IconButton',
+ IconButtonWithTooltip: 'IconButtonWithTooltip',
+ Image: 'Image',
+ MailtoLink: 'MailtoLink',
+ ModalDialog: {
+ Footer: 'ModalDialog.Footer',
+ Header: 'ModalDialog.Header',
+ Title: 'ModalDialog.Title',
+ Body: 'ModalDialog.Body',
+ CloseButton: 'ModalDialog.CloseButton',
+ },
+ Form: {
+ Checkbox: 'Form.Checkbox',
+ Control: {
+ Feedback: 'Form.Control.Feedback',
+ },
+ Group: 'Form.Group',
+ Label: 'Form.Label',
+ Text: 'Form.Text',
+ Row: 'Form.Row',
+ Radio: 'Radio',
+ RadioSet: 'RadioSet',
+ },
+ OverlayTrigger: 'OverlayTrigger',
+ Tooltip: 'Tooltip',
+ FullscreenModal: 'FullscreenModal',
+ Row: 'Row',
+ Scrollable: 'Scrollable',
+ SelectableBox: {
+ Set: 'SelectableBox.Set',
+ },
+
+ Spinner: 'Spinner',
+ Stack: 'Stack',
+ Toast: 'Toast',
+ Truncate: 'Truncate',
+ useWindowSize: { height: '500px' },
+}));
+
+jest.mock('@openedx/paragon/icons', () => ({
+ Close: jest.fn().mockName('icons.Close'),
+ Edit: jest.fn().mockName('icons.Edit'),
+ Locked: jest.fn().mockName('icons.Locked'),
+ Unlocked: jest.fn().mockName('icons.Unlocked'),
+}));
+
+// Mock react-redux hooks
+// unmock for integration tests
+jest.mock('react-redux', () => {
+ const dispatch = jest.fn((...args) => ({ dispatch: args })).mockName('react-redux.dispatch');
+ return {
+ connect: (mapStateToProps, mapDispatchToProps) => (component) => ({
+ mapStateToProps,
+ mapDispatchToProps,
+ component,
+ }),
+ useDispatch: jest.fn(() => dispatch),
+ useSelector: jest.fn((selector) => ({ useSelector: selector })),
+ };
+});
+
+// Mock the plugins repo so jest will stop complaining about ES6 syntax
+jest.mock('frontend-components-tinymce-advanced-plugins', () => ({
+ a11ycheckerCss: '',
+}));
diff --git a/src/editors/sharedComponents/BaseModal/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/BaseModal/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..aee7fdf3ed
--- /dev/null
+++ b/src/editors/sharedComponents/BaseModal/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,51 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`BaseModal ImageUploadModal template component snapshot 1`] = `
+
+
+
+ props.title node
+
+
+
+
+ props.children node
+
+
+
+
+ props.footerAction node
+
+
+
+
+ props.confirmAction node
+
+
+
+`;
diff --git a/src/editors/sharedComponents/BaseModal/index.jsx b/src/editors/sharedComponents/BaseModal/index.jsx
new file mode 100644
index 0000000000..74639dd54d
--- /dev/null
+++ b/src/editors/sharedComponents/BaseModal/index.jsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import {
+ ActionRow,
+ ModalDialog,
+ Scrollable,
+} from '@openedx/paragon';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+
+import messages from './messages';
+
+const BaseModal = ({
+ isOpen,
+ close,
+ title,
+ children,
+ headerComponent,
+ confirmAction,
+ footerAction,
+ size,
+ isFullscreenScroll,
+ bodyStyle,
+ className,
+}) => (
+
+
+
+ {title}
+
+ {headerComponent}
+
+
+
+ {children}
+
+
+
+
+ {footerAction}
+
+
+
+
+ {confirmAction}
+
+
+
+);
+
+BaseModal.defaultProps = {
+ footerAction: null,
+ headerComponent: null,
+ size: 'lg',
+ isFullscreenScroll: true,
+ bodyStyle: null,
+ className: undefined,
+};
+
+BaseModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ close: PropTypes.func.isRequired,
+ title: PropTypes.node.isRequired,
+ children: PropTypes.node.isRequired,
+ confirmAction: PropTypes.node.isRequired,
+ footerAction: PropTypes.node,
+ headerComponent: PropTypes.node,
+ size: PropTypes.string,
+ isFullscreenScroll: PropTypes.bool,
+ bodyStyle: PropTypes.shape({}),
+ className: PropTypes.string,
+};
+
+export default BaseModal;
diff --git a/src/editors/sharedComponents/BaseModal/index.test.jsx b/src/editors/sharedComponents/BaseModal/index.test.jsx
new file mode 100644
index 0000000000..cca9a4b57d
--- /dev/null
+++ b/src/editors/sharedComponents/BaseModal/index.test.jsx
@@ -0,0 +1,19 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import BaseModal from '.';
+
+describe('BaseModal ImageUploadModal template component', () => {
+ test('snapshot', () => {
+ const props = {
+ isOpen: true,
+ close: jest.fn().mockName('props.close'),
+ title: 'props.title node',
+ children: 'props.children node',
+ confirmAction: 'props.confirmAction node',
+ footerAction: 'props.footerAction node',
+ };
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+});
diff --git a/src/editors/sharedComponents/BaseModal/messages.js b/src/editors/sharedComponents/BaseModal/messages.js
new file mode 100644
index 0000000000..8a5823b459
--- /dev/null
+++ b/src/editors/sharedComponents/BaseModal/messages.js
@@ -0,0 +1,12 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+
+ cancelButtonLabel: {
+ id: 'authoring.baseModal.cancelButtonLabel',
+ defaultMessage: 'Cancel',
+ description: 'Label for cancel button.',
+ },
+});
+
+export default messages;
diff --git a/src/editors/sharedComponents/Button/hooks.js b/src/editors/sharedComponents/Button/hooks.js
new file mode 100644
index 0000000000..f12b865fb6
--- /dev/null
+++ b/src/editors/sharedComponents/Button/hooks.js
@@ -0,0 +1,23 @@
+/* eslint-disable import/prefer-default-export */
+export const isVariantAdd = (variant) => variant === 'add';
+
+export const getButtonProps = ({ variant, className, Add }) => {
+ const variantClasses = {
+ default: 'shared-button',
+ add: 'shared-button pl-0 text-primary-500 button-variant-add',
+ };
+ const variantMap = {
+ add: 'tertiary',
+ };
+ const classes = [variantClasses[variant]];
+ if (className) { classes.push(className); }
+
+ const iconProps = {};
+ if (isVariantAdd(variant)) { iconProps.iconBefore = Add; }
+
+ return {
+ className: classes.join(' '),
+ variant: variantMap[variant] || variant,
+ ...iconProps,
+ };
+};
diff --git a/src/editors/sharedComponents/Button/index.jsx b/src/editors/sharedComponents/Button/index.jsx
new file mode 100644
index 0000000000..29e28294bd
--- /dev/null
+++ b/src/editors/sharedComponents/Button/index.jsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { string, node, arrayOf } from 'prop-types';
+import { Button as ParagonButton } from '@openedx/paragon';
+import { Add } from '@openedx/paragon/icons';
+
+import { getButtonProps } from './hooks';
+import './index.scss';
+
+const Button = ({
+ variant, className, text, children, ...props
+}) => (
+
+ {children || text}
+
+);
+Button.propTypes = {
+ variant: string,
+ className: string,
+ text: string,
+ children: node || arrayOf(node),
+};
+Button.defaultProps = {
+ variant: 'default',
+ className: null,
+ text: null,
+ children: null,
+};
+
+export default Button;
diff --git a/src/editors/sharedComponents/Button/index.scss b/src/editors/sharedComponents/Button/index.scss
new file mode 100644
index 0000000000..3f5eedf197
--- /dev/null
+++ b/src/editors/sharedComponents/Button/index.scss
@@ -0,0 +1,9 @@
+.button-variant-add {
+ &:not(:disabled) {
+ &:not(.disabled) {
+ &:hover, &:active, &:focus {
+ background-color: transparent;
+ }
+ }
+ }
+}
diff --git a/src/editors/sharedComponents/CodeEditor/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/CodeEditor/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..877112366d
--- /dev/null
+++ b/src/editors/sharedComponents/CodeEditor/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,20 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`CodeEditor Component Snapshots Renders and calls Hooks 1`] = `
+
+`;
diff --git a/src/editors/sharedComponents/CodeEditor/constants.js b/src/editors/sharedComponents/CodeEditor/constants.js
new file mode 100644
index 0000000000..bb390430c0
--- /dev/null
+++ b/src/editors/sharedComponents/CodeEditor/constants.js
@@ -0,0 +1,36 @@
+// HTML symbols to unescape in a Alphanumeric value : Literal mapping.
+const alphanumericMap = {
+ cent: '¢',
+ pound: '£',
+ sect: '§',
+ copy: '©',
+ laquo: '«',
+ raquo: '»',
+ reg: '®',
+ deg: '°',
+ plusmn: '±',
+ para: '¶',
+ middot: '·',
+ frac12: '½',
+ mdash: '—',
+ ndash: '–',
+ lsquo: '‘',
+ rsquo: '’',
+ sbquo: '‚',
+ rdquo: '”',
+ ldquo: '“',
+ dagger: '†',
+ Dagger: '‡',
+ bull: '•',
+ hellip: '…',
+ prime: '′',
+ Prime: '″',
+ euro: '€',
+ trade: '™',
+ asymp: '≈',
+ ne: '≠',
+ le: '≤',
+ ge: '≥',
+ quot: '"',
+};
+export default alphanumericMap;
diff --git a/src/editors/sharedComponents/CodeEditor/hooks.js b/src/editors/sharedComponents/CodeEditor/hooks.js
new file mode 100644
index 0000000000..cddf281dcb
--- /dev/null
+++ b/src/editors/sharedComponents/CodeEditor/hooks.js
@@ -0,0 +1,108 @@
+import React, { useEffect } from 'react';
+import xmlChecker from 'xmlchecker';
+
+import { basicSetup } from 'codemirror';
+import { EditorState } from '@codemirror/state';
+import { EditorView } from '@codemirror/view';
+import { html } from '@codemirror/lang-html';
+import { xml } from '@codemirror/lang-xml';
+import { linter } from '@codemirror/lint';
+import alphanumericMap from './constants';
+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),
+};
+
+export const prepareShowBtnEscapeHTML = () => {
+ const [visibility, setVisibility] = state.showBtnEscapeHTML(true);
+ const hide = () => setVisibility(false);
+ return { showBtnEscapeHTML: visibility, hideBtn: hide };
+};
+
+export const cleanHTML = ({ initialText }) => {
+ const translateRegex = new RegExp(`&(${Object.keys(alphanumericMap).join('|')});`, 'g');
+ const translator = ($0, $1) => alphanumericMap[$1];
+ return initialText.replace(translateRegex, translator);
+};
+
+export const syntaxChecker = ({ textArr, lang }) => {
+ const diagnostics = [];
+ if (lang === 'xml' && textArr) {
+ const docString = textArr.join('\n');
+ const xmlDoc = ` ${docString}`;
+
+ try {
+ xmlChecker.check(xmlDoc);
+ } catch (error) {
+ let errorStart = 0;
+ for (let i = 0; i < error.line - 1; i++) {
+ errorStart += textArr[i].length;
+ }
+ const errorLine = error.line;
+ const errorEnd = errorStart + textArr[errorLine - 1].length;
+ diagnostics.push({
+ from: errorStart,
+ to: errorEnd,
+ severity: 'error',
+ message: `${error.name}: ${error.message}`,
+ });
+ }
+ }
+ return diagnostics;
+};
+
+export const createCodeMirrorDomNode = ({
+ ref,
+ initialText,
+ upstreamRef,
+ lang,
+}) => {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ useEffect(() => {
+ const languageExtension = lang === CODEMIRROR_LANGUAGES.HTML ? html() : xml();
+ const cleanText = cleanHTML({ initialText });
+ const newState = EditorState.create({
+ doc: cleanText,
+ extensions: [
+ basicSetup,
+ languageExtension,
+ EditorView.lineWrapping,
+ linter((view) => {
+ const textArr = view.state.doc.text;
+ return syntaxChecker({ textArr, lang });
+ }),
+ ],
+ });
+ const view = new EditorView({ state: newState, parent: ref.current });
+ // eslint-disable-next-line no-param-reassign
+ upstreamRef.current = view;
+ view.focus();
+
+ return () => {
+ // called on cleanup
+ view.destroy();
+ };
+ }, []);
+};
+
+export const escapeHTMLSpecialChars = ({ ref, hideBtn }) => {
+ const text = ref.current.state.doc.toString();
+ let pos = 0;
+ const changes = [];
+ Object.keys(alphanumericMap).forEach(
+ (escapedKeyword) => {
+ // eslint-disable-next-line no-cond-assign
+ for (let next; (next = text.indexOf(alphanumericMap[escapedKeyword], pos)) > -1;) {
+ changes.push({ from: next, to: next + 1, insert: `&${escapedKeyword};` });
+ pos = next + 1;
+ }
+ },
+ );
+
+ ref.current.dispatch({ changes });
+ hideBtn();
+};
diff --git a/src/editors/sharedComponents/CodeEditor/index.jsx b/src/editors/sharedComponents/CodeEditor/index.jsx
new file mode 100644
index 0000000000..29a4ed2daf
--- /dev/null
+++ b/src/editors/sharedComponents/CodeEditor/index.jsx
@@ -0,0 +1,57 @@
+import React, { useRef } from 'react';
+import PropTypes from 'prop-types';
+
+import {
+ Button,
+} from '@openedx/paragon';
+
+import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import messages from './messages';
+import './index.scss';
+
+import * as hooks from './hooks';
+
+const CodeEditor = ({
+ innerRef,
+ value,
+ lang,
+ // injected
+ intl,
+}) => {
+ const DOMref = useRef();
+ const btnRef = useRef();
+ hooks.createCodeMirrorDomNode({
+ ref: DOMref, initialText: value, upstreamRef: innerRef, lang,
+ });
+ const { showBtnEscapeHTML, hideBtn } = hooks.prepareShowBtnEscapeHTML();
+
+ return (
+
+
+ {showBtnEscapeHTML && (
+
hooks.escapeHTMLSpecialChars({ ref: innerRef, hideBtn })}
+ >
+
+
+ )}
+
+ );
+};
+
+CodeEditor.propTypes = {
+ innerRef: PropTypes.oneOfType([
+ PropTypes.func,
+ // eslint-disable-next-line react/forbid-prop-types
+ PropTypes.shape({ current: PropTypes.any }),
+ ]).isRequired,
+ value: PropTypes.string.isRequired,
+ intl: intlShape.isRequired,
+ lang: PropTypes.string.isRequired,
+};
+
+export const CodeEditorInternal = CodeEditor; // For testing only
+export default injectIntl(CodeEditor);
diff --git a/src/editors/sharedComponents/CodeEditor/index.scss b/src/editors/sharedComponents/CodeEditor/index.scss
new file mode 100644
index 0000000000..e24ef75397
--- /dev/null
+++ b/src/editors/sharedComponents/CodeEditor/index.scss
@@ -0,0 +1,2 @@
+.cm-editor { height: 100% }
+.cm-scroller { overflow: auto }
\ No newline at end of file
diff --git a/src/editors/sharedComponents/CodeEditor/index.test.jsx b/src/editors/sharedComponents/CodeEditor/index.test.jsx
new file mode 100644
index 0000000000..0760f8e0d1
--- /dev/null
+++ b/src/editors/sharedComponents/CodeEditor/index.test.jsx
@@ -0,0 +1,182 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { EditorState } from '@codemirror/state';
+import { EditorView } from '@codemirror/view';
+import { html } from '@codemirror/lang-html';
+import { formatMessage, MockUseState } from '../../testUtils';
+import alphanumericMap from './constants';
+import * as module from './index';
+import * as hooks from './hooks';
+
+const CodeEditor = module.CodeEditorInternal;
+
+jest.mock('@codemirror/view');
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useRef: jest.fn(val => ({ current: val })),
+ useEffect: jest.fn(),
+ useCallback: (cb, prereqs) => ({ cb, prereqs }),
+}));
+
+jest.mock('@codemirror/state', () => ({
+ ...jest.requireActual('@codemirror/state'),
+ EditorState: {
+ create: jest.fn(),
+ },
+}));
+
+jest.mock('@codemirror/lang-html', () => ({
+ html: jest.fn(),
+}));
+
+jest.mock('@codemirror/lang-xml', () => ({
+ xml: jest.fn(),
+}));
+
+jest.mock('codemirror', () => ({
+ basicSetup: 'bAsiCSetUp',
+}));
+
+const state = new MockUseState(hooks);
+
+describe('CodeEditor', () => {
+ describe('Hooks', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ state.testGetter(state.keys.showBtnEscapeHTML);
+ describe('stateHooks', () => {
+ beforeEach(() => {
+ state.mock();
+ });
+ it('prepareShowBtnEscapeHTML', () => {
+ const hook = hooks.prepareShowBtnEscapeHTML();
+ expect(state.stateVals.showBtnEscapeHTML).toEqual(hook.showBtnEscapeHTML);
+ hook.hideBtn();
+ expect(state.setState.showBtnEscapeHTML).toHaveBeenCalledWith(false);
+ });
+ afterEach(() => { state.restore(); });
+ });
+
+ describe('cleanHTML', () => {
+ const dirtyText = `&${Object.keys(alphanumericMap).join('; , &')};`;
+ const cleanText = `${Object.values(alphanumericMap).join(' , ')}`;
+
+ it('escapes alphanumerics and sets them to be literals', () => {
+ expect(hooks.cleanHTML({ initialText: dirtyText })).toEqual(cleanText);
+ });
+ });
+
+ describe('escapeHTMLSpecialChars', () => {
+ const cleanText = `${Object.values(alphanumericMap).join(' , ')}`;
+
+ const mockDispatch = jest.fn((args) => ({ mockDispatch: args }));
+
+ const ref = {
+ current: {
+ dispatch: mockDispatch,
+ state: {
+ doc: {
+ toString: () => cleanText,
+ },
+ },
+ },
+ };
+ const mockHideBtn = jest.fn();
+ it('unescapes literals and sets them to be alphanumerics', () => {
+ hooks.escapeHTMLSpecialChars({ ref, hideBtn: mockHideBtn });
+ expect(mockDispatch).toHaveBeenCalled();
+ expect(mockHideBtn).toHaveBeenCalled();
+ });
+ });
+
+ describe('createCodeMirrorDomNode', () => {
+ const props = {
+ ref: {
+ current: 'sOmEvAlUe',
+ },
+ lang: 'html',
+ initialText: 'sOmEhTmL',
+ upstreamRef: {
+ current: 'sOmEotHERvAlUe',
+ },
+ };
+ beforeEach(() => {
+ hooks.createCodeMirrorDomNode(props);
+ });
+ it('calls useEffect and sets up codemirror objects', () => {
+ const [cb, prereqs] = React.useEffect.mock.calls[0];
+ expect(prereqs).toStrictEqual([]);
+ cb();
+ expect(EditorState.create).toHaveBeenCalled();
+ expect(EditorView).toHaveBeenCalled();
+ expect(html).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('xmlSyntaxChecker', () => {
+ describe('lang equals html', () => {
+ it('returns empty array', () => {
+ const textArr = ['', '', 'this is some text', '
', ' '];
+ const diagnostics = hooks.syntaxChecker({ textArr, lang: 'html' });
+ expect(diagnostics).toEqual([]);
+ });
+ });
+ describe('textArr is undefined', () => {
+ it('returns empty array', () => {
+ let textArr;
+ const diagnostics = hooks.syntaxChecker({ textArr, lang: 'html' });
+ expect(diagnostics).toEqual([]);
+ });
+ });
+ describe('lang equals xml', () => {
+ it('returns empty array', () => {
+ const textArr = ['', '', 'this is some text', '
', ' '];
+ const diagnostics = hooks.syntaxChecker({ textArr, lang: 'xml' });
+ expect(diagnostics).toEqual([]);
+ });
+ it('returns an array with error object', () => {
+ const textArr = ['', '', '
', 'this is some text', '
', ' '];
+ const expectedDiagnostics = hooks.syntaxChecker({ textArr, lang: 'xml' });
+ const diagnostics = [{
+ from: 9,
+ to: 12,
+ severity: 'error',
+ message: 'SyntaxError: Expected that start and end tag must be identical but "<" found.',
+ }];
+ expect(expectedDiagnostics).toEqual(diagnostics);
+ });
+ });
+ });
+
+ describe('Component', () => {
+ describe('Snapshots', () => {
+ const mockHideBtn = jest.fn().mockName('mockHidebtn');
+ let props;
+ beforeAll(() => {
+ props = {
+ intl: { formatMessage },
+ innerRef: {
+ current: 'sOmEvALUE',
+ },
+ lang: 'html',
+ value: 'mOcKhTmL',
+ };
+ jest.spyOn(hooks, 'createCodeMirrorDomNode').mockImplementation(() => ({}));
+ });
+ afterAll(() => {
+ jest.clearAllMocks();
+ });
+ test('Renders and calls Hooks ', () => {
+ jest.spyOn(hooks, 'prepareShowBtnEscapeHTML').mockImplementation(() => ({ showBtnEscapeHTML: true, hideBtn: mockHideBtn }));
+ // Note: ref won't show up as it is not acutaly a DOM attribute.
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ expect(hooks.createCodeMirrorDomNode).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/CodeEditor/messages.js b/src/editors/sharedComponents/CodeEditor/messages.js
new file mode 100644
index 0000000000..84c5554653
--- /dev/null
+++ b/src/editors/sharedComponents/CodeEditor/messages.js
@@ -0,0 +1,12 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+
+ escapeHTMLButtonLabel: {
+ id: 'authoring.texteditor.codeEditor.escapeHTMLButton',
+ defaultMessage: 'Unescape HTML Literals',
+ description: 'Label For escape special html charectars button',
+ },
+});
+
+export default messages;
diff --git a/src/editors/sharedComponents/DraggableList/DraggableList.jsx b/src/editors/sharedComponents/DraggableList/DraggableList.jsx
new file mode 100644
index 0000000000..5279fa1ba8
--- /dev/null
+++ b/src/editors/sharedComponents/DraggableList/DraggableList.jsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from '@dnd-kit/core';
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+
+const DraggableList = ({
+ itemList,
+ setState,
+ updateOrder,
+ children,
+}) => {
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ );
+
+ const handleDragEnd = (event) => {
+ const { active, over } = event;
+ if (active.id !== over.id) {
+ let updatedArray;
+ setState(() => {
+ const [activeElement] = itemList.filter(item => item.id === active.id);
+ const [overElement] = itemList.filter(item => item.id === over.id);
+ const oldIndex = itemList.indexOf(activeElement);
+ const newIndex = itemList.indexOf(overElement);
+ updatedArray = arrayMove(itemList, oldIndex, newIndex);
+ return updatedArray;
+ });
+ updateOrder()(updatedArray);
+ }
+ };
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+DraggableList.propTypes = {
+ itemList: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ })).isRequired,
+ setState: PropTypes.func.isRequired,
+ updateOrder: PropTypes.func.isRequired,
+ children: PropTypes.node.isRequired,
+};
+
+export default DraggableList;
diff --git a/src/editors/sharedComponents/DraggableList/SortableItem.jsx b/src/editors/sharedComponents/DraggableList/SortableItem.jsx
new file mode 100644
index 0000000000..938c2546f8
--- /dev/null
+++ b/src/editors/sharedComponents/DraggableList/SortableItem.jsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { intlShape, injectIntl } from '@edx/frontend-platform/i18n';
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { Icon, IconButtonWithTooltip, Row } from '@openedx/paragon';
+import { DragIndicator } from '@openedx/paragon/icons';
+import messages from './messages';
+
+const SortableItem = ({
+ id,
+ componentStyle,
+ children,
+ // injected
+ intl,
+}) => {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ } = useSortable({ id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ ...componentStyle,
+ };
+
+ return (
+
+ {children}
+
+
+ );
+};
+SortableItem.defaultProps = {
+ componentStyle: null,
+};
+SortableItem.propTypes = {
+ id: PropTypes.string.isRequired,
+ children: PropTypes.node.isRequired,
+ componentStyle: PropTypes.shape({}),
+ // injected
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(SortableItem);
diff --git a/src/editors/sharedComponents/DraggableList/index.jsx b/src/editors/sharedComponents/DraggableList/index.jsx
new file mode 100644
index 0000000000..3055c1fa1c
--- /dev/null
+++ b/src/editors/sharedComponents/DraggableList/index.jsx
@@ -0,0 +1,5 @@
+import DraggableList from './DraggableList';
+import SortableItem from './SortableItem';
+
+export { SortableItem };
+export default DraggableList;
diff --git a/src/editors/sharedComponents/DraggableList/messages.js b/src/editors/sharedComponents/DraggableList/messages.js
new file mode 100644
index 0000000000..3a7263280b
--- /dev/null
+++ b/src/editors/sharedComponents/DraggableList/messages.js
@@ -0,0 +1,11 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ tooltipContent: {
+ id: 'authoring.draggableList.tooltip.content',
+ defaultMessage: 'Drag to reorder',
+ description: 'Tooltip content for drag indicator icon',
+ },
+});
+
+export default messages;
diff --git a/src/editors/sharedComponents/ErrorAlerts/ErrorAlert.jsx b/src/editors/sharedComponents/ErrorAlerts/ErrorAlert.jsx
new file mode 100644
index 0000000000..3e3ef935aa
--- /dev/null
+++ b/src/editors/sharedComponents/ErrorAlerts/ErrorAlert.jsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Alert } from '@openedx/paragon';
+import { Error } from '@openedx/paragon/icons';
+
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+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);
+ },
+ [isError],
+ );
+ return {
+ isDismissed,
+ dismissAlert: () => {
+ setIsDismissed(true);
+ if (dismissError) {
+ dismissError();
+ }
+ },
+ };
+ },
+};
+
+const ErrorAlert = ({
+ dismissError,
+ hideHeading,
+ isError,
+ children,
+}) => {
+ const { isDismissed, dismissAlert } = hooks.dismissalHooks({ dismissError, isError });
+ if (!isError || isDismissed) {
+ return null;
+ }
+ return (
+
+ {!hideHeading
+ && (
+
+
+
+ )}
+ {children}
+
+ );
+};
+
+ErrorAlert.defaultProps = {
+ dismissError: null,
+ hideHeading: false,
+};
+
+ErrorAlert.propTypes = {
+ dismissError: PropTypes.func,
+ hideHeading: PropTypes.bool,
+ isError: PropTypes.bool.isRequired,
+ children: PropTypes.oneOfType([
+ PropTypes.arrayOf(PropTypes.node),
+ PropTypes.node,
+ ]).isRequired,
+};
+
+export default ErrorAlert;
diff --git a/src/editors/sharedComponents/ErrorAlerts/ErrorAlert.test.jsx b/src/editors/sharedComponents/ErrorAlerts/ErrorAlert.test.jsx
new file mode 100644
index 0000000000..a8b1f0504b
--- /dev/null
+++ b/src/editors/sharedComponents/ErrorAlerts/ErrorAlert.test.jsx
@@ -0,0 +1,84 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+import ErrorAlert, { hooks } from './ErrorAlert';
+import { MockUseState } from '../../testUtils';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useRef: jest.fn(val => ({ current: val })),
+ useEffect: jest.fn(),
+ useCallback: (cb, prereqs) => ({ cb, prereqs }),
+}));
+
+const state = new MockUseState(hooks);
+let hook;
+const testValue = 'testVALUE';
+
+describe('ErrorAlert component', () => {
+ describe('Hooks', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ describe('state hooks', () => {
+ state.testGetter(state.keys.isDismissed);
+ });
+ describe('using state', () => {
+ beforeEach(() => { state.mock(); });
+ afterEach(() => { state.restore(); });
+ describe('dismissalHooks', () => {
+ const props = {
+ dismissError: jest.fn(),
+ isError: testValue,
+ };
+ beforeEach(() => {
+ hook = hooks.dismissalHooks(props);
+ });
+ it('returns isDismissed value, initialized to false', () => {
+ expect(state.stateVals.isDismissed).toEqual(hook.isDismissed);
+ });
+ test('dismissAlert sets isDismissed to true and calls dismissError', () => {
+ hook.dismissAlert();
+ expect(state.setState.isDismissed).toHaveBeenCalledWith(true);
+ expect(props.dismissError).toHaveBeenCalled();
+ });
+ test('On Render, calls setIsDismissed', () => {
+ expect(React.useEffect.mock.calls.length).toEqual(1);
+ const [cb, prereqs] = React.useEffect.mock.calls[0];
+ expect(prereqs[0]).toEqual(testValue);
+ cb();
+ expect(state.setState.isDismissed).toHaveBeenCalledWith(state.stateVals.isDismissed && !testValue);
+ });
+ });
+ });
+ });
+ describe('Component', () => {
+ describe('Snapshots', () => {
+ let props;
+ const msg = An Error Message
;
+ beforeAll(() => {
+ props = {
+ dismissError: jest.fn(),
+ };
+ jest.spyOn(hooks, 'dismissalHooks').mockImplementation(() => ({
+ isDismissed: false,
+ dismissAlert: jest.fn().mockName('dismissAlert'),
+ }));
+ });
+ afterAll(() => {
+ jest.clearAllMocks();
+ });
+ test('snapshot: is Null when no error (ErrorAlert)', () => {
+ expect(shallow( An Error Message
).snapshot).toMatchSnapshot();
+ });
+ test('snapshot: Loads children and component when error (ErrorAlert)', () => {
+ expect(
+ shallow({msg} ).snapshot,
+ ).toMatchSnapshot();
+ });
+ test('snapshot: Does not load heading when hideHeading is true', () => {
+ expect(shallow({msg} ).snapshot).toMatchSnapshot();
+ });
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/ErrorAlerts/FetchErrorAlert.jsx b/src/editors/sharedComponents/ErrorAlerts/FetchErrorAlert.jsx
new file mode 100644
index 0000000000..e8a8167174
--- /dev/null
+++ b/src/editors/sharedComponents/ErrorAlerts/FetchErrorAlert.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+
+import ErrorAlert from './ErrorAlert';
+
+const FetchErrorAlert = ({
+ message,
+ isFetchError,
+}) => (
+
+
+
+);
+
+FetchErrorAlert.propTypes = {
+ message: PropTypes.shape({
+ id: PropTypes.string,
+ defaultMessage: PropTypes.string,
+ description: PropTypes.string,
+ }).isRequired,
+ isFetchError: PropTypes.bool.isRequired,
+
+};
+
+export default FetchErrorAlert;
diff --git a/src/editors/sharedComponents/ErrorAlerts/FetchErrorAlert.test.jsx b/src/editors/sharedComponents/ErrorAlerts/FetchErrorAlert.test.jsx
new file mode 100644
index 0000000000..2a849ba417
--- /dev/null
+++ b/src/editors/sharedComponents/ErrorAlerts/FetchErrorAlert.test.jsx
@@ -0,0 +1,20 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+import FetchErrorAlert from './FetchErrorAlert';
+
+jest.mock('../../data/redux', () => ({
+ selectors: {
+ requests: {
+ isFailed: jest.fn((state, params) => ({ isFailed: { state, params } })),
+ },
+ },
+}));
+
+describe('FetchErrorAlert', () => {
+ describe('Snapshots', () => {
+ test('snapshot: is ErrorAlert with Message error (ErrorAlert)', () => {
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.jsx b/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.jsx
new file mode 100644
index 0000000000..3348515635
--- /dev/null
+++ b/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+
+import ErrorAlert from './ErrorAlert';
+
+const UploadErrorAlert = ({
+ message,
+ isUploadError,
+}) => (
+
+
+
+);
+
+UploadErrorAlert.propTypes = {
+ message: PropTypes.shape({
+ id: PropTypes.string,
+ defaultMessage: PropTypes.string,
+ description: PropTypes.string,
+ }).isRequired,
+ isUploadError: PropTypes.bool.isRequired,
+};
+
+export default UploadErrorAlert;
diff --git a/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.test.jsx b/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.test.jsx
new file mode 100644
index 0000000000..09c8a7df57
--- /dev/null
+++ b/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.test.jsx
@@ -0,0 +1,20 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+import UploadErrorAlert from './UploadErrorAlert';
+
+jest.mock('../../data/redux', () => ({
+ selectors: {
+ requests: {
+ isFailed: jest.fn((state, params) => ({ isFailed: { state, params } })),
+ },
+ },
+}));
+
+describe('UploadErrorAlert', () => {
+ describe('Snapshots', () => {
+ test('snapshot: is ErrorAlert with Message error (ErrorAlert)', () => {
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/ErrorAlerts/__snapshots__/ErrorAlert.test.jsx.snap b/src/editors/sharedComponents/ErrorAlerts/__snapshots__/ErrorAlert.test.jsx.snap
new file mode 100644
index 0000000000..2b2953062a
--- /dev/null
+++ b/src/editors/sharedComponents/ErrorAlerts/__snapshots__/ErrorAlert.test.jsx.snap
@@ -0,0 +1,34 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ErrorAlert component Component Snapshots snapshot: Does not load heading when hideHeading is true 1`] = `
+
+
+ An Error Message
+
+
+`;
+
+exports[`ErrorAlert component Component Snapshots snapshot: Loads children and component when error (ErrorAlert) 1`] = `
+
+
+
+
+
+ An Error Message
+
+
+`;
+
+exports[`ErrorAlert component Component Snapshots snapshot: is Null when no error (ErrorAlert) 1`] = `null`;
diff --git a/src/editors/sharedComponents/ErrorAlerts/__snapshots__/FetchErrorAlert.test.jsx.snap b/src/editors/sharedComponents/ErrorAlerts/__snapshots__/FetchErrorAlert.test.jsx.snap
new file mode 100644
index 0000000000..bc4615752b
--- /dev/null
+++ b/src/editors/sharedComponents/ErrorAlerts/__snapshots__/FetchErrorAlert.test.jsx.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FetchErrorAlert Snapshots snapshot: is ErrorAlert with Message error (ErrorAlert) 1`] = `
+
+
+
+`;
diff --git a/src/editors/sharedComponents/ErrorAlerts/__snapshots__/UploadErrorAlert.test.jsx.snap b/src/editors/sharedComponents/ErrorAlerts/__snapshots__/UploadErrorAlert.test.jsx.snap
new file mode 100644
index 0000000000..68f5d8b3d2
--- /dev/null
+++ b/src/editors/sharedComponents/ErrorAlerts/__snapshots__/UploadErrorAlert.test.jsx.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`UploadErrorAlert Snapshots snapshot: is ErrorAlert with Message error (ErrorAlert) 1`] = `
+
+
+
+`;
diff --git a/src/editors/sharedComponents/ErrorAlerts/messages.js b/src/editors/sharedComponents/ErrorAlerts/messages.js
new file mode 100644
index 0000000000..7857cc991d
--- /dev/null
+++ b/src/editors/sharedComponents/ErrorAlerts/messages.js
@@ -0,0 +1,12 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+
+ errorTitle: {
+ id: 'authoring.texteditor.selectimagemodal.error.errorTitle',
+ defaultMessage: 'Error',
+ description: 'Title of message presented to user when something goes wrong',
+ },
+});
+
+export default messages;
diff --git a/src/editors/sharedComponents/ErrorBoundary/ErrorPage.jsx b/src/editors/sharedComponents/ErrorBoundary/ErrorPage.jsx
new file mode 100644
index 0000000000..e00552b1aa
--- /dev/null
+++ b/src/editors/sharedComponents/ErrorBoundary/ErrorPage.jsx
@@ -0,0 +1,90 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import {
+ Button, Container, Row, Col,
+} from '@openedx/paragon';
+
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import messages from './messages';
+import { navigateTo } from '../../hooks';
+import { selectors } from '../../data/redux';
+
+/**
+ * An error page that displays a generic message for unexpected errors. Also contains a "Try
+ * Again" button to refresh the page.
+ */
+const ErrorPage = ({
+ message,
+ studioEndpointUrl,
+ learningContextId,
+ // redux
+ unitData,
+ // injected
+ intl,
+}) => {
+ const outlineType = learningContextId?.startsWith('library-v1') ? 'library' : 'course';
+ const outlineUrl = `${studioEndpointUrl}/${outlineType}/${learningContextId}`;
+ const unitUrl = unitData?.data ? `${studioEndpointUrl}/container/${unitData?.data.ancestors[0].id}` : null;
+
+ return (
+
+
+
+
+ {intl.formatMessage(messages.unexpectedError)}
+
+ {message && (
+
+ )}
+
+ {learningContextId && (unitUrl && outlineType !== 'library' ? (
+ navigateTo(unitUrl)}>
+ {intl.formatMessage(messages.returnToUnitPageLabel)}
+
+ ) : (
+ navigateTo(outlineUrl)}>
+ {intl.formatMessage(messages.returnToOutlineLabel, { outlineType })}
+
+ ))}
+ global.location.reload()}>
+ {intl.formatMessage(messages.unexpectedErrorButtonLabel)}
+
+
+
+
+
+ );
+};
+
+ErrorPage.propTypes = {
+ message: PropTypes.string,
+ learningContextId: PropTypes.string.isRequired,
+ studioEndpointUrl: PropTypes.string.isRequired,
+ // redux
+ unitData: PropTypes.shape({
+ data: PropTypes.shape({
+ ancestors: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string,
+ }),
+ ),
+ }),
+ }),
+ // injected
+ intl: intlShape.isRequired,
+};
+
+ErrorPage.defaultProps = {
+ message: null,
+ unitData: null,
+};
+
+export const mapStateToProps = (state) => ({
+ unitData: selectors.app.unitUrl(state),
+});
+
+export const ErrorPageInternal = ErrorPage; // For testing only
+export default injectIntl(connect(mapStateToProps)(ErrorPage));
diff --git a/src/editors/sharedComponents/ErrorBoundary/ErrorPage.test.jsx b/src/editors/sharedComponents/ErrorBoundary/ErrorPage.test.jsx
new file mode 100644
index 0000000000..139f6c98e1
--- /dev/null
+++ b/src/editors/sharedComponents/ErrorBoundary/ErrorPage.test.jsx
@@ -0,0 +1,77 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+import { selectors } from '../../data/redux';
+import { formatMessage } from '../../testUtils';
+import { ErrorPageInternal as ErrorPage, mapStateToProps } from './ErrorPage';
+
+jest.mock('../../data/redux', () => ({
+ selectors: {
+ app: {
+ unitUrl: jest.fn(state => ({ unitUrl: state })),
+ },
+ },
+}));
+
+describe('Editor Page', () => {
+ const emptyProps = {
+ learningContextId: null,
+ studioEndpointUrl: null,
+ intl: { formatMessage },
+ };
+ const passedProps = {
+ learningContextId: 'course-v1:edX+DemoX+Demo_Course',
+ studioEndpointUrl: 'fakeurl.com',
+ message: 'cUStomMEssagE',
+ intl: { formatMessage },
+ };
+ const unitData = {
+ data: {
+ ancestors: [{ id: 'SomeID' }],
+ },
+ };
+
+ describe('rendered with empty props', () => {
+ it('should only have one button (try again)', () => {
+ const wrapper = shallow( );
+ const buttonText = wrapper.instance.findByType('Button')[0].children[0].el;
+ expect(wrapper.snapshot).toMatchSnapshot();
+ expect(buttonText).toEqual('Try again');
+ });
+ });
+
+ describe('rendered with pass through props defined', () => {
+ const wrapper = shallow( );
+ describe('shows two buttons', () => {
+ it('the first button should correspond to returning to the course outline', () => {
+ const firstButtonText = wrapper.instance.findByType('Button')[0].children[0].el;
+ const secondButtonText = wrapper.instance.findByType('Button')[1].children[0].el;
+ expect(wrapper.snapshot).toMatchSnapshot();
+ expect(firstButtonText).toEqual('Return to course outline');
+ expect(secondButtonText).toEqual('Try again');
+ });
+ it('the first button should correspond to returning to the unit page', () => {
+ const returnToUnitPageWrapper = shallow( );
+ expect(returnToUnitPageWrapper.snapshot).toMatchSnapshot();
+ const firstButtonText = returnToUnitPageWrapper.instance.findByType('Button')[0].children[0].el;
+ const secondButtonText = returnToUnitPageWrapper.instance.findByType('Button')[1].children[0].el;
+ expect(returnToUnitPageWrapper.snapshot).toMatchSnapshot();
+ expect(firstButtonText).toEqual('Return to unit page');
+ expect(secondButtonText).toEqual('Try again');
+ });
+ });
+ it('should have custom message', () => {
+ const customMessageText = wrapper.instance.findByType('div')[0].children[0].children[0].el;
+ expect(wrapper.snapshot).toMatchSnapshot();
+ expect(customMessageText).toEqual('cUStomMEssagE');
+ });
+ });
+ describe('mapStateToProps() function', () => {
+ const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
+ test('unitData should equal unitUrl from app.unitUrl', () => {
+ expect(
+ mapStateToProps(testState).unitData,
+ ).toEqual(selectors.app.unitUrl(testState));
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/ErrorBoundary/__snapshots__/ErrorPage.test.jsx.snap b/src/editors/sharedComponents/ErrorBoundary/__snapshots__/ErrorPage.test.jsx.snap
new file mode 100644
index 0000000000..8b93543e60
--- /dev/null
+++ b/src/editors/sharedComponents/ErrorBoundary/__snapshots__/ErrorPage.test.jsx.snap
@@ -0,0 +1,196 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Editor Page rendered with empty props should only have one button (try again) 1`] = `
+
+
+
+
+ An unexpected error occurred. Please click the button below to refresh the page.
+
+
+
+ Try again
+
+
+
+
+
+`;
+
+exports[`Editor Page rendered with pass through props defined should have custom message 1`] = `
+
+
+
+
+ An unexpected error occurred. Please click the button below to refresh the page.
+
+
+
+
+ Return to course outline
+
+
+ Try again
+
+
+
+
+
+`;
+
+exports[`Editor Page rendered with pass through props defined shows two buttons the first button should correspond to returning to the course outline 1`] = `
+
+
+
+
+ An unexpected error occurred. Please click the button below to refresh the page.
+
+
+
+
+ Return to course outline
+
+
+ Try again
+
+
+
+
+
+`;
+
+exports[`Editor Page rendered with pass through props defined shows two buttons the first button should correspond to returning to the unit page 1`] = `
+
+
+
+
+ An unexpected error occurred. Please click the button below to refresh the page.
+
+
+
+
+ Return to unit page
+
+
+ Try again
+
+
+
+
+
+`;
+
+exports[`Editor Page rendered with pass through props defined shows two buttons the first button should correspond to returning to the unit page 2`] = `
+
+
+
+
+ An unexpected error occurred. Please click the button below to refresh the page.
+
+
+
+
+ Return to unit page
+
+
+ Try again
+
+
+
+
+
+`;
diff --git a/src/editors/sharedComponents/ErrorBoundary/index.jsx b/src/editors/sharedComponents/ErrorBoundary/index.jsx
new file mode 100644
index 0000000000..2298825b85
--- /dev/null
+++ b/src/editors/sharedComponents/ErrorBoundary/index.jsx
@@ -0,0 +1,57 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+
+import {
+ logError,
+} from '@edx/frontend-platform/logging';
+
+import ErrorPage from './ErrorPage';
+
+/**
+ * Error boundary component used to log caught errors and display the error page.
+ *
+ * @memberof module:React
+ * @extends {Component}
+ */
+export default class ErrorBoundary extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ hasError: false,
+ };
+ }
+
+ static getDerivedStateFromError() {
+ // Update state so the next render will show the fallback UI.
+ return { hasError: true };
+ }
+
+ componentDidCatch(error, info) {
+ logError(error, { stack: info.componentStack });
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+ErrorBoundary.propTypes = {
+ children: PropTypes.node,
+ learningContextId: PropTypes.string,
+ studioEndpointUrl: PropTypes.string,
+};
+
+ErrorBoundary.defaultProps = {
+ children: null,
+ learningContextId: null,
+ studioEndpointUrl: null,
+};
diff --git a/src/editors/sharedComponents/ErrorBoundary/index.test.jsx b/src/editors/sharedComponents/ErrorBoundary/index.test.jsx
new file mode 100644
index 0000000000..7f6ef81297
--- /dev/null
+++ b/src/editors/sharedComponents/ErrorBoundary/index.test.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+
+import {
+ logError,
+} from '@edx/frontend-platform/logging';
+import ErrorBoundary from './index';
+
+jest.mock('@edx/frontend-platform/logging', () => ({
+ logError: jest.fn(),
+}));
+
+// stubbing this to avoid needing to inject a stubbed intl into an internal component
+jest.mock('./ErrorPage', () => function mockErrorPage() {
+ return Error Page
;
+});
+
+describe('ErrorBoundary', () => {
+ it('should render children if no error', () => {
+ const component = (
+
+ Yay
+
+ );
+ const { container } = render(component);
+ const element = container.querySelector('div');
+
+ expect(logError).toHaveBeenCalledTimes(0);
+ expect(element.textContent).toEqual('Yay');
+ });
+
+ it('should render ErrorPage if it has an error', () => {
+ const ExplodingComponent = () => {
+ throw new Error('booyah');
+ };
+ const component = (
+
+
+
+ );
+ const { container } = render(component);
+ const element = container.querySelector('p');
+ expect(logError).toHaveBeenCalledTimes(1);
+ expect(logError).toHaveBeenCalledWith(
+ new Error('booyah'),
+ expect.objectContaining({
+ stack: expect.stringContaining('ExplodingComponent'),
+ }),
+ );
+ expect(element.textContent).toEqual('Error Page');
+ });
+});
diff --git a/src/editors/sharedComponents/ErrorBoundary/messages.js b/src/editors/sharedComponents/ErrorBoundary/messages.js
new file mode 100644
index 0000000000..8aa3e42161
--- /dev/null
+++ b/src/editors/sharedComponents/ErrorBoundary/messages.js
@@ -0,0 +1,26 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ unexpectedError: {
+ id: 'unexpected.error.message.text',
+ defaultMessage: 'An unexpected error occurred. Please click the button below to refresh the page.',
+ description: 'error message when an unexpected error occurs',
+ },
+ unexpectedErrorButtonLabel: {
+ id: 'unexpected.error.button.text',
+ defaultMessage: 'Try again',
+ description: 'text for button that tries to reload the app by refreshing the page',
+ },
+ returnToUnitPageLabel: {
+ id: 'unexpected.error.returnToUnitPage.button.text',
+ defaultMessage: 'Return to unit page',
+ description: 'Text for button that navigates back to the unit page',
+ },
+ returnToOutlineLabel: {
+ id: 'unexpected.error.returnToCourseOutline.button.text',
+ defaultMessage: 'Return to {outlineType} outline',
+ description: 'Text for button that navigates back to the course outline',
+ },
+});
+
+export default messages;
diff --git a/src/editors/sharedComponents/ExpandableTextArea/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/ExpandableTextArea/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..2bd7017c3f
--- /dev/null
+++ b/src/editors/sharedComponents/ExpandableTextArea/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,46 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ExpandableTextArea snapshots renders as expected with default behavior 1`] = `
+
+
+
+
+
+`;
+
+exports[`ExpandableTextArea snapshots renders error message 1`] = `
+
+
+
+
+
+
+`;
diff --git a/src/editors/sharedComponents/ExpandableTextArea/index.jsx b/src/editors/sharedComponents/ExpandableTextArea/index.jsx
new file mode 100644
index 0000000000..a2f30d66d8
--- /dev/null
+++ b/src/editors/sharedComponents/ExpandableTextArea/index.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import TinyMceWidget from '../TinyMceWidget';
+import { prepareEditorRef } from '../TinyMceWidget/hooks';
+import './index.scss';
+
+const ExpandableTextArea = ({
+ value,
+ setContent,
+ error,
+ errorMessage,
+ ...props
+}) => {
+ const { editorRef, setEditorRef } = prepareEditorRef();
+
+ return (
+ <>
+
+
+
+ {error && (
+
+ {props.errorMessage}
+
+ )}
+ >
+ );
+};
+
+ExpandableTextArea.defaultProps = {
+ value: null,
+ placeholder: null,
+ error: false,
+ errorMessage: null,
+};
+
+ExpandableTextArea.propTypes = {
+ value: PropTypes.string,
+ setContent: PropTypes.func.isRequired,
+ placeholder: PropTypes.string,
+ error: PropTypes.bool,
+ errorMessage: PropTypes.string,
+};
+
+export default ExpandableTextArea;
diff --git a/src/editors/sharedComponents/ExpandableTextArea/index.scss b/src/editors/sharedComponents/ExpandableTextArea/index.scss
new file mode 100644
index 0000000000..39659be1f5
--- /dev/null
+++ b/src/editors/sharedComponents/ExpandableTextArea/index.scss
@@ -0,0 +1,29 @@
+.expandable-mce {
+ .error {
+ outline: 2px solid #CA3A2F;
+ }
+
+ .mce-content-body {
+ padding: 10px;
+
+ p {
+ margin: 0;
+ }
+
+ blockquote {
+ margin: 16px 40px;
+ }
+ }
+
+ *[contentEditable="false"] {
+ outline: 1px solid #D7D3D1;
+ }
+
+ *[contentEditable="true"] {
+ outline: 1px solid #707070;
+
+ &:focus, &:active {
+ outline: 2px solid #000000;
+ }
+ }
+}
diff --git a/src/editors/sharedComponents/ExpandableTextArea/index.test.jsx b/src/editors/sharedComponents/ExpandableTextArea/index.test.jsx
new file mode 100644
index 0000000000..9995360e90
--- /dev/null
+++ b/src/editors/sharedComponents/ExpandableTextArea/index.test.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+import ExpandableTextArea from '.';
+
+// 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', () => {
+ const originalModule = jest.requireActual('@tinymce/tinymce-react');
+ return {
+ __esModule: true,
+ ...originalModule,
+ Editor: () => 'TiNYmCE EDitOR',
+ };
+});
+
+jest.mock('../TinyMceWidget', () => 'TinyMceWidget');
+
+describe('ExpandableTextArea', () => {
+ const props = {
+ value: 'text',
+ setContent: jest.fn(),
+ error: false,
+ errorMessage: null,
+ };
+ describe('snapshots', () => {
+ test('renders as expected with default behavior', () => {
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ test('renders error message', () => {
+ const wrapper = shallow( );
+ expect(wrapper.snapshot).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/FileInput/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/FileInput/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..454989b01c
--- /dev/null
+++ b/src/editors/sharedComponents/FileInput/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FileInput component snapshot 1`] = `
+
+
+
+`;
diff --git a/src/editors/sharedComponents/FileInput/index.jsx b/src/editors/sharedComponents/FileInput/index.jsx
new file mode 100644
index 0000000000..f265db4d44
--- /dev/null
+++ b/src/editors/sharedComponents/FileInput/index.jsx
@@ -0,0 +1,42 @@
+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) => {
+ const file = e.target.files[0];
+ if (file) {
+ onAddFile(file);
+ }
+ };
+ return {
+ click,
+ addFile,
+ ref,
+ };
+};
+
+export const FileInput = ({ fileInput: hook, acceptedFiles }) => (
+
+);
+
+FileInput.propTypes = {
+ acceptedFiles: PropTypes.string.isRequired,
+ fileInput: PropTypes.shape({
+ addFile: PropTypes.func,
+ ref: PropTypes.oneOfType([
+ // Either a function
+ PropTypes.func,
+ // Or the instance of a DOM native element (see the note about SSR)
+ PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
+ ]),
+ }).isRequired,
+};
diff --git a/src/editors/sharedComponents/FileInput/index.test.jsx b/src/editors/sharedComponents/FileInput/index.test.jsx
new file mode 100644
index 0000000000..fd2f354366
--- /dev/null
+++ b/src/editors/sharedComponents/FileInput/index.test.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react';
+
+import { FileInput } from '.';
+
+const mockOnChange = jest.fn();
+
+describe('FileInput component', () => {
+ let el;
+ let container;
+ let props;
+ beforeEach(() => {
+ container = {};
+ props = {
+ acceptedFiles: '.srt',
+ fileInput: {
+ addFile: () => mockOnChange(),
+ ref: (input) => { container.ref = input; },
+ },
+ };
+ el = render( );
+ });
+ test('snapshot', () => {
+ expect(el.container).toMatchSnapshot();
+ });
+ test('only accepts allowed file types', () => {
+ expect(el.container.querySelector('input').accept).toEqual('.srt');
+ });
+ test('calls fileInput.addFile onChange', () => {
+ fireEvent.change(el.container.querySelector('input'));
+ expect(mockOnChange).toHaveBeenCalled();
+ });
+ test('loads ref from fileInput.ref', () => {
+ expect(container.ref).toEqual(el.container.querySelector('input'));
+ });
+});
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/AltTextControls.jsx b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/AltTextControls.jsx
new file mode 100644
index 0000000000..5b06c6f8f2
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/AltTextControls.jsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Form } from '@openedx/paragon';
+import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+
+import * as hooks from './hooks';
+import messages from './messages';
+
+/**
+ * Wrapper for alt-text input and isDecorative checkbox control
+ * @param {obj} errorProps - props for error handling
+ * {bool} isValid - are alt-text fields valid for saving?
+ * @param {bool} isDecorative - is the image decorative?
+ * @param {func} setIsDecorative - handle isDecorative change event
+ * @param {func} setValue - update alt-text value
+ * @param {string} value - current alt-text value
+ */
+const AltTextControls = ({
+ isDecorative,
+ setIsDecorative,
+ setValue,
+ validation,
+ value,
+ // inject
+ intl,
+}) => (
+
+
+
+
+
+ {validation.show
+ && (
+
+
+
+ )}
+
+
+
+
+
+
+);
+AltTextControls.propTypes = {
+ error: PropTypes.shape({
+ show: PropTypes.bool,
+ }).isRequired,
+ isDecorative: PropTypes.bool.isRequired,
+ setValue: PropTypes.func.isRequired,
+ setIsDecorative: PropTypes.func.isRequired,
+ validation: PropTypes.shape({
+ show: PropTypes.bool,
+ }).isRequired,
+ value: PropTypes.string.isRequired,
+ // inject
+ intl: intlShape.isRequired,
+};
+
+export const AltTextControlsInternal = AltTextControls; // For testing only
+export default injectIntl(AltTextControls);
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/AltTextControls.test.jsx b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/AltTextControls.test.jsx
new file mode 100644
index 0000000000..36780669d5
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/AltTextControls.test.jsx
@@ -0,0 +1,34 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { formatMessage } from '../../../testUtils';
+import { AltTextControlsInternal as AltTextControls } from './AltTextControls';
+
+jest.mock('./hooks', () => ({
+ onInputChange: (handler) => ({ 'hooks.onInputChange': handler }),
+ onCheckboxChange: (handler) => ({ 'hooks.onCheckboxChange': handler }),
+}));
+
+describe('AltTextControls', () => {
+ const props = {
+ isDecorative: true,
+ value: 'props.value',
+ // inject
+ intl: { formatMessage },
+ };
+ beforeEach(() => {
+ props.setValue = jest.fn().mockName('props.setValue');
+ props.setIsDecorative = jest.fn().mockName('props.setIsDecorative');
+ props.validation = { show: true };
+ });
+ describe('render', () => {
+ test('snapshot: isDecorative=true errorProps.showAltTextSubmissionError=true', () => {
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ test('snapshot: isDecorative=true errorProps.showAltTextSubmissionError=false', () => {
+ props.validation.show = false;
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/DimensionControls.jsx b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/DimensionControls.jsx
new file mode 100644
index 0000000000..c05ec4faeb
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/DimensionControls.jsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+ Form,
+ Icon,
+ IconButton,
+} from '@openedx/paragon';
+import {
+ Locked,
+ Unlocked,
+} from '@openedx/paragon/icons';
+import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+
+import * as hooks from './hooks';
+import messages from './messages';
+
+/**
+ * Wrapper for image dimension inputs and the lock checkbox.
+ * @param {bool} isLocked - are dimensions locked
+ * @param {func} lock - lock dimensions
+ * @param {func} setHeight - updates dimensions based on new height
+ * @param {func} setWidth - updates dimensions based on new width
+ * @param {func} unlock - unlock dimensions
+ * @param {func} updateDimensions - update dimensions callback
+ * @param {obj} value - local dimension values { height, width }
+ */
+const DimensionControls = ({
+ isLocked,
+ lock,
+ setHeight,
+ setWidth,
+ unlock,
+ updateDimensions,
+ value,
+ // inject
+ intl,
+}) => ((value !== null) && (
+
+
+
+
+
+
+
+
+
+
+));
+DimensionControls.defaultProps = {
+ value: {
+ height: '100',
+ width: '100',
+ },
+};
+DimensionControls.propTypes = ({
+ value: PropTypes.shape({
+ height: PropTypes.string,
+ width: PropTypes.string,
+ }),
+ setHeight: PropTypes.func.isRequired,
+ setWidth: PropTypes.func.isRequired,
+ isLocked: PropTypes.bool.isRequired,
+ lock: PropTypes.func.isRequired,
+ unlock: PropTypes.func.isRequired,
+ updateDimensions: PropTypes.func.isRequired,
+ // inject
+ intl: intlShape.isRequired,
+});
+
+export const DimensionControlsInternal = DimensionControls; // For testing only
+export default injectIntl(DimensionControls);
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/DimensionControls.test.jsx b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/DimensionControls.test.jsx
new file mode 100644
index 0000000000..0cf9a6d279
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/DimensionControls.test.jsx
@@ -0,0 +1,141 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React, { useEffect } from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+import * as paragon from '@openedx/paragon';
+import * as icons from '@openedx/paragon/icons';
+
+import {
+ fireEvent, render, screen, waitFor,
+} from '@testing-library/react';
+import { formatMessage } from '../../../testUtils';
+import { DimensionControlsInternal as DimensionControls } from './DimensionControls';
+import * as hooks from './hooks';
+
+const WrappedDimensionControls = () => {
+ const dimensions = hooks.dimensionHooks('altText');
+
+ useEffect(() => {
+ dimensions.onImgLoad({ })({ target: { naturalWidth: 1517, naturalHeight: 803 } });
+ }, []);
+
+ return ;
+};
+
+const UnlockedDimensionControls = () => {
+ const dimensions = hooks.dimensionHooks('altText');
+
+ useEffect(() => {
+ dimensions.onImgLoad({ })({ target: { naturalWidth: 1517, naturalHeight: 803 } });
+ dimensions.unlock();
+ }, []);
+
+ return ;
+};
+
+describe('DimensionControls', () => {
+ 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( ).snapshot).toMatchSnapshot();
+ });
+ test('null value: empty snapshot', () => {
+ const el = shallow( );
+ expect(el.snapshot).toMatchSnapshot();
+ expect(el.isEmptyRender()).toEqual(true);
+ });
+ test('unlocked dimensions', () => {
+ const el = shallow( );
+ expect(el.snapshot).toMatchSnapshot();
+ });
+ });
+ describe('component tests for dimensions', () => {
+ beforeEach(() => {
+ paragon.Form.Group = jest.fn().mockImplementation(({ children }) => (
+ {children}
+ ));
+ paragon.Form.Label = jest.fn().mockImplementation(({ children }) => (
+ {children}
+ ));
+ // eslint-disable-next-line no-import-assign
+ paragon.Icon = jest.fn().mockImplementation(({ children }) => (
+ {children}
+ ));
+ // eslint-disable-next-line no-import-assign
+ paragon.IconButton = jest.fn().mockImplementation(({ children }) => (
+ {children}
+ ));
+ paragon.Form.Control = jest.fn().mockImplementation(({ value, onChange, onBlur }) => (
+
+ ));
+ // 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( );
+ const widthInput = container.querySelector('.formControl');
+ expect(widthInput.value).toBe('1517');
+ });
+
+ it('resizes dimensions proportionally', async () => {
+ const { container } = render( );
+ 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( );
+ 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();
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/__snapshots__/AltTextControls.test.jsx.snap b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/__snapshots__/AltTextControls.test.jsx.snap
new file mode 100644
index 0000000000..7e0b029a86
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/__snapshots__/AltTextControls.test.jsx.snap
@@ -0,0 +1,102 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AltTextControls render snapshot: isDecorative=true errorProps.showAltTextSubmissionError=false 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`AltTextControls render snapshot: isDecorative=true errorProps.showAltTextSubmissionError=true 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/__snapshots__/DimensionControls.test.jsx.snap b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/__snapshots__/DimensionControls.test.jsx.snap
new file mode 100644
index 0000000000..6f1bf0cdc7
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/__snapshots__/DimensionControls.test.jsx.snap
@@ -0,0 +1,97 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DimensionControls render null value: empty snapshot 1`] = `false`;
+
+exports[`DimensionControls render snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`DimensionControls render unlocked dimensions 1`] = `
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..d590d01b40
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,174 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ImageSettingsModal render snapshot 1`] = `
+
+
+
+ }
+ footerAction={null}
+ headerComponent={null}
+ isFullscreenScroll={true}
+ isOpen={false}
+ size="lg"
+ title="Image Settings"
+>
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/hooks.js b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/hooks.js
new file mode 100644
index 0000000000..c67d63f404
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/hooks.js
@@ -0,0 +1,340 @@
+import React from 'react';
+
+import { StrictDict } from '../../../utils';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+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),
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ lockAspectRatio: (val) => React.useState(val),
+};
+
+export const dimKeys = StrictDict({
+ height: 'height',
+ width: 'width',
+});
+
+/**
+ * findGcd(numerator, denominator)
+ * 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) => {
+ 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);
+
+/**
+ * getValidDimensions({ dimensions, local, locked })
+ * Find valid ending dimensions based on start state, request, and lock state
+ * @param {obj} dimensions - current stored dimensions
+ * @param {obj} local - local (active) dimensions in the inputs
+ * @param {obj} locked - locked dimensions
+ * @return {obj} - output dimensions after move ({ height, width })
+ */
+export const getValidDimensions = ({
+ dimensions,
+ local,
+ isLocked,
+ 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 = {};
+
+ // 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 };
+
+ 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.
+ * Locks the dimensions initially, on lock initialization.
+ * @param {obj} dimensions - current stored dimensions
+ * @return {obj} - dimension lock hooks
+ * {func} initializeLock - enable the lock mechanism
+ * {bool} isLocked - are dimensions locked?
+ * {obj} lockAspectRatio - image dimensions ({ height, width })
+ * {func} lock - lock the dimensions
+ * {func} unlock - unlock the dimensions
+ */
+export const dimensionLockHooks = () => {
+ const [lockAspectRatio, setLockAspectRatio] = module.state.lockAspectRatio(null);
+ const [isLocked, setIsLocked] = module.state.isLocked(true);
+
+ const initializeLock = ({ width, height }) => {
+ // 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),
+ lockAspectRatio,
+ unlock: () => setIsLocked(false),
+ };
+};
+
+/**
+ * dimensionHooks()
+ * Returns an object of dimension-focused react hooks.
+ * @return {obj} - dimension hooks
+ * {func} onImgLoad - initializes image dimension fields
+ * @param {object} selection - selected image object with possible override dimensions.
+ * @return {callback} - image load event callback that loads dimensions.
+ * {object} locked - current locked state
+ * {func} lock - lock current dimensions
+ * {func} unlock - unlock dimensions
+ * {object} value - current dimension values
+ * {func} setHeight - set height
+ * @param {string} - new height string
+ * {func} setWidth - set width
+ * @param {string} - new width string
+ * {func} updateDimensions - set dimensions based on state
+ * {obj} errorProps - props for user feedback error
+ * {bool} isError - true if dimensions are blank
+ * {func} setError - sets isError to true
+ * {func} dismissError - sets isError to false
+ * {bool} isHeightValid - true if height field is ready to save
+ * {func} setHeightValid - sets isHeightValid to true
+ * {func} setHeightNotValid - sets isHeightValid to false
+ * {bool} isWidthValid - true if width field is ready to save
+ * {func} setWidthValid - sets isWidthValid to true
+ * {func} setWidthNotValid - sets isWidthValid to false
+ */
+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 === '') {
+ altTextHook.setIsDecorative(true);
+ }
+ altTextHook.setValue(altText);
+ }
+ 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,
+ lockAspectRatio,
+ unlock,
+ } = module.dimensionLockHooks({ dimensions });
+
+ return {
+ onImgLoad: (selection) => ({ target: img }) => {
+ const imageDims = { height: img.naturalHeight, width: img.naturalWidth };
+ setAll(selection.height ? selection : imageDims);
+ initializeLock(selection.height ? selection : imageDims);
+ },
+ isLocked,
+ lock,
+ unlock,
+ value: local,
+ setHeight,
+ setWidth,
+ updateDimensions: () => setAll(module.getValidDimensions({
+ dimensions,
+ local,
+ isLocked,
+ lockAspectRatio,
+ })),
+ };
+};
+
+/**
+ * altTextHooks(savedText)
+ * Returns a set of react hooks focused around alt text
+ * @return {obj} - alt text hooks
+ * {string} value - alt text value
+ * {func} setValue - set alt test value
+ * @param {string} - new alt text
+ * {bool} isDecorative - is the image decorative?
+ * {func} setIsDecorative - set isDecorative field
+ * {obj} error - error at top of page
+ * {bool} show - is error being displayed?
+ * {func} set - set show to true
+ * {func} dismiss - set show to false
+ * {obj} validation - local alt text error
+ * {bool} show - is validation error being displayed?
+ * {func} set - set validation to true
+ * {func} dismiss - set validation to false
+ */
+export const altTextHooks = (savedText) => {
+ const [value, setValue] = module.state.altText(savedText || '');
+ const [isDecorative, setIsDecorative] = module.state.isDecorative(false);
+ const [showAltTextDismissibleError, setShowAltTextDismissibleError] = module.state.showAltTextDismissibleError(false);
+ const [showAltTextSubmissionError, setShowAltTextSubmissionError] = module.state.showAltTextSubmissionError(false);
+
+ const validateAltText = (newVal, newDecorative) => {
+ if (showAltTextSubmissionError) {
+ if (newVal || newDecorative) {
+ setShowAltTextSubmissionError(false);
+ }
+ }
+ };
+
+ return {
+ value,
+ setValue: (val) => {
+ setValue(val);
+ validateAltText(val, null);
+ },
+ isDecorative,
+ setIsDecorative: (decorative) => {
+ setIsDecorative(decorative);
+ validateAltText(null, decorative);
+ },
+ error: {
+ show: showAltTextDismissibleError,
+ set: () => setShowAltTextDismissibleError(true),
+ dismiss: () => setShowAltTextDismissibleError(false),
+ },
+ validation: {
+ show: showAltTextSubmissionError,
+ set: () => setShowAltTextSubmissionError(true),
+ dismiss: () => setShowAltTextSubmissionError(false),
+ },
+ };
+};
+
+/**
+ * onInputChange(handleValue)
+ * Simple event handler forwarding the event target value to a given callback
+ * @param {func} handleValue - event value handler
+ * @return {func} - evt callback that will call handleValue with the event target value.
+ */
+export const onInputChange = (handleValue) => (e) => handleValue(e.target.value);
+
+/**
+ * onCheckboxChange(handleValue)
+ * Simple event handler forwarding the event target checked prop to a given callback
+ * @param {func} handleValue - event value handler
+ * @return {func} - evt callback that will call handleValue with the event target checked prop.
+ */
+export const onCheckboxChange = (handleValue) => (e) => handleValue(e.target.checked);
+
+/**
+ * checkFormValidation({ altText, isDecorative, onAltTextFail })
+ * Handle saving the image context to the text editor
+ * @param {string} altText - image alt text
+ * @param {bool} isDecorative - is the image decorative?
+ * @param {func} onAltTextFail - called if alt text validation fails
+ */
+export const checkFormValidation = ({
+ altText,
+ isDecorative,
+ onAltTextFail,
+}) => {
+ if (!isDecorative && altText === '') {
+ onAltTextFail();
+ return false;
+ }
+ return true;
+};
+
+/**
+ * onSave({ altText, dimensions, isDecorative, saveToEditor })
+ * Handle saving the image context to the text editor
+ * @param {string} altText - image alt text
+ * @param {object} dimensions - image dimensions ({ width, height })
+ * @param {bool} isDecorative - is the image decorative?
+ * @param {func} saveToEditor - save method for submitting image settings.
+ */
+export const onSaveClick = ({
+ altText,
+ dimensions,
+ isDecorative,
+ saveToEditor,
+}) => () => {
+ if (module.checkFormValidation({
+ altText: altText.value,
+ isDecorative,
+ onAltTextFail: () => {
+ altText.error.set();
+ altText.validation.set();
+ },
+ })) {
+ altText.error.dismiss();
+ altText.validation.dismiss();
+ saveToEditor({
+ altText: altText.value,
+ dimensions,
+ isDecorative,
+ });
+ }
+};
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/hooks.test.js b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/hooks.test.js
new file mode 100644
index 0000000000..bcac2ef658
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/hooks.test.js
@@ -0,0 +1,387 @@
+import React from 'react';
+import { StrictDict } from '../../../utils';
+import { MockUseState } from '../../../testUtils';
+import * as hooks from './hooks';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useEffect: jest.fn(),
+ useState: (val) => ({ useState: val }),
+}));
+
+const simpleDims = { width: 3, height: 4 };
+const reducedDims = { width: 7, height: 13 };
+const gcd = 7;
+const multiDims = {
+ width: reducedDims.width * gcd,
+ height: reducedDims.height * gcd,
+};
+
+const state = new MockUseState(hooks);
+
+const hookKeys = StrictDict(Object.keys(hooks).reduce(
+ (obj, key) => ({ ...obj, [key]: key }),
+ {},
+));
+
+let hook;
+
+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));
+ test('provides dimensions state value', () => testStateMethod(state.keys.dimensions));
+ test('provides showAltTextDismissibleError state value', () => testStateMethod(state.keys.showAltTextDismissibleError));
+ test('provides showAltTextSubmissionError state value', () => testStateMethod(state.keys.showAltTextSubmissionError));
+ 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 lockAspectRatio state value', () => testStateMethod(state.keys.lockAspectRatio));
+});
+
+describe('ImageSettingsModal hooks', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ describe('dimensions-related hooks', () => {
+ describe('getValidDimensions', () => {
+ it('returns local dimensions if not locked', () => {
+ expect(hooks.getValidDimensions({
+ dimensions: simpleDims,
+ local: reducedDims,
+ isLocked: false,
+ lockAspectRatio: simpleDims,
+ })).toEqual(reducedDims);
+ });
+ it('returns local dimensions if the same as stored', () => {
+ expect(hooks.getValidDimensions({
+ dimensions: simpleDims,
+ local: simpleDims,
+ isLocked: true,
+ lockAspectRatio: reducedDims,
+ })).toEqual(simpleDims);
+ });
+ 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 testDimensions = (newDimensions, expected) => {
+ const dimensions = { width: w, height: h };
+ expect(hooks.getValidDimensions({
+ 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(() => {
+ state.mock();
+ hook = hooks.dimensionLockHooks({ dimensions: simpleDims });
+ });
+ afterEach(() => {
+ state.restore();
+ });
+ test('lockAspectRatio defaults to null', () => {
+ expect(hook.lockAspectRatio).toEqual(null);
+ });
+ test('isLocked defaults to true', () => {
+ expect(hook.isLocked).toEqual(true);
+ });
+ describe('initializeLock', () => {
+ it('calls setLockAspectRatio with the passed dimensions divided by their gcd', () => {
+ hook.initializeLock(multiDims);
+ expect(state.setState.lockAspectRatio).toHaveBeenCalledWith(reducedDims);
+ });
+ it('returns the values themselves if they have no gcd', () => {
+ jest.spyOn(hooks, hookKeys.findGcd).mockReturnValueOnce(1);
+ hook.initializeLock(simpleDims);
+ expect(state.setState.lockAspectRatio).toHaveBeenCalledWith(simpleDims);
+ });
+ });
+ test('lock sets isLocked to true', () => {
+ hook = hooks.dimensionLockHooks({ dimensions: simpleDims });
+ hook.lock();
+ expect(state.setState.isLocked).toHaveBeenCalledWith(true);
+ });
+ test('unlock sets locked to null', () => {
+ hook = hooks.dimensionLockHooks({ dimensions: simpleDims });
+ hook.unlock();
+ expect(state.setState.isLocked).toHaveBeenCalledWith(false);
+ });
+ });
+ describe('dimensionHooks', () => {
+ let lockHooks;
+ beforeEach(() => {
+ state.mock();
+ lockHooks = {
+ initializeLock: jest.fn(),
+ lock: jest.fn(),
+ unlock: jest.fn(),
+ locked: { ...reducedDims },
+ };
+ jest.spyOn(hooks, hookKeys.dimensionLockHooks).mockReturnValueOnce(lockHooks);
+ hook = hooks.dimensionHooks();
+ });
+ afterEach(() => {
+ state.restore();
+ });
+ it('initializes dimension lock hooks with incoming dimension value', () => {
+ state.mockVal(state.keys.dimensions, reducedDims);
+ hook = hooks.dimensionHooks();
+ expect(hooks.dimensionLockHooks).toHaveBeenCalledWith({ dimensions: reducedDims });
+ });
+ test('value is tied to local state', () => {
+ state.mockVal(state.keys.local, simpleDims);
+ hook = hooks.dimensionHooks();
+ expect(hook.value).toEqual(simpleDims);
+ });
+ describe('onImgLoad', () => {
+ const img = { naturalHeight: 200, naturalWidth: 345 };
+ const evt = { target: img };
+ it('calls initializeDimensions with selection dimensions if passed', () => {
+ hook.onImgLoad(simpleDims)(evt);
+ expect(state.setState.dimensions).toHaveBeenCalledWith(simpleDims);
+ expect(state.setState.local).toHaveBeenCalledWith(simpleDims);
+ });
+ it('calls initializeDimensions with target image dimensions if no selection', () => {
+ hook.onImgLoad({})(evt);
+ const expected = { width: img.naturalWidth, height: img.naturalHeight };
+ expect(state.setState.dimensions).toHaveBeenCalledWith(expected);
+ expect(state.setState.local).toHaveBeenCalledWith(expected);
+ });
+ it('calls initializeLock', () => {
+ const initializeDimensions = jest.fn();
+ hook.onImgLoad(initializeDimensions, simpleDims)(evt);
+ expect(lockHooks.initializeLock).toHaveBeenCalled();
+ });
+ });
+ describe('setHeight', () => {
+ it('sets local height to int value of argument', () => {
+ state.mockVal(state.keys.local, simpleDims);
+ hooks.dimensionHooks().setHeight('23.4');
+ expect(state.setState.local).toHaveBeenCalledWith({ ...simpleDims, height: 23 });
+ });
+ });
+ describe('setWidth', () => {
+ it('sets local width to int value of argument', () => {
+ state.mockVal(state.keys.local, simpleDims);
+ hooks.dimensionHooks().setWidth('34.5');
+ expect(state.setState.local).toHaveBeenCalledWith({ ...simpleDims, width: 34 });
+ });
+ });
+ describe('updateDimensions', () => {
+ it('sets local and stored dimensions to newDimensions output', () => {
+ // store values we care about under height or width, and add junk data to be stripped out.
+ const testDims = (args) => ({ ...simpleDims, height: args });
+ const getValidDimensions = (args) => ({ ...testDims(args), junk: 'data' });
+ state.mockVal(state.keys.isLocked, true);
+ state.mockVal(state.keys.dimensions, simpleDims);
+ 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,
+ lockAspectRatio: reducedDims,
+ local: multiDims,
+ isLocked: true,
+ });
+ expect(state.setState.local).toHaveBeenCalledWith(expected);
+ expect(state.setState.dimensions).toHaveBeenCalledWith(expected);
+ });
+ });
+ });
+ });
+ describe('altTextHooks', () => {
+ const value = 'myVAL';
+ const isDecorative = true;
+ const showAltTextDismissibleError = true;
+ const showAltTextSubmissionError = true;
+ beforeEach(() => {
+ state.mock();
+ hook = hooks.altTextHooks();
+ });
+ afterEach(() => {
+ state.restore();
+ });
+ it('returns value and isDecorative', () => {
+ state.mockVal(state.keys.altText, value);
+ state.mockVal(state.keys.isDecorative, isDecorative);
+ hook = hooks.altTextHooks();
+ expect(hook.value).toEqual(value);
+ expect(hook.isDecorative).toEqual(isDecorative);
+ });
+ test('setValue sets value', () => {
+ state.mockVal(state.keys.altText, value);
+ hook = hooks.altTextHooks();
+ hook.setValue(value);
+ expect(state.setState.altText).toHaveBeenCalledWith(value);
+ });
+ test('setIsDecorative sets isDecorative', () => {
+ state.mockVal(state.keys.altText, value);
+ hook = hooks.altTextHooks();
+ hook.setIsDecorative(value);
+ expect(state.setState.isDecorative).toHaveBeenCalledWith(value);
+ });
+ describe('error', () => {
+ test('show is initialized to false and returns properly', () => {
+ expect(hook.error.show).toEqual(false);
+ state.mockVal(state.keys.showAltTextDismissibleError, showAltTextDismissibleError);
+ hook = hooks.altTextHooks();
+ expect(hook.error.show).toEqual(showAltTextDismissibleError);
+ });
+ test('set sets showAltTextDismissibleError to true', () => {
+ hook.error.set();
+ expect(state.setState.showAltTextDismissibleError).toHaveBeenCalledWith(true);
+ });
+ test('dismiss sets showAltTextDismissibleError to false', () => {
+ hook.error.dismiss();
+ expect(state.setState.showAltTextDismissibleError).toHaveBeenCalledWith(false);
+ });
+ });
+ describe('validation', () => {
+ test('show is initialized to false and returns properly', () => {
+ expect(hook.validation.show).toEqual(false);
+ state.mockVal(state.keys.showAltTextSubmissionError, showAltTextSubmissionError);
+ hook = hooks.altTextHooks();
+ expect(hook.validation.show).toEqual(showAltTextSubmissionError);
+ });
+ test('set sets showAltTextSubmissionError to true', () => {
+ hook.validation.set();
+ expect(state.setState.showAltTextSubmissionError).toHaveBeenCalledWith(true);
+ });
+ test('dismiss sets showAltTextSubmissionError to false', () => {
+ hook.validation.dismiss();
+ expect(state.setState.showAltTextSubmissionError).toHaveBeenCalledWith(false);
+ });
+ });
+ });
+ describe('onInputChange', () => {
+ it('calls handleValue with event value prop', () => {
+ const value = 'TEST value';
+ const onChange = jest.fn();
+ hooks.onInputChange(onChange)({ target: { value } });
+ expect(onChange).toHaveBeenCalledWith(value);
+ });
+ });
+ describe('onCheckboxChange', () => {
+ it('calls handleValue with event checked prop', () => {
+ const checked = 'TEST value';
+ const onChange = jest.fn();
+ hooks.onCheckboxChange(onChange)({ target: { checked } });
+ expect(onChange).toHaveBeenCalledWith(checked);
+ });
+ });
+ describe('checkFormValidation', () => {
+ const props = {
+ onAltTextFail: jest.fn().mockName('onAltTextFail'),
+ };
+ beforeEach(() => {
+ props.altText = '';
+ props.isDecorative = false;
+ });
+ it('calls onAltTextFail when isDecorative is false and altText is an empty string', () => {
+ hooks.checkFormValidation({ ...props });
+ expect(props.onAltTextFail).toHaveBeenCalled();
+ });
+ it('returns false when isDeocrative is false and altText is an empty string', () => {
+ expect(hooks.checkFormValidation({ ...props })).toEqual(false);
+ });
+ it('returns true when isDecorative is true', () => {
+ props.isDecorative = true;
+ expect(hooks.checkFormValidation({ ...props })).toEqual(true);
+ });
+ });
+ describe('onSaveClick', () => {
+ const props = {
+ altText: {
+ error: {
+ show: true,
+ set: jest.fn(),
+ dismiss: jest.fn(),
+ },
+ validation: {
+ show: true,
+ set: jest.fn(),
+ dismiss: jest.fn(),
+ },
+ },
+ dimensions: simpleDims,
+ saveToEditor: jest.fn().mockName('saveToEditor'),
+ };
+ beforeEach(() => {
+ props.altText.value = 'What is this?';
+ props.isDecorative = false;
+ });
+ it('calls checkFormValidation', () => {
+ jest.spyOn(hooks, hookKeys.checkFormValidation);
+ hooks.onSaveClick({ ...props })();
+ expect(hooks.checkFormValidation).toHaveBeenCalled();
+ });
+ it('calls saveToEditor with dimensions, altText and isDecorative when checkFormValidation is true', () => {
+ jest.spyOn(hooks, hookKeys.checkFormValidation).mockReturnValueOnce(true);
+ hooks.onSaveClick({ ...props })();
+ expect(props.saveToEditor).toHaveBeenCalledWith({
+ altText: props.altText.value,
+ dimensions: props.dimensions,
+ isDecorative: props.isDecorative,
+ });
+ });
+ it('calls dismissError and sets showAltTextSubmissionError to false when checkFormValidation is true', () => {
+ jest.spyOn(hooks, hookKeys.checkFormValidation).mockReturnValueOnce(true);
+ hooks.onSaveClick({ ...props })();
+ expect(props.altText.error.dismiss).toHaveBeenCalled();
+ expect(props.altText.validation.dismiss).toHaveBeenCalled();
+ });
+ it('does not call saveEditor when checkFormValidation is false', () => {
+ jest.spyOn(hooks, hookKeys.checkFormValidation).mockReturnValueOnce(false);
+ hooks.onSaveClick({ ...props })();
+ 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]);
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.jsx b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.jsx
new file mode 100644
index 0000000000..da0073f514
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.jsx
@@ -0,0 +1,106 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Button, Image } from '@openedx/paragon';
+import { ArrowBackIos } from '@openedx/paragon/icons';
+import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+
+import './index.scss';
+import * as hooks from './hooks';
+import messages from './messages';
+import BaseModal from '../../BaseModal';
+import AltTextControls from './AltTextControls';
+import DimensionControls from './DimensionControls';
+import ErrorAlert from '../../ErrorAlerts/ErrorAlert';
+
+/**
+ * Modal display wrapping the dimension and alt-text controls for image tags
+ * inserted into the TextEditor TinyMCE context.
+ * Provides a thumbnail and populates dimension and alt-text controls.
+ * @param {bool} isOpen - is the modal open?
+ * @param {func} close - close the modal
+ * @param {obj} selection - current image selection object
+ * @param {func} saveToEditor - save the current settings to the editor
+ * @param {func} returnToSelection - return to image selection
+ */
+const ImageSettingsModal = ({
+ close,
+ isOpen,
+ returnToSelection,
+ saveToEditor,
+ selection,
+ // inject
+ intl,
+}) => {
+ const altText = hooks.altTextHooks(selection.altText);
+ const dimensions = hooks.dimensionHooks(altText);
+ const onSaveClick = hooks.onSaveClick({
+ altText,
+ dimensions: dimensions.value,
+ isDecorative: altText.isDecorative,
+ saveToEditor,
+ });
+ return (
+
+
+
+ )}
+ isOpen={isOpen}
+ title={intl.formatMessage(messages.titleLabel)}
+ >
+
+
+
+
+
+
+
+
+
+ );
+};
+
+ImageSettingsModal.propTypes = {
+ close: PropTypes.func.isRequired,
+ isOpen: PropTypes.bool.isRequired,
+ returnToSelection: PropTypes.func.isRequired,
+ saveToEditor: PropTypes.func.isRequired,
+ selection: PropTypes.shape({
+ altText: PropTypes.string,
+ externalUrl: PropTypes.string,
+ url: PropTypes.string,
+ }).isRequired,
+ // inject
+ intl: intlShape.isRequired,
+};
+export const ImageSettingsModalInternal = ImageSettingsModal; // For testing only
+export default injectIntl(ImageSettingsModal);
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.scss b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.scss
new file mode 100644
index 0000000000..d50b5e81d1
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.scss
@@ -0,0 +1,34 @@
+.img-settings-form-container {
+ .img-settings-thumbnail-container {
+ width: 282px;
+
+ .img-settings-thumbnail {
+ margin-left: 32px;
+ max-height: 250px;
+ max-width: 250px;
+ }
+ }
+
+ hr {
+ width: 1px;
+ }
+
+ .img-settings-form-controls {
+ width: 375px;
+ margin: 0 24px;
+
+ .dimension-input {
+ width: 145px;
+ margin-right: 15px;
+ display: inline-block;
+ }
+
+ .img-settings-control-label {
+ font-size: 1rem;
+ }
+
+ .decorative-control-label label {
+ font-size: .75rem;
+ }
+ }
+}
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.test.jsx b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.test.jsx
new file mode 100644
index 0000000000..f6d378ccfa
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/index.test.jsx
@@ -0,0 +1,50 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { formatMessage } from '../../../testUtils';
+import { ImageSettingsModalInternal as ImageSettingsModal } from '.';
+
+jest.mock('./AltTextControls', () => 'AltTextControls');
+jest.mock('./DimensionControls', () => 'DimensionControls');
+
+jest.mock('./hooks', () => ({
+ altTextHooks: () => ({
+ error: {
+ show: true,
+ dismiss: jest.fn(),
+ },
+ isDecorative: false,
+ value: 'alternative Taxes',
+ }),
+ dimensionHooks: () => ({
+ onImgLoad: jest.fn(
+ (selection) => ({ 'hooks.dimensions.onImgLoad.callback': { selection } }),
+ ).mockName('hooks.dimensions.onImgLoad'),
+ value: { width: 12, height: 13 },
+ }),
+ onSaveClick: (args) => ({ 'hooks.onSaveClick': args }),
+}));
+
+describe('ImageSettingsModal', () => {
+ const props = {
+ isOpen: false,
+ selection: {
+ altText: 'AlTTExt',
+ externalUrl: 'ExtERNALurL',
+ url: 'UrL',
+ },
+ // inject
+ intl: { formatMessage },
+ };
+ beforeEach(() => {
+ props.close = jest.fn().mockName('props.close');
+ props.saveToEditor = jest.fn().mockName('props.saveToEditor');
+ props.returnToSelection = jest.fn().mockName('props.returnToSelector');
+ });
+ describe('render', () => {
+ test('snapshot', () => {
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/messages.js b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/messages.js
new file mode 100644
index 0000000000..a20a492a01
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/messages.js
@@ -0,0 +1,94 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+
+ // index
+ titleLabel: {
+ id: 'authoring.texteditor.imagesettingsmodal.titleLabel',
+ defaultMessage: 'Image Settings',
+ description: 'Label title for image settings modal.',
+ },
+ saveButtonLabel: {
+ id: 'authoring.texteditor.imagesettingsmodal.saveButtonLabel',
+ defaultMessage: 'Save',
+ description: 'Label for save button.',
+ },
+ replaceImageButtonLabel: {
+ id: 'authoring.texteditor.imagesettingsmodal.replaceImageButtonLabel',
+ defaultMessage: 'Replace image',
+ description: 'Label for replace image button.',
+ },
+
+ // DimensionControls
+ imageDimensionsLabel: {
+ id: 'authoring.texteditor.imagesettingsmodal.imageDimensionsLabel',
+ defaultMessage: 'Image Dimensions',
+ description: 'Label title for the image dimensions section.',
+ },
+ widthFloatingLabel: {
+ id: 'authoring.texteditor.imagesettingsmodal.widthFloatingLabel',
+ defaultMessage: 'Width',
+ description: 'Floating label for width input.',
+ },
+ heightFloatingLabel: {
+ id: 'authoring.texteditor.imagesettingsmodal.heightFloatingLabel',
+ defaultMessage: 'Height',
+ description: 'Floating label for height input.',
+ },
+ unlockDimensionsLabel: {
+ id: 'authoring.texteditor.imagesettingsmodal.unlockDimensionsLabel',
+ defaultMessage: 'unlock dimensions',
+ description: 'Label for button when unlocking dimensions.',
+ },
+ lockDimensionsLabel: {
+ id: 'authoring.texteditor.imagesettingsmodal.lockDimensionsLabel',
+ defaultMessage: 'lock dimensions',
+ description: 'Label for button when locking dimensions.',
+ },
+ decorativeDimensionCheckboxLabel: {
+ id: 'authoring.texteditor.imagesettingsmodal.decorativeDimensionCheckboxLabel',
+ defaultMessage: "Use percentages for the image's width and height",
+ description: 'Checkbox label for whether or not an image uses percentages for width and height.',
+ },
+
+ // AltTextControls
+ accessibilityLabel: {
+ id: 'authoring.texteditor.imagesettingsmodal.accessibilityLabel',
+ defaultMessage: 'Accessibility',
+ description: 'Label title for accessibility section.',
+ },
+ altTextFloatingLabel: {
+ id: 'authoring.texteditor.imagesettingsmodal.altTextFloatingLabel',
+ defaultMessage: 'Alt Text',
+ description: 'Floating label title for alt text input.',
+ },
+ decorativeAltTextCheckboxLabel: {
+ id: 'authoring.texteditor.imagesettingsmodal.decorativeAltTextCheckboxLabel',
+ defaultMessage: 'This image is decorative (no alt text required).',
+ description: 'Checkbox label for whether or not an image is decorative.',
+ },
+
+ // User Feedback Errors
+ altTextError: {
+ id: 'authoring.texteditor.imagesettingsmodal.error.altTextError',
+ defaultMessage: 'Enter alt text or specify that the image is decorative only.',
+ description: 'Message presented to user when user attempts to save unaccepted altText configuration.',
+ },
+ altTextLocalFeedback: {
+ id: 'authoring.texteditor.imagesettingsmodal.error.altTextLocalFeedback',
+ defaultMessage: 'Enter alt text',
+ description: 'Message feedback for user below the alt text field.',
+ },
+ dimensionError: {
+ id: 'authoring.texteditor.imagesettingsmodal.error.dimensionError',
+ defaultMessage: 'Dimension values must be less than or equal to 100.',
+ description: 'Message presented to user when user attempts to save unaccepted dimension configuration.',
+ },
+ dimensionLocalFeedback: {
+ id: 'authoring.texteditor.imagesettingsmodal.error.dimensionFeedback',
+ defaultMessage: 'Enter a value less than or equal to 100.',
+ description: 'Message feedback for user below the dimension fields.',
+ },
+});
+
+export default messages;
diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..fd2b660d1e
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,94 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SelectImageModal component snapshot 1`] = `
+
+`;
diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.js b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.js
new file mode 100644
index 0000000000..92a1f513ef
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.js
@@ -0,0 +1,183 @@
+import React from 'react';
+import { useDispatch } from 'react-redux';
+
+import { thunkActions } from '../../../data/redux';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './hooks';
+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),
+};
+
+export const searchAndSortHooks = () => {
+ const [searchString, setSearchString] = module.state.searchString('');
+ const [sortBy, setSortBy] = module.state.sortBy(sortKeys.dateNewest);
+ return {
+ searchString,
+ onSearchChange: (e) => setSearchString(e.target.value),
+ clearSearchString: () => setSearchString(''),
+ sortBy,
+ onSortClick: (key) => () => setSortBy(key),
+ sortKeys,
+ sortMessages,
+ };
+};
+
+export const filteredList = ({ searchString, imageList }) => (
+ imageList.filter(({ displayName }) => displayName?.toLowerCase().includes(searchString?.toLowerCase()))
+);
+
+export const displayList = ({ sortBy, searchString, images }) => (
+ module.filteredList({
+ searchString,
+ imageList: images,
+ }).sort(sortFunctions[sortBy in sortKeys ? sortKeys[sortBy] : sortKeys.dateNewest]));
+
+export const imgListHooks = ({
+ searchSortProps,
+ setSelection,
+ images,
+ imageCount,
+}) => {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const dispatch = useDispatch();
+ const [highlighted, setHighlighted] = module.state.highlighted(null);
+ const [
+ showSelectImageError,
+ setShowSelectImageError,
+ ] = module.state.showSelectImageError(false);
+ const [showSizeError, setShowSizeError] = module.state.showSizeError(false);
+ const list = module.displayList({ ...searchSortProps, images });
+
+ return {
+ galleryError: {
+ show: showSelectImageError,
+ set: () => setShowSelectImageError(true),
+ dismiss: () => setShowSelectImageError(false),
+ message: messages.selectImageError,
+ },
+ inputError: {
+ show: showSizeError,
+ set: () => setShowSizeError(true),
+ dismiss: () => setShowSizeError(false),
+ message: messages.fileSizeError,
+ },
+ images,
+ galleryProps: {
+ galleryIsEmpty: Object.keys(images).length === 0,
+ searchIsEmpty: list.length === 0,
+ displayList: list,
+ highlighted,
+ onHighlightChange: (e) => setHighlighted(e.target.value),
+ emptyGalleryLabel: messages.emptyGalleryLabel,
+ allowLazyLoad: true,
+ fetchNextPage: ({ pageNumber }) => dispatch(thunkActions.app.fetchImages({ pageNumber })),
+ assetCount: imageCount,
+ },
+ // highlight by id
+ selectBtnProps: {
+ onClick: () => {
+ if (highlighted) {
+ const highlightedImage = images.find(image => image.id === highlighted);
+ setSelection(highlightedImage);
+ } else {
+ setShowSelectImageError(true);
+ }
+ },
+ },
+ };
+};
+
+export const checkValidFileSize = ({
+ selectedFile,
+ clearSelection,
+ onSizeFail,
+}) => {
+ // Check if the file size is greater than 10 MB, upload size limit
+ if (selectedFile.size > 10000000) {
+ clearSelection();
+ onSizeFail();
+ return false;
+ }
+ return true;
+};
+
+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) => {
+ const selectedFile = e.target.files[0];
+ if (selectedFile && module.checkValidFileSize({
+ selectedFile,
+ clearSelection,
+ onSizeFail: () => {
+ imgList.inputError.set();
+ },
+ })) {
+ dispatch(
+ thunkActions.app.uploadAsset({
+ file: selectedFile,
+ setSelection,
+ }),
+ );
+ }
+ };
+
+ return {
+ click,
+ addFile,
+ ref,
+ };
+};
+
+export const imgHooks = ({
+ setSelection,
+ clearSelection,
+ images,
+ imageCount,
+}) => {
+ const searchSortProps = module.searchAndSortHooks();
+ const imgList = module.imgListHooks({
+ setSelection,
+ searchSortProps,
+ images,
+ imageCount,
+ });
+ const fileInput = module.fileInputHooks({
+ setSelection,
+ clearSelection,
+ imgList,
+ });
+ const {
+ galleryError,
+ galleryProps,
+ inputError,
+ selectBtnProps,
+ } = imgList;
+
+ return {
+ galleryError,
+ inputError,
+ fileInput,
+ galleryProps,
+ searchSortProps,
+ selectBtnProps,
+ };
+};
diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.test.js b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.test.js
new file mode 100644
index 0000000000..4029bd9f0d
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.test.js
@@ -0,0 +1,328 @@
+import React from 'react';
+import { dispatch } from 'react-redux';
+
+import { MockUseState } from '../../../testUtils';
+import { keyStore } from '../../../utils';
+import { thunkActions } from '../../../data/redux';
+
+import * as hooks from './hooks';
+import { sortFunctions, sortKeys } from './utils';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useRef: jest.fn(val => ({ current: val })),
+ useEffect: jest.fn(),
+ useCallback: (cb, prereqs) => ({ cb, prereqs }),
+}));
+
+jest.mock('react-redux', () => {
+ const dispatchFn = jest.fn();
+ return {
+ ...jest.requireActual('react-redux'),
+ dispatch: dispatchFn,
+ useDispatch: jest.fn(() => dispatchFn),
+ };
+});
+
+jest.mock('../../../data/redux', () => ({
+ thunkActions: {
+ app: {
+ uploadAsset: jest.fn(),
+ },
+ },
+}));
+
+const state = new MockUseState(hooks);
+const hookKeys = keyStore(hooks);
+let hook;
+const testValue = 'testVALUEVALIDIMAGE';
+const testValueInvalidImage = { value: 'testVALUEVALIDIMAGE', size: 90000000 };
+
+describe('SelectImageModal hooks', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ describe('state hooks', () => {
+ state.testGetter(state.keys.highlighted);
+ state.testGetter(state.keys.showSelectImageError);
+ state.testGetter(state.keys.searchString);
+ state.testGetter(state.keys.sortBy);
+ state.testGetter(state.keys.showSizeError);
+ });
+
+ describe('using state', () => {
+ beforeEach(() => { state.mock(); });
+ afterEach(() => { state.restore(); });
+
+ describe('searchAndSortHooks', () => {
+ beforeEach(() => {
+ hook = hooks.searchAndSortHooks();
+ });
+ it('returns searchString value, initialized to an empty string', () => {
+ expect(state.stateVals.searchString).toEqual(hook.searchString);
+ expect(state.stateVals.searchString).toEqual('');
+ });
+ it('returns highlighted value, initialized to dateNewest', () => {
+ expect(state.stateVals.sortBy).toEqual(hook.sortBy);
+ expect(state.stateVals.sortBy).toEqual(sortKeys.dateNewest);
+ });
+ test('onSearchChange sets searchString with event target value', () => {
+ hook.onSearchChange({ target: { value: testValue } });
+ expect(state.setState.searchString).toHaveBeenCalledWith(testValue);
+ });
+ test('clearSearchString sets search string to empty string', () => {
+ hook.clearSearchString();
+ expect(state.setState.searchString).toHaveBeenCalledWith('');
+ });
+ test('onSortClick takes a key and returns callback to set sortBY to that key', () => {
+ hook.onSortClick(testValue);
+ expect(state.setState.sortBy).not.toHaveBeenCalled();
+ hook.onSortClick(testValue)();
+ expect(state.setState.sortBy).toHaveBeenCalledWith(testValue);
+ });
+ });
+ describe('filteredList', () => {
+ const matching = [
+ 'test',
+ 'TEst',
+ 'eeees',
+ 'essSSSS',
+ ];
+ const notMatching = ['bad', 'other', 'bad stuff'];
+ const searchString = 'eS';
+ test('returns list filtered lowercase by displayName', () => {
+ const filter = jest.fn(cb => ({ filter: cb }));
+ hook = hooks.filteredList({ searchString, imageList: { filter } });
+ expect(filter).toHaveBeenCalled();
+ const [[filterCb]] = filter.mock.calls;
+ matching.forEach(val => expect(filterCb({ displayName: val })).toEqual(true));
+ notMatching.forEach(val => expect(filterCb({ displayName: val })).toEqual(false));
+ });
+ });
+ describe('displayList', () => {
+ const props = {
+ images: ['data1', 'data2', 'other distinct data'],
+ sortBy: sortKeys.dateNewest,
+ searchString: 'test search string',
+
+ };
+ const load = (loadProps = {}) => {
+ jest.spyOn(hooks, hookKeys.filteredList).mockImplementationOnce(
+ ({ searchString, imageList }) => ({
+ sort: (cb) => ({ filteredList: { searchString, imageList }, sort: { cb } }),
+ }),
+ );
+ hook = hooks.displayList({ ...props, ...loadProps });
+ };
+ it('returns a sorted filtered list, based on the searchString and imageList values', () => {
+ load();
+ expect(hook.filteredList.searchString).toEqual(props.searchString);
+ expect(hook.filteredList.imageList).toEqual(Object.values(props.images));
+ });
+ describe('sort behavior', () => {
+ Object.keys(sortKeys).forEach(key => {
+ test(`it sorts by ${key} when selected`, () => {
+ load({ sortBy: sortKeys[key] });
+ expect(hook.sort).toEqual({ cb: sortFunctions[key] });
+ });
+ });
+ test('defaults to sorting by dateNewest', () => {
+ load();
+ expect(hook.sort).toEqual({ cb: sortFunctions.dateNewest });
+ });
+ });
+ });
+ describe('imgListHooks outputs', () => {
+ const props = {
+ setSelection: jest.fn(),
+ searchSortProps: { searchString: 'Es', sortBy: sortKeys.dateNewest },
+ images: [
+ {
+ displayName: 'sOmEuiMAge',
+ staTICUrl: '/assets/sOmEuiMAge',
+ id: 'sOmEuiMAgeURl',
+ },
+ ],
+ };
+ const displayList = (args) => ({ displayList: args });
+ const load = () => {
+ jest.spyOn(hooks, hookKeys.displayList).mockImplementationOnce(displayList);
+ hook = hooks.imgListHooks(props);
+ };
+ beforeEach(() => {
+ load();
+ });
+ describe('selectBtnProps', () => {
+ test('on click, if sets selection to the image with the same id', () => {
+ const highlighted = 'sOmEuiMAgeURl';
+ const highlightedValue = { displayName: 'sOmEuiMAge', staTICUrl: '/assets/sOmEuiMAge', id: 'sOmEuiMAgeURl' };
+ state.mockVal(state.keys.highlighted, highlighted);
+ load();
+ expect(props.setSelection).not.toHaveBeenCalled();
+ hook.selectBtnProps.onClick();
+ expect(props.setSelection).toHaveBeenCalledWith(highlightedValue);
+ });
+ test('on click, sets showSelectImageError to true if nothing is highlighted', () => {
+ state.mockVal(state.keys.highlighted, null);
+ load();
+ hook.selectBtnProps.onClick();
+ expect(props.setSelection).not.toHaveBeenCalled();
+ expect(state.setState.showSelectImageError).toHaveBeenCalledWith(true);
+ });
+ });
+ describe('galleryProps', () => {
+ it('returns highlighted value, initialized to null', () => {
+ expect(hook.galleryProps.highlighted).toEqual(state.stateVals.highlighted);
+ expect(state.stateVals.highlighted).toEqual(null);
+ });
+ test('onHighlightChange sets highlighted with event target value', () => {
+ hook.galleryProps.onHighlightChange({ target: { value: testValue } });
+ expect(state.setState.highlighted).toHaveBeenCalledWith(testValue);
+ });
+ test('displayList returns displayListhook called with searchSortProps and images', () => {
+ expect(hook.galleryProps.displayList).toEqual(displayList({
+ ...props.searchSortProps,
+ images: props.images,
+ }));
+ });
+ });
+ describe('galleryError', () => {
+ test('show is initialized to false and returns properly', () => {
+ const show = 'sHOWSelectiMaGEeRROr';
+ expect(hook.galleryError.show).toEqual(false);
+ state.mockVal(state.keys.showSelectImageError, show);
+ hook = hooks.imgListHooks(props);
+ expect(hook.galleryError.show).toEqual(show);
+ });
+ test('set sets showSelectImageError to true', () => {
+ hook.galleryError.set();
+ expect(state.setState.showSelectImageError).toHaveBeenCalledWith(true);
+ });
+ test('dismiss sets showSelectImageError to false', () => {
+ hook.galleryError.dismiss();
+ expect(state.setState.showSelectImageError).toHaveBeenCalledWith(false);
+ });
+ // TODO
+ // it('returns selectImageError value, initialized to false', () => {
+ // expect(hook.selectImageErrorProps.isError).toEqual(state.stateVals.isSelectImageError);
+ // expect(state.stateVals.isSelectImageError).toEqual(false);
+ // });
+ // test('dismissError sets selectImageError to false', () => {
+ // hook.selectImageErrorProps.dismissError();
+ // expect(state.setState.isSelectImageError).toHaveBeenCalledWith(false);
+ // });
+ });
+ });
+ });
+ describe('checkValidFileSize', () => {
+ const selectedFileFail = testValueInvalidImage;
+ const selectedFileSuccess = { value: testValue, size: 2000 };
+ const clearSelection = jest.fn();
+ const onSizeFail = jest.fn();
+ it('returns false for valid file size ', () => {
+ hook = hooks.checkValidFileSize({ selectedFile: selectedFileFail, clearSelection, onSizeFail });
+ expect(clearSelection).toHaveBeenCalled();
+ expect(onSizeFail).toHaveBeenCalled();
+ expect(hook).toEqual(false);
+ });
+ it('returns true for valid file size', () => {
+ hook = hooks.checkValidFileSize({ selectedFile: selectedFileSuccess, clearSelection, onSizeFail });
+ expect(hook).toEqual(true);
+ });
+ });
+ describe('fileInputHooks', () => {
+ const setSelection = jest.fn();
+ const clearSelection = jest.fn();
+ const imgList = { inputError: { show: true, dismiss: jest.fn(), set: jest.fn() } };
+ const spies = {};
+ beforeEach(() => {
+ hook = hooks.fileInputHooks({ setSelection, clearSelection, imgList });
+ });
+ it('returns a ref for the file input', () => {
+ expect(hook.ref).toEqual({ current: undefined });
+ });
+ test('click calls current.click on the ref', () => {
+ const click = jest.fn();
+ React.useRef.mockReturnValueOnce({ current: { click } });
+ hook = hooks.fileInputHooks({ setSelection });
+ hook.click();
+ expect(click).toHaveBeenCalled();
+ });
+ describe('addFile (uploadAsset args)', () => {
+ const eventSuccess = { target: { files: [{ value: testValue, size: 2000 }] } };
+ const eventFailure = { target: { files: [testValueInvalidImage] } };
+ it('image fails to upload if file size is greater than 1000000', () => {
+ const checkValidFileSize = false;
+ spies.checkValidFileSize = jest.spyOn(hooks, hookKeys.checkValidFileSize)
+ .mockReturnValueOnce(checkValidFileSize);
+ hook.addFile(eventFailure);
+ expect(spies.checkValidFileSize.mock.calls.length).toEqual(1);
+ expect(spies.checkValidFileSize).toHaveReturnedWith(false);
+ });
+ it('dispatches uploadAsset thunkAction with the first target file and setSelection', () => {
+ const checkValidFileSize = true;
+ spies.checkValidFileSize = jest.spyOn(hooks, hookKeys.checkValidFileSize)
+ .mockReturnValueOnce(checkValidFileSize);
+ hook.addFile(eventSuccess);
+ expect(spies.checkValidFileSize.mock.calls.length).toEqual(1);
+ expect(spies.checkValidFileSize).toHaveReturnedWith(true);
+ expect(dispatch).toHaveBeenCalledWith(thunkActions.app.uploadAsset({
+ file: testValue,
+ setSelection,
+ }));
+ });
+ });
+ });
+ describe('imgHooks wrapper', () => {
+ const imgListHooks = {
+ galleryProps: 'some gallery props',
+ selectBtnProps: 'some select btn props',
+ };
+ const searchAndSortHooks = { search: 'props' };
+ const fileInputHooks = { file: 'input hooks' };
+ const images = { sOmEuiMAge: { staTICUrl: '/assets/sOmEuiMAge' } };
+ const imageCount = 1;
+
+ const setSelection = jest.fn();
+ const clearSelection = jest.fn();
+ const spies = {};
+ beforeEach(() => {
+ spies.imgList = jest.spyOn(hooks, hookKeys.imgListHooks)
+ .mockReturnValueOnce(imgListHooks);
+ spies.search = jest.spyOn(hooks, hookKeys.searchAndSortHooks)
+ .mockReturnValueOnce(searchAndSortHooks);
+ spies.file = jest.spyOn(hooks, hookKeys.fileInputHooks)
+ .mockReturnValueOnce(fileInputHooks);
+ hook = hooks.imgHooks({
+ setSelection, clearSelection, images, imageCount,
+ });
+ });
+ it('forwards fileInputHooks as fileInput, called with uploadAsset prop', () => {
+ expect(hook.fileInput).toEqual(fileInputHooks);
+ expect(spies.file.mock.calls.length).toEqual(1);
+ expect(spies.file).toHaveBeenCalledWith({
+ setSelection, clearSelection, imgList: imgListHooks,
+ });
+ });
+ it('initializes imgListHooks with setSelection,searchAndSortHooks, and images', () => {
+ expect(spies.imgList.mock.calls.length).toEqual(1);
+ expect(spies.imgList).toHaveBeenCalledWith({
+ setSelection,
+ searchSortProps: searchAndSortHooks,
+ images,
+ imageCount,
+ });
+ });
+ it('forwards searchAndSortHooks as searchSortProps', () => {
+ expect(hook.searchSortProps).toEqual(searchAndSortHooks);
+ expect(spies.file.mock.calls.length).toEqual(1);
+ expect(spies.file).toHaveBeenCalledWith({
+ setSelection, clearSelection, imgList: imgListHooks,
+ });
+ });
+ it('forwards galleryProps and selectBtnProps from the image list hooks', () => {
+ expect(hook.galleryProps).toEqual(imgListHooks.galleryProps);
+ expect(hook.selectBtnProps).toEqual(imgListHooks.selectBtnProps);
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.jsx b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.jsx
new file mode 100644
index 0000000000..81e4802cb7
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.jsx
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import * as hooks from './hooks';
+import { acceptedImgKeys } from './utils';
+import SelectionModal from '../../SelectionModal';
+import messages from './messages';
+import { RequestKeys } from '../../../data/constants/requests';
+import { selectors } from '../../../data/redux';
+
+const SelectImageModal = ({
+ isOpen,
+ close,
+ setSelection,
+ clearSelection,
+ images,
+ // redux
+ isLoaded,
+ isFetchError,
+ isUploadError,
+ imageCount,
+}) => {
+ const {
+ galleryError,
+ inputError,
+ fileInput,
+ galleryProps,
+ searchSortProps,
+ selectBtnProps,
+ } = hooks.imgHooks({
+ setSelection,
+ clearSelection,
+ images: images.current,
+ imageCount,
+ });
+
+ const modalMessages = {
+ confirmMsg: messages.nextButtonLabel,
+ titleMsg: messages.titleLabel,
+ uploadButtonMsg: messages.uploadButtonLabel,
+ fetchError: messages.fetchImagesError,
+ uploadError: messages.uploadImageError,
+ };
+
+ return (
+
+ );
+};
+
+SelectImageModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ close: PropTypes.func.isRequired,
+ setSelection: PropTypes.func.isRequired,
+ clearSelection: PropTypes.func.isRequired,
+ images: PropTypes.arrayOf(PropTypes.string).isRequired,
+ // redux
+ isLoaded: PropTypes.bool.isRequired,
+ isFetchError: PropTypes.bool.isRequired,
+ isUploadError: PropTypes.bool.isRequired,
+ imageCount: PropTypes.number.isRequired,
+};
+
+export const mapStateToProps = (state) => ({
+ isLoaded: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchImages }),
+ isFetchError: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchImages }),
+ isUploadError: selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadAsset }),
+ imageCount: state.app.imageCount,
+});
+
+export const mapDispatchToProps = {};
+
+export const SelectImageModalInternal = SelectImageModal; // For testing only
+export default connect(mapStateToProps, mapDispatchToProps)(SelectImageModal);
diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.test.jsx b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.test.jsx
new file mode 100644
index 0000000000..0bd810d280
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.test.jsx
@@ -0,0 +1,114 @@
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { formatMessage } from '../../../testUtils';
+import SelectionModal from '../../SelectionModal';
+import * as hooks from './hooks';
+import { SelectImageModalInternal as 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', () => ({
+ imgHooks: jest.fn(() => ({
+ galleryError: {
+ show: 'ShoWERror gAlLery',
+ set: jest.fn(),
+ dismiss: jest.fn(),
+ message: {
+ id: 'Gallery error id',
+ defaultMessage: 'Gallery error',
+ description: 'Gallery error',
+ },
+ },
+ inputError: {
+ show: 'ShoWERror inPUT',
+ set: jest.fn(),
+ dismiss: jest.fn(),
+ message: {
+ id: 'Input error id',
+ defaultMessage: 'Input error',
+ description: 'Input error',
+ },
+ },
+ fileInput: {
+ addFile: 'imgHooks.fileInput.addFile',
+ click: 'imgHooks.fileInput.click',
+ ref: 'imgHooks.fileInput.ref',
+ },
+ galleryProps: { gallery: 'props' },
+ searchSortProps: { search: 'sortProps' },
+ selectBtnProps: { select: 'btnProps' },
+ })),
+}));
+
+jest.mock('../../../data/redux', () => ({
+ selectors: {
+ requests: {
+ isPending: (state, { requestKey }) => ({ isPending: { state, requestKey } }),
+ },
+ },
+}));
+
+describe('SelectImageModal', () => {
+ describe('component', () => {
+ const props = {
+ isOpen: true,
+ close: jest.fn().mockName('props.close'),
+ setSelection: jest.fn().mockName('props.setSelection'),
+ clearSelection: jest.fn().mockName('props.clearSelection'),
+ images: mockImagesRef,
+ intl: { formatMessage },
+ };
+ let el;
+ const imgHooks = hooks.imgHooks();
+ beforeEach(() => {
+ el = shallow( );
+ });
+ test('snapshot', () => {
+ expect(el.snapshot).toMatchSnapshot();
+ });
+ it('provides confirm action, forwarding selectBtnProps from imgHooks', () => {
+ expect(el.instance.findByType(SelectionModal)[0].props.selectBtnProps).toEqual(
+ expect.objectContaining({ ...hooks.imgHooks().selectBtnProps }),
+ );
+ });
+ it('provides file upload button linked to fileInput.click', () => {
+ expect(el.instance.findByType(SelectionModal)[0].props.fileInput.click).toEqual(
+ imgHooks.fileInput.click,
+ );
+ });
+ it('provides a SearchSort component with searchSortProps from imgHooks', () => {
+ expect(el.instance.findByType(SelectionModal)[0].props.searchSortProps).toEqual(imgHooks.searchSortProps);
+ });
+ it('provides a Gallery component with galleryProps from imgHooks', () => {
+ expect(el.instance.findByType(SelectionModal)[0].props.galleryProps).toEqual(imgHooks.galleryProps);
+ });
+ it('provides a FileInput component with fileInput props from imgHooks', () => {
+ expect(el.instance.findByType(SelectionModal)[0].props.fileInput).toMatchObject(imgHooks.fileInput);
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/messages.js b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/messages.js
new file mode 100644
index 0000000000..f39c307798
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/messages.js
@@ -0,0 +1,77 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+
+ nextButtonLabel: {
+ id: 'authoring.texteditor.selectimagemodal.next.label',
+ defaultMessage: 'Next',
+ description: 'Label for Next button',
+ },
+ uploadButtonLabel: {
+ id: 'authoring.texteditor.selectimagemodal.upload.label',
+ defaultMessage: 'Upload a new image (10 MB max)',
+ description: 'Label for upload button',
+ },
+ titleLabel: {
+ id: 'authoring.texteditor.selectimagemodal.title.label',
+ defaultMessage: 'Add an image',
+ description: 'Title for the select image modal',
+ },
+
+ // Sort Dropdown
+ sortByDateNewest: {
+ id: 'authoring.texteditor.selectimagemodal.sort.datenewest.label',
+ defaultMessage: 'By date added (newest)',
+ description: 'Dropdown label for sorting by date (newest)',
+ },
+ sortByDateOldest: {
+ id: 'authoring.texteditor.selectimagemodal.sort.dateoldest.label',
+ defaultMessage: 'By date added (oldest)',
+ description: 'Dropdown label for sorting by date (oldest)',
+ },
+ sortByNameAscending: {
+ id: 'authoring.texteditor.selectimagemodal.sort.nameascending.label',
+ defaultMessage: 'By name (ascending)',
+ description: 'Dropdown label for sorting by name (ascending)',
+ },
+ sortByNameDescending: {
+ id: 'authoring.texteditor.selectimagemodal.sort.namedescending.label',
+ defaultMessage: 'By name (descending)',
+ description: 'Dropdown label for sorting by name (descending)',
+ },
+
+ // Gallery
+ emptyGalleryLabel: {
+ id: 'authoring.texteditor.selectimagemodal.emptyGalleryLabel',
+ defaultMessage:
+ 'No images found in your gallery. Please upload an image using the button below.',
+ description: 'Label for when image gallery is empty.',
+ },
+
+ // Errors
+ uploadImageError: {
+ id: 'authoring.texteditor.selectimagemodal.error.uploadImageError',
+ defaultMessage: 'Failed to upload image. Please try again.',
+ description: 'Message presented to user when image fails to upload',
+ },
+ fetchImagesError: {
+ id: 'authoring.texteditor.selectimagemodal.error.fetchImagesError',
+ defaultMessage: 'Failed to obtain course images. Please try again.',
+ description: 'Message presented to user when images are not found',
+ },
+ fileSizeError: {
+ id: 'authoring.texteditor.selectimagemodal.error.fileSizeError',
+ defaultMessage:
+ 'Images must be 10 MB or less. Please resize image and try again.',
+ description:
+ ' Message presented to user when file size of image is larger than 10 MB',
+ },
+ selectImageError: {
+ id: 'authoring.texteditor.selectimagemodal.error.selectImageError',
+ defaultMessage: 'Select an image to continue.',
+ description:
+ 'Message presented to user when clicking Next without selecting an image',
+ },
+});
+
+export default messages;
diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/utils.js b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/utils.js
new file mode 100644
index 0000000000..a94a6ebf24
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/utils.js
@@ -0,0 +1,47 @@
+import { StrictDict, keyStore } from '../../../utils';
+import messages from './messages';
+
+export const sortKeys = StrictDict({
+ dateNewest: 'dateNewest',
+ dateOldest: 'dateOldest',
+ nameAscending: 'nameAscending',
+ nameDescending: 'nameDescending',
+});
+
+const messageKeys = keyStore(messages);
+
+export const sortMessages = StrictDict({
+ dateNewest: messages[messageKeys.sortByDateNewest],
+ dateOldest: messages[messageKeys.sortByDateOldest],
+ nameAscending: messages[messageKeys.sortByNameAscending],
+ nameDescending: messages[messageKeys.sortByNameDescending],
+});
+
+export const sortFunctions = StrictDict({
+ dateNewest: (a, b) => b.dateAdded - a.dateAdded,
+ dateOldest: (a, b) => a.dateAdded - b.dateAdded,
+ nameAscending: (a, b) => {
+ const nameA = a.displayName.toLowerCase();
+ const nameB = b.displayName.toLowerCase();
+ if (nameA < nameB) { return -1; }
+ if (nameB < nameA) { return 1; }
+ return b.dateAdded - a.dateAdded;
+ },
+ nameDescending: (a, b) => {
+ const nameA = a.displayName.toLowerCase();
+ const nameB = b.displayName.toLowerCase();
+ if (nameA < nameB) { return 1; }
+ if (nameB < nameA) { return -1; }
+ return b.dateAdded - a.dateAdded;
+ },
+});
+
+export const acceptedImgKeys = StrictDict({
+ gif: '.gif',
+ jpg: '.jpg',
+ jpeg: '.jpeg',
+ png: '.png',
+ tif: '.tif',
+ tiff: '.tiff',
+ ico: '.ico',
+});
diff --git a/src/editors/sharedComponents/ImageUploadModal/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/ImageUploadModal/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..ac23319881
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,92 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ImageUploadModal component snapshot: no selection (Select Image Modal) 1`] = `
+
+`;
+
+exports[`ImageUploadModal component snapshot: selection has no externalUrl (Select Image Modal) 1`] = `
+
+`;
+
+exports[`ImageUploadModal component snapshot: with selection content (ImageSettingsUpload) 1`] = `
+
+`;
diff --git a/src/editors/sharedComponents/ImageUploadModal/index.jsx b/src/editors/sharedComponents/ImageUploadModal/index.jsx
new file mode 100644
index 0000000000..e5a705ce37
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/index.jsx
@@ -0,0 +1,196 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { injectIntl } from '@edx/frontend-platform/i18n';
+import * as tinyMCEKeys from '../../data/constants/tinyMCE';
+import ImageSettingsModal from './ImageSettingsModal';
+import SelectImageModal from './SelectImageModal';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from '.';
+import { updateImageDimensions } from '../TinyMceWidget/hooks';
+
+export const propsString = (props) => (
+ Object.keys(props).map((key) => `${key}="${props[key]}"`).join(' ')
+);
+
+export const imgProps = ({
+ settings,
+ selection,
+ lmsEndpointUrl,
+ editorType,
+}) => {
+ let url = selection?.externalUrl;
+ if (url?.startsWith(lmsEndpointUrl) && editorType !== 'expandable') {
+ const sourceEndIndex = lmsEndpointUrl.length;
+ url = url.substring(sourceEndIndex);
+ }
+ return {
+ src: url,
+ alt: settings.isDecorative ? '' : settings.altText,
+ width: settings.dimensions.width,
+ height: settings.dimensions.height,
+ };
+};
+
+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,
+ });
+
+ // eslint-disable-next-line no-param-reassign
+ 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,
+ ...args
+ }) => (
+ settings,
+ ) => {
+ saveToEditor({ settings, ...args });
+ updateReactState({ settings, ...args });
+
+ close();
+ args.setSelection(null);
+ },
+ onClose: ({ clearSelection, close }) => () => {
+ clearSelection();
+ close();
+ },
+ imgTag: ({
+ settings,
+ selection,
+ lmsEndpointUrl,
+ editorType,
+ }) => {
+ const props = module.imgProps({
+ settings,
+ selection,
+ lmsEndpointUrl,
+ editorType,
+ });
+ return ` `;
+ },
+ updateReactState,
+ updateImagesRef,
+ saveToEditor,
+ imgProps,
+ propsString,
+};
+
+const ImageUploadModal = ({
+ // eslint-disable-next-line
+ editorRef,
+ isOpen,
+ close,
+ clearSelection,
+ selection,
+ setSelection,
+ images,
+ editorType,
+ lmsEndpointUrl,
+}) => {
+ if (selection && selection.externalUrl) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+};
+
+ImageUploadModal.defaultProps = {
+ editorRef: null,
+ editorType: null,
+ selection: null,
+};
+ImageUploadModal.propTypes = {
+ clearSelection: PropTypes.func.isRequired,
+ close: PropTypes.func.isRequired,
+ editorRef: PropTypes.oneOfType([
+ PropTypes.func,
+ // eslint-disable-next-line react/forbid-prop-types
+ PropTypes.shape({ current: PropTypes.any }),
+ ]),
+ isOpen: PropTypes.bool.isRequired,
+ selection: PropTypes.shape({
+ url: PropTypes.string,
+ externalUrl: PropTypes.string,
+ altText: PropTypes.bool,
+ }),
+ setSelection: PropTypes.func.isRequired,
+ images: PropTypes.shape({}).isRequired,
+ lmsEndpointUrl: PropTypes.string.isRequired,
+ editorType: PropTypes.string,
+};
+
+export const ImageUploadModalInternal = ImageUploadModal; // For testing only
+export default injectIntl(ImageUploadModal);
diff --git a/src/editors/sharedComponents/ImageUploadModal/index.test.jsx b/src/editors/sharedComponents/ImageUploadModal/index.test.jsx
new file mode 100644
index 0000000000..b9b8fbc2bd
--- /dev/null
+++ b/src/editors/sharedComponents/ImageUploadModal/index.test.jsx
@@ -0,0 +1,184 @@
+/* eslint-disable no-import-assign */
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { keyStore } from '../../utils';
+import * as tinyMCEKeys from '../../data/constants/tinyMCE';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from '.';
+import * as tinyMceHooks from '../TinyMceWidget/hooks';
+
+jest.mock('./ImageSettingsModal', () => 'ImageSettingsModal');
+jest.mock('./SelectImageModal', () => 'SelectImageModal');
+
+const { ImageUploadModalInternal: ImageUploadModal } = module;
+const hookKeys = keyStore(module.hooks);
+
+const settings = {
+ altText: 'aLt tExt',
+ isDecorative: false,
+ dimensions: {
+ width: 2022,
+ height: 1619,
+ },
+};
+
+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' };
+ const url = 'uRl.cOm';
+ const expected = {
+ src: url,
+ alt: settings.altText,
+ width: settings.dimensions.width,
+ height: settings.dimensions.height,
+ };
+ const testImgTag = (args) => {
+ const output = module.hooks.imgTag({
+ settings: args.settings,
+ selection,
+ lmsEndpointUrl: 'sOmE',
+ });
+ expect(output).toEqual(` `);
+ };
+ test('It returns a html string which matches an image tag', () => {
+ testImgTag({ settings, expected });
+ });
+ test('If isDecorative is true, alt text is an empty string', () => {
+ testImgTag({
+ settings: { ...settings, isDecorative: true },
+ expected: { ...expected, alt: '' },
+ });
+ });
+ });
+ 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 = { 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, 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 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();
+ expect(setSelection).toBeCalledWith(null);
+ },
+ );
+ });
+ describe('onClose', () => {
+ it('takes and calls clearSelection and close callbacks', () => {
+ const clearSelection = jest.fn();
+ const close = jest.fn();
+ module.hooks.onClose({ clearSelection, close })();
+ expect(clearSelection).toHaveBeenCalled();
+ expect(close).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('component', () => {
+ let props;
+ let hooks;
+ beforeAll(() => {
+ hooks = module.hooks;
+ props = {
+ editorRef: { current: null },
+ isOpen: false,
+ close: jest.fn().mockName('props.close'),
+ clearSelection: jest.fn().mockName('props.clearSelection'),
+ 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'),
+ onClose: jest.fn().mockName('hooks.onClose'),
+ };
+ });
+ afterAll(() => {
+ module.hooks = hooks;
+ });
+ test('snapshot: with selection content (ImageSettingsUpload)', () => {
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ test('snapshot: selection has no externalUrl (Select Image Modal)', () => {
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ test('snapshot: no selection (Select Image Modal)', () => {
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/RawEditor/index.jsx b/src/editors/sharedComponents/RawEditor/index.jsx
new file mode 100644
index 0000000000..d6cccca65c
--- /dev/null
+++ b/src/editors/sharedComponents/RawEditor/index.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Alert } from '@openedx/paragon';
+
+import CodeEditor from '../CodeEditor';
+import { setAssetToStaticUrl } from '../TinyMceWidget/hooks';
+
+function getValue(content) {
+ if (!content) { return null; }
+ if (typeof content === 'string') { return content; }
+ return content.data?.data;
+}
+
+const RawEditor = ({
+ editorRef,
+ content,
+ lang,
+}) => {
+ const value = getValue(content) || '';
+ const staticUpdate = setAssetToStaticUrl({ editorValue: value });
+
+ return (
+
+ {lang === 'xml' ? null : (
+
+ You are using the raw {lang} editor.
+
+ )}
+ { value ? (
+
+ ) : null}
+
+
+ );
+};
+RawEditor.defaultProps = {
+ editorRef: null,
+ content: null,
+ lang: 'html',
+};
+RawEditor.propTypes = {
+ editorRef: PropTypes.oneOfType([
+ PropTypes.func,
+ // eslint-disable-next-line react/forbid-prop-types
+ PropTypes.shape({ current: PropTypes.any }),
+ ]),
+ content: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.shape({
+ data: PropTypes.shape({ data: PropTypes.string }),
+ }),
+ ]),
+ lang: PropTypes.string,
+};
+
+export default RawEditor;
diff --git a/src/editors/sharedComponents/RawEditor/index.test.jsx b/src/editors/sharedComponents/RawEditor/index.test.jsx
new file mode 100644
index 0000000000..8b68afaf30
--- /dev/null
+++ b/src/editors/sharedComponents/RawEditor/index.test.jsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import '@testing-library/jest-dom/extend-expect';
+
+import RawEditor from '.';
+
+jest.unmock('@openedx/paragon');
+
+const renderComponent = (props) => render(
+
+
+ ,
+);
+describe('RawEditor', () => {
+ const defaultProps = {
+ editorRef: {
+ current: {
+ value: 'Ref Value',
+ },
+ },
+ content: { data: { data: 'eDiTablE Text HtmL' } },
+ lang: 'html',
+ };
+ const xmlProps = {
+ editorRef: {
+ current: {
+ value: 'Ref Value',
+ },
+ },
+ content: { data: { data: 'eDiTablE Text XMl' } },
+ lang: 'xml',
+ };
+ const noContentProps = {
+ editorRef: {
+ current: {
+ value: 'Ref Value',
+ },
+ },
+ content: null,
+ lang: 'html',
+ width: { width: '80%' },
+ };
+
+ it('renders as expected with default behavior', () => {
+ renderComponent(defaultProps);
+ expect(screen.getByRole('alert')).toBeVisible();
+
+ expect(screen.getByText('eDiTablE Text HtmL')).toBeVisible();
+ });
+
+ it('updates the assets to static srcs', () => {
+ const updatedProps = {
+ ...defaultProps,
+ content: 'pick or ',
+ };
+ renderComponent(updatedProps);
+ expect(screen.getByText('"/static/img.jpeg"')).toBeVisible();
+
+ expect(screen.getByText('"/static/img2.jpeg"')).toBeVisible();
+
+ expect(screen.queryByText('"/asset-v1:org+run+term+type@asset+block@img.jpeg"')).toBeNull();
+
+ expect(screen.queryByText('"/assets/courseware/v1/hash/asset-v1:org+run+term+type@asset+block/img2.jpeg"')).toBeNull();
+ });
+
+ it('renders as expected with lang equal to xml', () => {
+ renderComponent(xmlProps);
+ expect(screen.queryByRole('alert')).toBeNull();
+
+ expect(screen.getByText('eDiTablE Text XMl')).toBeVisible();
+ });
+
+ it('renders as expected with content equal to null', () => {
+ renderComponent(noContentProps);
+ expect(screen.getByRole('alert')).toBeVisible();
+
+ expect(screen.queryByTestId('code-editor')).toBeNull();
+ });
+});
diff --git a/src/editors/sharedComponents/SelectableBox/FormCheckbox.jsx b/src/editors/sharedComponents/SelectableBox/FormCheckbox.jsx
new file mode 100644
index 0000000000..d2e1951ba5
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormCheckbox.jsx
@@ -0,0 +1,145 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { useCheckboxSetContext } from './FormCheckboxSetContext';
+import { FormGroupContextProvider, useFormGroupContext } from './FormGroupContext';
+import FormLabel from './FormLabel';
+import FormControlFeedback from './FormControlFeedback';
+
+const CheckboxControl = React.forwardRef(
+ ({ isIndeterminate, ...props }, ref) => {
+ const { getCheckboxControlProps, hasCheckboxSetProvider } = useCheckboxSetContext();
+ const defaultRef = React.useRef();
+ const resolvedRef = ref || defaultRef;
+ const { getControlProps } = useFormGroupContext();
+ let checkboxProps = getControlProps({
+ ...props,
+ className: classNames('pgn__form-checkbox-input', props.className),
+ });
+
+ if (hasCheckboxSetProvider) {
+ checkboxProps = getCheckboxControlProps(checkboxProps);
+ }
+
+ React.useEffect(() => {
+ // this if(resolvedRef.current) prevents console errors in testing
+ if (resolvedRef.current) {
+ resolvedRef.current.indeterminate = isIndeterminate;
+ }
+ }, [resolvedRef, isIndeterminate]);
+
+ return (
+
+ );
+ },
+);
+
+CheckboxControl.propTypes = {
+ /** Specifies whether the checkbox should be rendered in indeterminate state. */
+ isIndeterminate: PropTypes.bool,
+ /** Specifies class name to append to the base element. */
+ className: PropTypes.string,
+};
+
+CheckboxControl.defaultProps = {
+ isIndeterminate: false,
+ className: undefined,
+};
+
+const FormCheckbox = React.forwardRef(({
+ children,
+ className,
+ controlClassName,
+ labelClassName,
+ description,
+ isInvalid,
+ isValid,
+ controlAs,
+ floatLabelLeft,
+ ...props
+}, ref) => {
+ const { hasCheckboxSetProvider } = useCheckboxSetContext();
+ const { hasFormGroupProvider, useSetIsControlGroupEffect, getControlProps } = useFormGroupContext();
+ useSetIsControlGroupEffect(true);
+ const shouldActAsGroup = hasFormGroupProvider && !hasCheckboxSetProvider;
+ const groupProps = shouldActAsGroup ? {
+ ...getControlProps({}),
+ role: 'group',
+ } : {};
+
+ const control = React.createElement(controlAs, { ...props, className: controlClassName, ref });
+ return (
+
+
+ {control}
+
+
+ {children}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+ );
+});
+
+FormCheckbox.propTypes = {
+ /** Specifies id of the FormCheckbox component. */
+ id: PropTypes.string,
+ /** Specifies contents of the component. */
+ children: PropTypes.node.isRequired,
+ /** Specifies class name to append to the base element. */
+ className: PropTypes.string,
+ /** Specifies class name for control component. */
+ controlClassName: PropTypes.string,
+ /** Specifies class name for label component. */
+ labelClassName: PropTypes.string,
+ /** Specifies description to show under the checkbox. */
+ description: PropTypes.node,
+ /** Specifies whether to display checkbox in invalid state, this affects styling. */
+ isInvalid: PropTypes.bool,
+ /** Specifies whether to display checkbox in valid state, this affects styling. */
+ isValid: PropTypes.bool,
+ /** Specifies control element. */
+ controlAs: PropTypes.elementType,
+ /** Specifies whether the floating label should be aligned to the left. */
+ floatLabelLeft: PropTypes.bool,
+ /** Specifies whether the `FormCheckbox` is disabled. */
+ disabled: PropTypes.bool,
+};
+
+FormCheckbox.defaultProps = {
+ id: undefined,
+ className: undefined,
+ controlClassName: undefined,
+ labelClassName: undefined,
+ description: undefined,
+ isInvalid: false,
+ isValid: false,
+ controlAs: CheckboxControl,
+ floatLabelLeft: false,
+ disabled: false,
+};
+
+export { CheckboxControl };
+export default FormCheckbox;
diff --git a/src/editors/sharedComponents/SelectableBox/FormCheckboxSet.jsx b/src/editors/sharedComponents/SelectableBox/FormCheckboxSet.jsx
new file mode 100644
index 0000000000..2d90cada08
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormCheckboxSet.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useFormGroupContext } from './FormGroupContext';
+import { FormCheckboxSetContextProvider } from './FormCheckboxSetContext';
+import FormControlSet from './FormControlSet';
+
+const FormCheckboxSet = ({
+ children,
+ name,
+ value,
+ defaultValue,
+ isInline,
+ onChange,
+ onFocus,
+ onBlur,
+ ...props
+}) => {
+ const { getControlProps, useSetIsControlGroupEffect } = useFormGroupContext();
+ useSetIsControlGroupEffect(true);
+ const controlProps = getControlProps(props);
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+FormCheckboxSet.propTypes = {
+ /** Specifies contents of the component. */
+ children: PropTypes.node.isRequired,
+ /** Specifies class name to append to the base element. */
+ className: PropTypes.string,
+ /** Specifies name for the component. */
+ name: PropTypes.string.isRequired,
+ /** Specifies values for the checkboxes. */
+ value: PropTypes.arrayOf(PropTypes.string),
+ /** Specifies default values for the checkboxes. */
+ defaultValue: PropTypes.arrayOf(PropTypes.string),
+ /** Specifies whether to display components with inline styling. */
+ isInline: PropTypes.bool,
+ /** Specifies onChange event handler. */
+ onChange: PropTypes.func,
+ /** Specifies onFocus event handler. */
+ onFocus: PropTypes.func,
+ /** Specifies onBlur event handler. */
+ onBlur: PropTypes.func,
+};
+
+FormCheckboxSet.defaultProps = {
+ className: undefined,
+ value: undefined,
+ defaultValue: undefined,
+ isInline: false,
+ onChange: undefined,
+ onFocus: undefined,
+ onBlur: undefined,
+};
+
+export default FormCheckboxSet;
diff --git a/src/editors/sharedComponents/SelectableBox/FormCheckboxSetContext.jsx b/src/editors/sharedComponents/SelectableBox/FormCheckboxSetContext.jsx
new file mode 100644
index 0000000000..1dd45b5240
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormCheckboxSetContext.jsx
@@ -0,0 +1,77 @@
+import React, { useContext } from 'react';
+import PropTypes from 'prop-types';
+import { callAllHandlers } from './fieldUtils';
+
+const identityFn = props => props;
+
+const FormCheckboxSetContext = React.createContext({
+ getCheckboxControlProps: identityFn,
+ hasCheckboxSetProvider: false,
+});
+
+const useCheckboxSetContext = () => useContext(FormCheckboxSetContext);
+
+const FormCheckboxSetContextProvider = ({
+ children,
+ name,
+ onBlur,
+ onFocus,
+ onChange,
+ value,
+ defaultValue,
+}) => {
+ const isControlled = !defaultValue && Array.isArray(value);
+ const getCheckboxControlProps = (checkboxProps) => ({
+ ...checkboxProps,
+ name,
+ /* istanbul ignore next */
+ onBlur: checkboxProps.onBlur ? callAllHandlers(onBlur, checkboxProps.onBlur) : onBlur,
+ /* istanbul ignore next */
+ onFocus: checkboxProps.onFocus ? callAllHandlers(onFocus, checkboxProps.onFocus) : onFocus,
+ /* istanbul ignore next */
+ onChange: checkboxProps.onChange ? callAllHandlers(onChange, checkboxProps.onChange) : onChange,
+ checked: isControlled ? value.includes(checkboxProps.value) : undefined,
+ defaultChecked: isControlled ? undefined : (defaultValue && defaultValue.includes(checkboxProps.value)),
+ });
+ // eslint-disable-next-line react/jsx-no-constructed-context-values
+ const contextValue = {
+ name,
+ value,
+ defaultValue,
+ getCheckboxControlProps,
+ onBlur,
+ onFocus,
+ onChange,
+ hasCheckboxSetProvider: true,
+ };
+ return (
+
+ {children}
+
+ );
+};
+
+FormCheckboxSetContextProvider.propTypes = {
+ children: PropTypes.node.isRequired,
+ name: PropTypes.string,
+ onBlur: PropTypes.func,
+ onFocus: PropTypes.func,
+ onChange: PropTypes.func,
+ value: PropTypes.arrayOf(PropTypes.string),
+ defaultValue: PropTypes.arrayOf(PropTypes.string),
+};
+
+FormCheckboxSetContextProvider.defaultProps = {
+ onBlur: undefined,
+ name: undefined,
+ onFocus: undefined,
+ onChange: undefined,
+ value: undefined,
+ defaultValue: undefined,
+};
+
+export default FormCheckboxSetContext;
+export {
+ useCheckboxSetContext,
+ FormCheckboxSetContextProvider,
+};
diff --git a/src/editors/sharedComponents/SelectableBox/FormControlFeedback.jsx b/src/editors/sharedComponents/SelectableBox/FormControlFeedback.jsx
new file mode 100644
index 0000000000..b4e211218a
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormControlFeedback.jsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { useFormGroupContext } from './FormGroupContext';
+import FormText, { resolveTextType } from './FormText';
+
+const FormControlFeedback = ({ children, ...props }) => {
+ const { getDescriptorProps, isInvalid, isValid } = useFormGroupContext();
+ const descriptorProps = getDescriptorProps(props);
+ const className = classNames('pgn__form-control-description', props.className);
+ const textType = props.type || resolveTextType({ isInvalid, isValid });
+ return (
+
+ {children}
+
+ );
+};
+
+const FEEDBACK_TYPES = [
+ 'default',
+ 'valid',
+ 'invalid',
+ 'warning',
+ 'criteria-empty',
+ 'criteria-valid',
+ 'criteria-invalid',
+];
+
+FormControlFeedback.propTypes = {
+ /** Specifies contents of the component. */
+ children: PropTypes.node.isRequired,
+ /** Specifies class name to append to the base element. */
+ className: PropTypes.string,
+ /** Specifies whether to show an icon next to the text. */
+ hasIcon: PropTypes.bool,
+ /** Specifies feedback type, this affects styling. */
+ type: PropTypes.oneOf(FEEDBACK_TYPES),
+ /** Specifies icon to show, will only be shown if `hasIcon` prop is set to `true`. */
+ icon: PropTypes.node,
+ /** Specifies whether to show feedback with muted styling. */
+ muted: PropTypes.bool,
+};
+
+FormControlFeedback.defaultProps = {
+ hasIcon: true,
+ type: undefined,
+ icon: undefined,
+ className: undefined,
+ muted: false,
+};
+
+export default FormControlFeedback;
diff --git a/src/editors/sharedComponents/SelectableBox/FormControlSet.jsx b/src/editors/sharedComponents/SelectableBox/FormControlSet.jsx
new file mode 100644
index 0000000000..69052e7675
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormControlSet.jsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+const FormControlSet = ({
+ as,
+ className,
+ isInline,
+ children,
+ ...props
+}) => React.createElement(as, {
+ className: classNames(
+ className,
+ {
+ 'pgn__form-control-set': !isInline,
+ 'pgn__form-control-set-inline': isInline,
+ },
+ ),
+ ...props,
+}, children);
+
+FormControlSet.propTypes = {
+ /** Specifies the base element */
+ as: PropTypes.elementType,
+ /** A class name to append to the base element. */
+ className: PropTypes.string,
+ /** Specifies whether the component should be displayed with inline styling. */
+ isInline: PropTypes.bool,
+ /** Specifies contents of the component. */
+ children: PropTypes.node,
+};
+
+FormControlSet.defaultProps = {
+ as: 'div',
+ className: undefined,
+ isInline: false,
+ children: null,
+};
+
+export default FormControlSet;
diff --git a/src/editors/sharedComponents/SelectableBox/FormGroupContext.jsx b/src/editors/sharedComponents/SelectableBox/FormGroupContext.jsx
new file mode 100644
index 0000000000..af38a4f8ab
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormGroupContext.jsx
@@ -0,0 +1,121 @@
+import React, {
+ useState, useEffect, useMemo, useCallback,
+} from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import newId from './newId';
+import { useIdList, omitUndefinedProperties } from './fieldUtils';
+import { FORM_CONTROL_SIZES } from './constants';
+
+const identityFn = props => props;
+const noop = () => {};
+
+const FormGroupContext = React.createContext({
+ getControlProps: identityFn,
+ useSetIsControlGroupEffect: noop,
+ getLabelProps: identityFn,
+ getDescriptorProps: identityFn,
+ hasFormGroupProvider: false,
+});
+
+const useFormGroupContext = () => React.useContext(FormGroupContext);
+
+const useStateEffect = (initialState) => {
+ const [state, setState] = useState(initialState);
+ const useSetStateEffect = (newState) => {
+ useEffect(() => setState(newState), [newState]);
+ };
+ return [state, useSetStateEffect];
+};
+
+const FormGroupContextProvider = ({
+ children,
+ controlId: explicitControlId,
+ isInvalid,
+ isValid,
+ size,
+}) => {
+ const controlId = useMemo(() => explicitControlId || newId('form-field'), [explicitControlId]);
+ const [describedByIds, registerDescriptorId] = useIdList(controlId);
+ const [labelledByIds, registerLabelerId] = useIdList(controlId);
+ const [isControlGroup, useSetIsControlGroupEffect] = useStateEffect(false);
+
+ const getControlProps = useCallback((controlProps) => {
+ // labelledByIds from the list above should only be added to a control
+ // if it the control is a group. We prefer adding a condition here because:
+ // - Hooks cannot be called inside conditionals
+ // - The getLabelProps function below is forced to generate an id
+ // whether it is needed or not.
+ // - This is what allows consumers of Paragon to use
+ // interchangeably between ControlGroup type controls and regular Controls
+ const labelledByIdsForControl = isControlGroup ? labelledByIds : undefined;
+ return omitUndefinedProperties({
+ ...controlProps,
+ 'aria-describedby': classNames(controlProps['aria-describedby'], describedByIds) || undefined,
+ 'aria-labelledby': classNames(controlProps['aria-labelledby'], labelledByIdsForControl) || undefined,
+ id: controlId,
+ });
+ }, [
+ isControlGroup,
+ describedByIds,
+ labelledByIds,
+ controlId,
+ ]);
+
+ const getLabelProps = (labelProps) => {
+ const id = registerLabelerId(labelProps?.id);
+ if (isControlGroup) {
+ return { ...labelProps, id };
+ }
+ return { ...labelProps, htmlFor: controlId };
+ };
+
+ const getDescriptorProps = (descriptorProps) => {
+ const id = registerDescriptorId(descriptorProps?.id);
+ return { ...descriptorProps, id };
+ };
+
+ // eslint-disable-next-line react/jsx-no-constructed-context-values
+ const contextValue = {
+ getControlProps,
+ getLabelProps,
+ getDescriptorProps,
+ useSetIsControlGroupEffect,
+ isControlGroup,
+ controlId,
+ isInvalid,
+ isValid,
+ size,
+ hasFormGroupProvider: true,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+FormGroupContextProvider.propTypes = {
+ children: PropTypes.node.isRequired,
+ controlId: PropTypes.string,
+ isInvalid: PropTypes.bool,
+ isValid: PropTypes.bool,
+ size: PropTypes.oneOf([
+ FORM_CONTROL_SIZES.SMALL,
+ FORM_CONTROL_SIZES.LARGE,
+ ]),
+};
+
+FormGroupContextProvider.defaultProps = {
+ controlId: undefined,
+ isInvalid: undefined,
+ isValid: undefined,
+ size: undefined,
+};
+
+export {
+ FormGroupContext,
+ FormGroupContextProvider,
+ useFormGroupContext,
+};
diff --git a/src/editors/sharedComponents/SelectableBox/FormLabel.jsx b/src/editors/sharedComponents/SelectableBox/FormLabel.jsx
new file mode 100644
index 0000000000..7f45bb9bc8
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormLabel.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { useFormGroupContext } from './FormGroupContext';
+import { FORM_CONTROL_SIZES } from './constants';
+
+const FormLabel = ({ children, isInline, ...props }) => {
+ const { size, isControlGroup, getLabelProps } = useFormGroupContext();
+ const className = classNames(
+ 'pgn__form-label',
+ {
+ 'pgn__form-label-inline': isInline,
+ 'pgn__form-label-lg': size === FORM_CONTROL_SIZES.LARGE,
+ 'pgn__form-label-sm': size === FORM_CONTROL_SIZES.SMALL,
+ },
+ props.className,
+ );
+ const labelProps = getLabelProps({ ...props, className });
+ const componentType = isControlGroup ? 'p' : 'label';
+ return React.createElement(componentType, labelProps, children);
+};
+
+const SIZE_CHOICES = ['sm', 'lg'];
+
+FormLabel.propTypes = {
+ /** Specifies class name to append to the base element. */
+ className: PropTypes.string,
+ /** Specifies contents of the component. */
+ children: PropTypes.node.isRequired,
+ /** Specifies whether the component should be displayed with inline styling. */
+ isInline: PropTypes.bool,
+ /** Specifies size of the component. */
+ size: PropTypes.oneOf(SIZE_CHOICES),
+};
+
+FormLabel.defaultProps = {
+ isInline: false,
+ size: undefined,
+ className: undefined,
+};
+
+export default FormLabel;
diff --git a/src/editors/sharedComponents/SelectableBox/FormRadio.jsx b/src/editors/sharedComponents/SelectableBox/FormRadio.jsx
new file mode 100644
index 0000000000..43e4133d66
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormRadio.jsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { useRadioSetContext } from './FormRadioSetContext';
+import { FormGroupContextProvider, useFormGroupContext } from './FormGroupContext';
+import FormLabel from './FormLabel';
+import FormControlFeedback from './FormControlFeedback';
+
+const RadioControl = React.forwardRef((props, ref) => {
+ const { getControlProps } = useFormGroupContext();
+ const { getRadioControlProps, hasRadioSetProvider } = useRadioSetContext();
+ let radioProps = getControlProps({
+ ...props,
+ className: classNames('pgn__form-radio-input', props.className),
+ });
+
+ if (hasRadioSetProvider) {
+ radioProps = getRadioControlProps(radioProps);
+ }
+
+ const onChange = (...args) => {
+ if (radioProps.onChange) {
+ radioProps.onChange(...args);
+ }
+ };
+
+ return (
+
+ );
+});
+
+RadioControl.propTypes = {
+ className: PropTypes.string,
+};
+
+RadioControl.defaultProps = {
+ className: undefined,
+};
+
+const FormRadio = React.forwardRef(({
+ children,
+ className,
+ controlClassName,
+ labelClassName,
+ description,
+ isInvalid,
+ isValid,
+ ...props
+}, ref) => (
+
+
+
+
+
+ {children}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+));
+
+FormRadio.propTypes = {
+ /** Specifies id of the FormRadio component. */
+ id: PropTypes.string,
+ /** Specifies contents of the component. */
+ children: PropTypes.node.isRequired,
+ /** Specifies class name to append to the base element. */
+ className: PropTypes.string,
+ /** Specifies class name for control component. */
+ controlClassName: PropTypes.string,
+ /** Specifies class name for label component. */
+ labelClassName: PropTypes.string,
+ /** Specifies description to show under the radio's value. */
+ description: PropTypes.node,
+ /** Specifies whether to display component in invalid state, this affects styling. */
+ isInvalid: PropTypes.bool,
+ /** Specifies whether to display component in valid state, this affects styling. */
+ isValid: PropTypes.bool,
+ /** Specifies whether the `FormRadio` is disabled. */
+ disabled: PropTypes.bool,
+};
+
+FormRadio.defaultProps = {
+ id: undefined,
+ className: undefined,
+ controlClassName: undefined,
+ labelClassName: undefined,
+ description: undefined,
+ isInvalid: false,
+ isValid: false,
+ disabled: false,
+};
+
+export { RadioControl };
+export default FormRadio;
diff --git a/src/editors/sharedComponents/SelectableBox/FormRadioSet.jsx b/src/editors/sharedComponents/SelectableBox/FormRadioSet.jsx
new file mode 100644
index 0000000000..05524ba20d
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormRadioSet.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useFormGroupContext } from './FormGroupContext';
+import { FormRadioSetContextProvider } from './FormRadioSetContext';
+import FormControlSet from './FormControlSet';
+
+const FormRadioSet = ({
+ children,
+ name,
+ value,
+ defaultValue,
+ isInline,
+ onChange,
+ onFocus,
+ onBlur,
+ ...props
+}) => {
+ const { getControlProps, useSetIsControlGroupEffect } = useFormGroupContext();
+ useSetIsControlGroupEffect(true);
+ const controlProps = getControlProps(props);
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+FormRadioSet.propTypes = {
+ /** Specifies contents of the component. */
+ children: PropTypes.node.isRequired,
+ /** A class name to append to the base element. */
+ className: PropTypes.string,
+ /** Specifies name for the component. */
+ name: PropTypes.string.isRequired,
+ /** Specifies values for the FormRadioSet. */
+ value: PropTypes.string,
+ /** Specifies default values. */
+ defaultValue: PropTypes.string,
+ /** Specifies whether the component should be displayed with inline styling. */
+ isInline: PropTypes.bool,
+ /** Specifies onChange event handler. */
+ onChange: PropTypes.func,
+ /** Specifies onFocus event handler. */
+ onFocus: PropTypes.func,
+ /** Specifies onBlur event handler. */
+ onBlur: PropTypes.func,
+};
+
+FormRadioSet.defaultProps = {
+ className: undefined,
+ value: undefined,
+ defaultValue: undefined,
+ isInline: false,
+ onChange: undefined,
+ onFocus: undefined,
+ onBlur: undefined,
+};
+
+export default FormRadioSet;
diff --git a/src/editors/sharedComponents/SelectableBox/FormRadioSetContext.jsx b/src/editors/sharedComponents/SelectableBox/FormRadioSetContext.jsx
new file mode 100644
index 0000000000..41716cadd1
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormRadioSetContext.jsx
@@ -0,0 +1,79 @@
+import React, { useContext } from 'react';
+import PropTypes from 'prop-types';
+import { callAllHandlers } from './fieldUtils';
+
+const identityFn = props => props;
+
+const FormRadioSetContext = React.createContext({
+ getRadioControlProps: identityFn,
+ hasRadioSetProvider: false,
+});
+
+const useRadioSetContext = () => useContext(FormRadioSetContext);
+
+const FormRadioSetContextProvider = ({
+ children,
+ name,
+ onBlur,
+ onFocus,
+ onChange,
+ value,
+ defaultValue,
+}) => {
+ const handleChange = (...args) => {
+ onChange(...args);
+ };
+ const isControlled = !defaultValue && value !== undefined;
+ const getRadioControlProps = (radioProps) => ({
+ ...radioProps,
+ name,
+ /* istanbul ignore next */
+ onBlur: radioProps.onBlur ? callAllHandlers(onBlur, radioProps.onBlur) : onBlur,
+ /* istanbul ignore next */
+ onFocus: radioProps.onFocus ? callAllHandlers(onFocus, radioProps.onFocus) : onFocus,
+ /* istanbul ignore next */
+ onChange: radioProps.onChange ? callAllHandlers(handleChange, radioProps.onChange) : onChange,
+ checked: isControlled ? value === radioProps.value : undefined,
+ defaultChecked: isControlled ? undefined : defaultValue === radioProps.value,
+ });
+ // eslint-disable-next-line react/jsx-no-constructed-context-values
+ const contextValue = {
+ name,
+ value,
+ defaultValue,
+ getRadioControlProps,
+ onBlur,
+ onFocus,
+ onChange,
+ hasRadioSetProvider: true,
+ };
+ return (
+
+ {children}
+
+ );
+};
+
+FormRadioSetContextProvider.propTypes = {
+ children: PropTypes.node.isRequired,
+ name: PropTypes.string.isRequired,
+ onBlur: PropTypes.func,
+ onFocus: PropTypes.func,
+ onChange: PropTypes.func,
+ value: PropTypes.string,
+ defaultValue: PropTypes.string,
+};
+
+FormRadioSetContextProvider.defaultProps = {
+ onBlur: undefined,
+ onFocus: undefined,
+ onChange: undefined,
+ value: undefined,
+ defaultValue: undefined,
+};
+
+export default FormRadioSetContext;
+export {
+ useRadioSetContext,
+ FormRadioSetContextProvider,
+};
diff --git a/src/editors/sharedComponents/SelectableBox/FormText.jsx b/src/editors/sharedComponents/SelectableBox/FormText.jsx
new file mode 100644
index 0000000000..f6469a1512
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormText.jsx
@@ -0,0 +1,115 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+// import Icon from '../Icon';
+// import {
+// Check, Close, Cancel, CheckCircle, RadioButtonUnchecked, WarningFilled,
+// } from '../../icons';
+
+import { FORM_TEXT_TYPES } from './constants';
+
+// const FORM_TEXT_ICONS = {
+// [FORM_TEXT_TYPES.DEFAULT]: null,
+// [FORM_TEXT_TYPES.VALID]: Check,
+// [FORM_TEXT_TYPES.INVALID]: Close,
+// [FORM_TEXT_TYPES.WARNING]: WarningFilled,
+// [FORM_TEXT_TYPES.CRITERIA_EMPTY]: RadioButtonUnchecked,
+// [FORM_TEXT_TYPES.CRITERIA_VALID]: CheckCircle,
+// [FORM_TEXT_TYPES.CRITERIA_INVALID]: Cancel,
+// };
+
+const resolveTextType = ({ isInvalid, isValid }) => {
+ if (isValid) {
+ return FORM_TEXT_TYPES.VALID;
+ }
+ if (isInvalid) {
+ return FORM_TEXT_TYPES.INVALID;
+ }
+ return FORM_TEXT_TYPES.DEFAULT;
+};
+
+// const FormTextIcon = ({ type, customIcon }) => {
+// if (customIcon) {
+// return customIcon;
+// }
+
+// const typeIcon = FORM_TEXT_ICONS[type];
+// if (typeIcon) {
+// return ;
+// }
+
+// return null;
+// };
+
+// FormTextIcon.propTypes = {
+// type: PropTypes.oneOf(Object.values(FORM_TEXT_TYPES)),
+// customIcon: PropTypes.node,
+// };
+
+// FormTextIcon.defaultProps = {
+// type: undefined,
+// customIcon: undefined,
+// };
+
+const FormText = ({
+ children, type, icon, muted, hasIcon, ...props
+}) => {
+ const className = classNames(
+ props.className,
+ 'pgn__form-text',
+ `pgn__form-text-${type}`,
+ {
+ 'text-muted': muted,
+ },
+ );
+
+ return (
+
+ {/* {hasIcon &&
} */}
+
+ {children}
+
+
+ );
+};
+
+const FORM_TEXT_TYPE_CHOICES = [
+ 'default',
+ 'valid',
+ 'invalid',
+ 'warning',
+ 'criteria-empty',
+ 'criteria-valid',
+ 'criteria-invalid',
+];
+
+FormText.propTypes = {
+ /** Specifies contents of the component. */
+ children: PropTypes.node.isRequired,
+ /** Specifies class name to append to the base element. */
+ className: PropTypes.string,
+ /** Specifies whether to show an icon next to the text. */
+ hasIcon: PropTypes.bool,
+ /** Specifies text type, this affects styling. */
+ type: PropTypes.oneOf(FORM_TEXT_TYPE_CHOICES),
+ /** Specifies icon to show, will only be shown if `hasIcon` prop is set to `true`. */
+ icon: PropTypes.node,
+ /** Specifies whether to show text with muted styling. */
+ muted: PropTypes.bool,
+};
+
+FormText.defaultProps = {
+ hasIcon: true,
+ type: 'default',
+ icon: undefined,
+ className: undefined,
+ muted: false,
+};
+
+export default FormText;
+export {
+ FORM_TEXT_TYPES,
+ // FORM_TEXT_ICONS,
+ // FormTextIcon,
+ resolveTextType,
+};
diff --git a/src/editors/sharedComponents/SelectableBox/README.md b/src/editors/sharedComponents/SelectableBox/README.md
new file mode 100644
index 0000000000..4ab1f417da
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/README.md
@@ -0,0 +1,215 @@
+---
+title: 'SelectableBox'
+type: 'component'
+components:
+- SelectableBox
+- SelectableBoxSet
+categories:
+- Forms
+- Content
+status: 'New'
+designStatus: 'Done'
+devStatus: 'In progress'
+notes: |
+---
+
+A box that has selection states. It can be used as an alternative to a radio button or checkbox set.
+
+The ``SelectableBox`` can contain any kind of content as long as it is not clickable. In other words, there should be no clickable targets distinct from selection.
+
+## Basic Usage
+
+As ``Checkbox``
+
+```jsx live
+() => {
+ const type = 'checkbox';
+ const allCheeseOptions = ['swiss', 'cheddar', 'pepperjack'];
+ const [checkedCheeses, { add, remove, set, clear }] = useCheckboxSetValues(['swiss']);
+
+ const handleChange = e => {
+ e.target.checked ? add(e.target.value) : remove(e.target.value);
+ };
+
+ const isInvalid = () => checkedCheeses.includes('swiss');
+ const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth });
+
+ return (
+
+
+
+
+
It is my first SelectableBox
+
Swiss
+
+
+
+ Cheddar
+
+
+ Pepperjack
+
+
+
+ );
+}
+```
+
+## As Radio
+
+```jsx live
+() => {
+ const type = 'radio';
+ const [value, setValue] = useState('green');
+ const handleChange = e => setValue(e.target.value);
+ const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth });
+
+ return (
+
+
+
+
It is Red color
+
Select me
+
+
+
+ Green
+ Leaves and grass
+
+
+ Blue
+ The sky
+
+
+ );
+}
+```
+## As Checkbox
+As ``Checkbox`` with ``isIndeterminate``
+
+```jsx live
+() => {
+ const type = 'checkbox';
+ const allCheeseOptions = ['swiss', 'cheddar', 'pepperjack'];
+ const [checkedCheeses, { add, remove, set, clear }] = useCheckboxSetValues(['swiss']);
+
+ const allChecked = allCheeseOptions.every(value => checkedCheeses.includes(value));
+ const someChecked = allCheeseOptions.some(value => checkedCheeses.includes(value));
+ const isIndeterminate = someChecked && !allChecked;
+ const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.small.maxWidth });
+
+ const handleChange = e => {
+ e.target.checked ? add(e.target.value) : remove(e.target.value);
+ };
+
+ const handleCheckAllChange = ({ checked }) => {
+ checked ? set(allCheeseOptions) : clear();
+ };
+
+ return (
+ <>
+
+
+ All the cheese
+
+
+
+
+
+
It is my first SelectableBox
+
Swiss
+
+
+
+ Cheddar
+
+
+ Pepperjack
+
+
+ >
+ );
+}
+```
+
+As ``Checkbox`` with ``ariaLabelledby``
+
+```jsx live
+() => {
+ const type = 'checkbox';
+ const allCheeseOptions = ['swiss', 'cheddar', 'pepperjack'];
+ const [checkedCheeses, { add, remove, set, clear }] = useCheckboxSetValues(['swiss']);
+
+ const handleChange = e => {
+ e.target.checked ? add(e.target.value) : remove(e.target.value);
+ };
+
+ const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth });
+
+ return (
+
+
+ Select your favorite cheese
+
+
+
+
+ Swiss
+
+
+
+
+ Cheddar
+
+
+
+
+ Pepperjack
+
+
+
+
+ );
+}
+```
diff --git a/src/editors/sharedComponents/SelectableBox/SelectableBoxSet.jsx b/src/editors/sharedComponents/SelectableBox/SelectableBoxSet.jsx
new file mode 100644
index 0000000000..a96cccab53
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/SelectableBoxSet.jsx
@@ -0,0 +1,98 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { getInputType } from './utils';
+// import { requiredWhenNot } from '../utils/propTypes';
+
+const INPUT_TYPES = [
+ 'radio',
+ 'checkbox',
+];
+
+const DEFAULT_COLUMNS_NUMBER = 2;
+
+const SelectableBoxSet = React.forwardRef(({
+ children,
+ name,
+ value,
+ defaultValue,
+ onChange,
+ type,
+ columns,
+ className,
+ ariaLabel,
+ ariaLabelledby,
+ ...props
+}, ref) => {
+ const inputType = getInputType('SelectableBoxSet', type);
+
+ return React.createElement(
+ inputType,
+ {
+ name,
+ value,
+ defaultValue,
+ onChange,
+ ref,
+ className: classNames(
+ 'pgn__selectable_box-set',
+ `pgn__selectable_box-set--${columns || DEFAULT_COLUMNS_NUMBER}`,
+ className,
+ ),
+ 'aria-label': ariaLabel,
+ 'aria-labelledby': ariaLabelledby,
+ ...props,
+ },
+ children,
+ );
+});
+
+SelectableBoxSet.propTypes = {
+ /** Specifies a name for the group of `SelectableBox`'es. */
+ name: PropTypes.string.isRequired,
+ /** Content of the `SelectableBoxSet`. */
+ children: PropTypes.node,
+ /** A function that receives event of the clicked `SelectableBox` and can be used to handle the value change. */
+ onChange: PropTypes.func,
+ /** Indicates selected `SelectableBox`'es. */
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]),
+ /** Specifies default values for the `SelectableBox`'es. */
+ defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ /** Indicates the input type: checkbox or radio. */
+ type: PropTypes.oneOf(INPUT_TYPES),
+ /**
+ * Specifies number of `SelectableBox`'es in a row.
+ *
+ * Class that is responsible for the columns number: `pgn__selectable_box-set--{columns}`.
+ * Max number of columns: `12`.
+ */
+ columns: PropTypes.number,
+ /** A class that is be appended to the base element. */
+ className: PropTypes.string,
+ /**
+ * The ID of the label for the `SelectableBoxSet`.
+ *
+ * An accessible label must be provided to the `SelectableBoxSet`.
+ */
+ ariaLabelledby: PropTypes.string,
+ /**
+ * A label for the `SelectableBoxSet`.
+ *
+ * If not using `ariaLabelledby`, then `ariaLabel` must be provided */
+ // eslint-disable-next-line react/forbid-prop-types
+ ariaLabel: PropTypes.any, // requiredWhenNot(PropTypes.string, 'ariaLabelledby'),
+};
+
+SelectableBoxSet.defaultProps = {
+ children: undefined,
+ onChange: () => {},
+ value: undefined,
+ defaultValue: undefined,
+ type: 'radio',
+ columns: DEFAULT_COLUMNS_NUMBER,
+ className: undefined,
+ ariaLabelledby: undefined,
+ ariaLabel: undefined,
+};
+
+export default SelectableBoxSet;
diff --git a/src/editors/sharedComponents/SelectableBox/_variables.scss b/src/editors/sharedComponents/SelectableBox/_variables.scss
new file mode 100644
index 0000000000..b7d4e114db
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/_variables.scss
@@ -0,0 +1,5 @@
+$selectable-box-padding: 1rem !default;
+$selectable-box-border-radius: .25rem !default;
+$selectable-box-space: .75rem !default;
+$min-cols-number: 1 !default;
+$max-cols-number: 12 !default;
diff --git a/src/editors/sharedComponents/SelectableBox/constants.js b/src/editors/sharedComponents/SelectableBox/constants.js
new file mode 100644
index 0000000000..68abdda931
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/constants.js
@@ -0,0 +1,17 @@
+/* eslint-disable import/prefer-default-export */
+const FORM_CONTROL_SIZES = {
+ SMALL: 'sm',
+ LARGE: 'lg',
+};
+
+const FORM_TEXT_TYPES = {
+ DEFAULT: 'default',
+ VALID: 'valid',
+ INVALID: 'invalid',
+ WARNING: 'warning',
+ CRITERIA_EMPTY: 'criteria-empty',
+ CRITERIA_VALID: 'criteria-valid',
+ CRITERIA_INVALID: 'criteria-invalid',
+};
+
+export { FORM_CONTROL_SIZES, FORM_TEXT_TYPES };
diff --git a/src/editors/sharedComponents/SelectableBox/fieldUtils.js b/src/editors/sharedComponents/SelectableBox/fieldUtils.js
new file mode 100644
index 0000000000..5c0d980a17
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/fieldUtils.js
@@ -0,0 +1,70 @@
+import classNames from 'classnames';
+import { useState, useEffect } from 'react';
+import newId from './newId';
+
+const omitUndefinedProperties = (obj = {}) => Object.entries(obj)
+ .reduce((acc, [key, value]) => {
+ if (value !== undefined) {
+ acc[key] = value;
+ }
+ return acc;
+ }, {});
+
+const callAllHandlers = (...handlers) => {
+ const unifiedEventHandler = (event) => {
+ handlers
+ .filter(handler => typeof handler === 'function')
+ .forEach(handler => handler(event));
+ };
+ return unifiedEventHandler;
+};
+
+const useHasValue = ({ defaultValue, value }) => {
+ const [hasUncontrolledValue, setHasUncontrolledValue] = useState(!!defaultValue || defaultValue === 0);
+ const hasValue = !!value || value === 0 || hasUncontrolledValue;
+ const handleInputEvent = (e) => setHasUncontrolledValue(e.target.value);
+ return [hasValue, handleInputEvent];
+};
+
+const useIdList = (uniqueIdPrefix, initialList) => {
+ const [idList, setIdList] = useState(initialList || []);
+ const addId = (idToAdd) => {
+ setIdList(oldIdList => [...oldIdList, idToAdd]);
+ return idToAdd;
+ };
+ const getNewId = () => {
+ const idToAdd = newId(`${uniqueIdPrefix}-`);
+ return addId(idToAdd);
+ };
+ const removeId = (idToRemove) => {
+ setIdList(oldIdList => oldIdList.filter(id => id !== idToRemove));
+ };
+
+ const useRegisteredId = (explicitlyRegisteredId) => {
+ const [registeredId, setRegisteredId] = useState(explicitlyRegisteredId);
+ useEffect(() => {
+ if (explicitlyRegisteredId) {
+ addId(explicitlyRegisteredId);
+ } else if (!registeredId) {
+ setRegisteredId(getNewId(uniqueIdPrefix));
+ }
+ return () => removeId(registeredId);
+ }, [registeredId, explicitlyRegisteredId]);
+ return registeredId;
+ };
+
+ return [idList, useRegisteredId];
+};
+
+const mergeAttributeValues = (...values) => {
+ const mergedValues = classNames(values);
+ return mergedValues || undefined;
+};
+
+export {
+ callAllHandlers,
+ useHasValue,
+ mergeAttributeValues,
+ useIdList,
+ omitUndefinedProperties,
+};
diff --git a/src/editors/sharedComponents/SelectableBox/index.jsx b/src/editors/sharedComponents/SelectableBox/index.jsx
new file mode 100644
index 0000000000..372bb27c55
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/index.jsx
@@ -0,0 +1,118 @@
+import React, { useRef, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import SelectableBoxSet from './SelectableBoxSet';
+import { useCheckboxSetContext } from './FormCheckboxSetContext';
+import { useRadioSetContext } from './FormRadioSetContext';
+import { getInputType } from './utils';
+
+const INPUT_TYPES = [
+ 'radio',
+ 'checkbox',
+];
+
+// The 'type: any' below is to avoid some errors while this file lacks proper
+// types. But we can probably soon just delete this file and use the upstream
+// Paragon.
+const SelectableBox = /** @type {any} */ (React.forwardRef(({
+ type,
+ value,
+ checked,
+ children,
+ isIndeterminate,
+ isInvalid,
+ onClick,
+ onFocus,
+ inputHidden,
+ className,
+ ...props
+}, ref) => {
+ const inputType = getInputType('SelectableBox', type);
+ const { value: radioValue } = useRadioSetContext();
+ const { value: checkboxValues = [] } = useCheckboxSetContext();
+
+ const isChecked = () => {
+ switch (type) {
+ case 'radio':
+ return radioValue === value;
+ case 'checkbox':
+ return checkboxValues.includes(value);
+ default:
+ return radioValue === value;
+ }
+ };
+
+ const inputRef = useRef(null);
+ const input = React.createElement(inputType, {
+ value,
+ checked,
+ hidden: inputHidden,
+ ref: inputRef,
+ tabIndex: -1,
+ onChange: () => {},
+ ...(type === 'checkbox' ? { ...props, isIndeterminate } : { ...props }),
+ }, null);
+
+ useEffect(() => {
+ if (onClick && inputRef.current) {
+ inputRef.current.onclick = () => onClick(inputRef.current);
+ }
+ }, [onClick]);
+
+ return (
+ inputRef.current.click()}
+ onClick={() => inputRef.current.click()}
+ onFocus={onFocus}
+ className={classNames('pgn__selectable_box', className, {
+ 'pgn__selectable_box-active': isChecked() || checked,
+ 'pgn__selectable_box-invalid': isInvalid,
+ })}
+ tabIndex={0}
+ ref={ref}
+ {...props}
+ >
+ {input}
+ {children}
+
+ );
+}));
+
+SelectableBox.propTypes = {
+ /** Content of the `SelectableBox`. */
+ children: PropTypes.node.isRequired,
+ /** A value that is passed to the input tag. */
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ /** Controls whether `SelectableBox` is checked. */
+ checked: PropTypes.bool,
+ /** Indicates the input type: checkbox or radio. */
+ type: PropTypes.oneOf(INPUT_TYPES),
+ /** Function that is called when the `SelectableBox` is clicked. */
+ onClick: PropTypes.func,
+ /** Function that is called when the `SelectableBox` is focused. */
+ onFocus: PropTypes.func,
+ /** Controls display of the input (checkbox or radio button) on the `SelectableBox`. */
+ inputHidden: PropTypes.bool,
+ /** Indicates a state for the 'checkbox' `type` when `SelectableBox` is neither checked nor unchecked. */
+ isIndeterminate: PropTypes.bool,
+ /** Adds errors styles to the `SelectableBox`. */
+ isInvalid: PropTypes.bool,
+ /** A class that is appended to the base element. */
+ className: PropTypes.string,
+};
+
+SelectableBox.defaultProps = {
+ value: undefined,
+ checked: false,
+ type: 'radio',
+ onClick: () => {},
+ onFocus: () => {},
+ inputHidden: true,
+ isIndeterminate: false,
+ isInvalid: false,
+ className: undefined,
+};
+
+SelectableBox.Set = SelectableBoxSet;
+export default SelectableBox;
diff --git a/src/editors/sharedComponents/SelectableBox/index.scss b/src/editors/sharedComponents/SelectableBox/index.scss
new file mode 100644
index 0000000000..1038286af2
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/index.scss
@@ -0,0 +1,54 @@
+@import "variables";
+
+.pgn__selectable_box-set {
+ display: grid;
+ grid-auto-rows: 1fr;
+ grid-gap: $selectable-box-space;
+
+ @for $i from $min-cols-number through $max-cols-number {
+ &.pgn__selectable_box-set--#{$i} {
+ grid-template-columns: repeat(#{$i}, 1fr);
+ }
+ }
+
+ & > * + * {
+ margin: 0;
+ }
+}
+
+.pgn__selectable_box {
+ position: relative;
+ height: 100%;
+ padding: $selectable-box-padding;
+ box-shadow: $level-1-box-shadow;
+ border-radius: $selectable-box-border-radius;
+ text-align: start;
+ background: $white;
+
+ &:focus-visible {
+ outline: 1px solid $primary-700;
+ }
+
+ .pgn__form-radio,
+ .pgn__form-checkbox {
+ position: absolute;
+ top: $selectable-box-padding;
+ inset-inline-end: $selectable-box-padding;
+
+ input {
+ margin-inline-end: 0;
+ }
+ }
+
+ * {
+ pointer-events: none;
+ }
+}
+
+.pgn__selectable_box-active {
+ outline: 2px solid $primary-500;
+}
+
+.pgn__selectable_box-invalid {
+ outline: 2px solid $danger-300;
+}
diff --git a/src/editors/sharedComponents/SelectableBox/newId.js b/src/editors/sharedComponents/SelectableBox/newId.js
new file mode 100644
index 0000000000..9055c9e31a
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/newId.js
@@ -0,0 +1,8 @@
+let lastId = 0;
+
+const newId = (prefix = 'id') => {
+ lastId += 1;
+ return `${prefix}${lastId}`;
+};
+
+export default newId;
diff --git a/src/editors/sharedComponents/SelectableBox/tests/SelectableBox.test.jsx b/src/editors/sharedComponents/SelectableBox/tests/SelectableBox.test.jsx
new file mode 100644
index 0000000000..226c5a9f8b
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/tests/SelectableBox.test.jsx
@@ -0,0 +1,124 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import renderer from 'react-test-renderer';
+
+import SelectableBox from '..';
+
+const checkboxType = 'checkbox';
+const checkboxText = 'SelectableCheckbox';
+
+const radioType = 'radio';
+const radioText = 'SelectableRadio';
+
+const SelectableCheckbox = (props) => {checkboxText} ;
+
+const SelectableRadio = (props) => {radioText} ;
+
+describe(' ', () => {
+ describe('correct rendering', () => {
+ it('renders without props', () => {
+ const tree = renderer.create((
+ SelectableBox
+ )).toJSON();
+ expect(tree).toMatchSnapshot();
+ });
+ it('correct render when type prop is changed', () => {
+ const { rerender } = render( );
+ const checkboxControl = screen.getByText(radioText);
+ expect(checkboxControl).toBeTruthy();
+ rerender( );
+ const radioControl = screen.getByText(radioText);
+ expect(radioControl).toBeTruthy();
+ });
+ it('renders with radio input type if neither checkbox nor radio is passed', () => {
+ // Mock the `console.error` is intentional because an invalid `type` prop
+ // with `wrongType` specified for `ForwardRef` expects one of the ['radio','flag'] parameters.
+ // eslint-disable-next-line no-console
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+ render( );
+ const selectableBox = screen.getByRole('button');
+ expect(selectableBox).toBeTruthy();
+ consoleErrorSpy.mockRestore();
+ });
+ it('renders with checkbox input type', () => {
+ render( );
+ const selectableBox = screen.getByRole('button');
+ expect(selectableBox).toBeTruthy();
+ });
+ it('renders with radio input type', () => {
+ render( );
+ const selectableBox = screen.getByRole('button');
+ expect(selectableBox).toBeTruthy();
+ });
+ it('renders with correct children', () => {
+ render( );
+ const selectableBox = screen.getByText(radioText);
+ expect(selectableBox).toBeTruthy();
+ });
+ it('renders with correct class', () => {
+ const className = 'myClass';
+ render( );
+ const selectableBox = screen.getByRole('button');
+ expect(selectableBox.classList.contains(className)).toEqual(true);
+ });
+ it('renders as active when checked is passed', () => {
+ render( );
+ const selectableBox = screen.getByRole('button');
+ const inputElement = screen.getByRole('radio', { hidden: true });
+ expect(selectableBox.classList.contains('pgn__selectable_box-active')).toEqual(true);
+ expect(inputElement.checked).toEqual(true);
+ });
+ it('renders as invalid when isInvalid is passed', () => {
+ render( );
+ const selectableBox = screen.getByRole('button');
+ expect(selectableBox.classList.contains('pgn__selectable_box-invalid')).toEqual(true);
+ });
+ it('renders with on click event when onClick is passed', async () => {
+ const onClickSpy = jest.fn();
+ render( );
+ const selectableBox = screen.getByRole('button');
+ await userEvent.click(selectableBox);
+ expect(onClickSpy).toHaveBeenCalledTimes(1);
+ });
+ it('renders with on key press event when onClick is passed', async () => {
+ const onClickSpy = jest.fn();
+ render( );
+ const selectableBox = screen.getByRole('button');
+ selectableBox.focus();
+ await userEvent.keyboard('{enter}');
+ expect(onClickSpy).toHaveBeenCalledTimes(1);
+ });
+ it('renders with hidden input when inputHidden is passed', () => {
+ const { rerender } = render( );
+ const inputElement = screen.getByRole('checkbox', { hidden: true });
+ expect(inputElement.getAttribute('hidden')).toEqual('');
+ rerender( );
+ expect(inputElement.getAttribute('hidden')).toBeNull();
+ });
+ });
+ describe('correct interactions', () => {
+ it('correct checkbox state change when checked is changed', () => {
+ const { rerender } = render( );
+ const checkbox = screen.getByRole('button');
+ expect(checkbox.className).not.toContain('pgn__selectable_box-active');
+ rerender( );
+ expect(checkbox.className).toContain('pgn__selectable_box-active');
+ });
+ it('correct radio state change when checked is changed', () => {
+ const { rerender } = render( );
+ const radio = screen.getByRole('button');
+ expect(radio.className).toContain('pgn__selectable_box-active');
+ rerender( );
+ expect(radio.className).toContain('pgn__selectable_box-active');
+ });
+ it('ref is passed to onClick function', () => {
+ let inputRef;
+ const onClick = (ref) => { inputRef = ref; };
+ render( );
+ const radio = screen.getByRole('button');
+ userEvent.click(radio);
+ expect(inputRef).not.toBeFalsy();
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/SelectableBox/tests/SelectableBoxSet.test.jsx b/src/editors/sharedComponents/SelectableBox/tests/SelectableBoxSet.test.jsx
new file mode 100644
index 0000000000..cf3db6f3c9
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/tests/SelectableBoxSet.test.jsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom/extend-expect';
+import userEvent from '@testing-library/user-event';
+import SelectableBox from '..';
+
+const boxText = (text) => `SelectableBox${text}`;
+
+const checkboxType = 'checkbox';
+const checkboxText = (text) => `SelectableCheckbox${text}`;
+
+const radioType = 'radio';
+const radioText = (text) => `SelectableRadio${text}`;
+
+const ariaLabel = 'test-default-label';
+
+const SelectableBoxSet = (props) => (
+
+ {boxText(1)}
+ {boxText(2)}
+ {boxText(3)}
+
+);
+
+const SelectableCheckboxSet = (props) => (
+
+ {checkboxText(1)}
+ {checkboxText(2)}
+ {checkboxText(3)}
+
+);
+
+const SelectableRadioSet = (props) => (
+
+ {radioText(1)}
+ {radioText(2)}
+ {radioText(3)}
+
+);
+
+describe(' ', () => {
+ describe('correct rendering', () => {
+ it('renders without props', () => {
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+ it('forwards props', () => {
+ render(( ));
+ expect(screen.getByTestId('test-radio-set-name')).toBeInTheDocument();
+ });
+ it('correct render when type prop is changed', () => {
+ const { rerender } = render( );
+ expect(screen.getByTestId('radio-set')).toBeInTheDocument();
+ rerender( );
+ expect(screen.getByTestId('radio-set')).toBeInTheDocument();
+ rerender( );
+ expect(screen.getByTestId('checkbox-set')).toBeInTheDocument();
+ });
+ it('renders with children', () => {
+ render(
+ {checkboxText(1)} ,
+ );
+ expect(screen.getByText(checkboxText(1))).toBeInTheDocument();
+ });
+ it('renders with on change event', async () => {
+ const onChangeSpy = jest.fn();
+ render( );
+ const checkbox = screen.getByRole('button', { name: checkboxText(1) });
+ await userEvent.click(checkbox);
+ expect(onChangeSpy).toHaveBeenCalledTimes(1);
+ });
+ it('renders with checkbox type', () => {
+ render( );
+ expect(screen.getByTestId('checkbox-set')).toBeInTheDocument();
+ });
+ it('renders with radio type if neither checkbox nor radio is passed', () => {
+ render( );
+ expect(screen.getByTestId('radio-set')).toBeInTheDocument();
+ });
+ it('renders with radio type', () => {
+ render( );
+ expect(screen.getByTestId('radio-set')).toBeInTheDocument();
+ });
+ it('renders with correct number of columns', () => {
+ const columns = 10;
+ render( );
+ const selectableBoxSet = screen.getByTestId('selectable-box-set');
+ expect(selectableBoxSet).toHaveClass(`pgn__selectable_box-set--${columns}`);
+ });
+ it('renders with an aria-label attribute', () => {
+ render(( ));
+ expect(screen.getByLabelText('test-radio-set-label')).toBeInTheDocument();
+ });
+ it('renders with an aria-labelledby attribute', () => {
+ render((
+ <>
+ Radio Set Label text
+
+ >
+ ));
+ expect(screen.getByLabelText('Radio Set Label text')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBox.test.jsx.snap b/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBox.test.jsx.snap
new file mode 100644
index 0000000000..96676cabf5
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBox.test.jsx.snap
@@ -0,0 +1,22 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` correct rendering renders without props 1`] = `
+
+
+ SelectableBox
+
+`;
diff --git a/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBoxSet.test.jsx.snap b/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBoxSet.test.jsx.snap
new file mode 100644
index 0000000000..4c9a0ab8f4
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBoxSet.test.jsx.snap
@@ -0,0 +1,57 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` correct rendering renders without props 1`] = `
+
+`;
diff --git a/src/editors/sharedComponents/SelectableBox/utils.js b/src/editors/sharedComponents/SelectableBox/utils.js
new file mode 100644
index 0000000000..87efbab0c9
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/utils.js
@@ -0,0 +1,27 @@
+import { CheckboxControl } from './FormCheckbox';
+import { RadioControl } from './FormRadio';
+import FormRadioSet from './FormRadioSet';
+import FormCheckboxSet from './FormCheckboxSet';
+
+// eslint-disable-next-line import/prefer-default-export,consistent-return
+export const getInputType = (component, type) => {
+ if (component === 'SelectableBox') {
+ switch (type) {
+ case 'radio':
+ return RadioControl;
+ case 'checkbox':
+ return CheckboxControl;
+ default:
+ return RadioControl;
+ }
+ } else if (component === 'SelectableBoxSet') {
+ switch (type) {
+ case 'radio':
+ return FormRadioSet;
+ case 'checkbox':
+ return FormCheckboxSet;
+ default:
+ return FormRadioSet;
+ }
+ }
+};
diff --git a/src/editors/sharedComponents/SelectionModal/Gallery.jsx b/src/editors/sharedComponents/SelectionModal/Gallery.jsx
new file mode 100644
index 0000000000..1e01a109fb
--- /dev/null
+++ b/src/editors/sharedComponents/SelectionModal/Gallery.jsx
@@ -0,0 +1,123 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Spinner } from '@openedx/paragon';
+import {
+ FormattedMessage,
+ useIntl,
+} from '@edx/frontend-platform/i18n';
+
+// SelectableBox in paragon has a bug where you can't change selection. So we override it
+import SelectableBox from '../SelectableBox';
+import messages from './messages';
+import GalleryCard from './GalleryCard';
+import GalleryLoadMoreButton from './GalleryLoadMoreButton';
+
+const Gallery = ({
+ galleryIsEmpty,
+ searchIsEmpty,
+ displayList,
+ highlighted,
+ onHighlightChange,
+ emptyGalleryLabel,
+ showIdsOnCards,
+ height,
+ isLoaded,
+ thumbnailFallback,
+ allowLazyLoad,
+ fetchNextPage,
+ assetCount,
+}) => {
+ const intl = useIntl();
+
+ if (!isLoaded && !allowLazyLoad) {
+ return (
+
+
+
+ );
+ }
+ if (galleryIsEmpty) {
+ return (
+
+
+
+ );
+ }
+ if (searchIsEmpty) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+ {displayList.map(asset => (
+
+ )) }
+
+ {allowLazyLoad && (
+
+ )}
+
+ );
+};
+
+Gallery.defaultProps = {
+ highlighted: '',
+ showIdsOnCards: false,
+ height: '375px',
+ show: true,
+ thumbnailFallback: undefined,
+ allowLazyLoad: false,
+ fetchNextPage: null,
+ assetCount: 0,
+};
+Gallery.propTypes = {
+ show: PropTypes.bool,
+ isLoaded: PropTypes.bool.isRequired,
+ galleryIsEmpty: PropTypes.bool.isRequired,
+ searchIsEmpty: PropTypes.bool.isRequired,
+ displayList: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
+ highlighted: PropTypes.string,
+ onHighlightChange: PropTypes.func.isRequired,
+ emptyGalleryLabel: PropTypes.shape({}).isRequired,
+ showIdsOnCards: PropTypes.bool,
+ height: PropTypes.string,
+ thumbnailFallback: PropTypes.element,
+ allowLazyLoad: PropTypes.bool,
+ fetchNextPage: PropTypes.func,
+ assetCount: PropTypes.number,
+};
+
+export default Gallery;
diff --git a/src/editors/sharedComponents/SelectionModal/Gallery.test.jsx b/src/editors/sharedComponents/SelectionModal/Gallery.test.jsx
new file mode 100644
index 0000000000..e4ba05be25
--- /dev/null
+++ b/src/editors/sharedComponents/SelectionModal/Gallery.test.jsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import Gallery from './Gallery';
+
+jest.mock('../../data/redux', () => ({
+ selectors: {
+ requests: {
+ isFinished: (state, { requestKey }) => ({ isFinished: { state, requestKey } }),
+ },
+ },
+}));
+
+jest.mock('./GalleryCard', () => 'GalleryCard');
+
+describe('TextEditor Image Gallery component', () => {
+ describe('component', () => {
+ const props = {
+ galleryIsEmpty: false,
+ emptyGalleryLabel: {
+ id: 'emptyGalleryMsg',
+ defaultMessage: 'Empty Gallery',
+ },
+ searchIsEmpty: false,
+ displayList: [{ id: 1 }, { id: 2 }, { id: 3 }],
+ highlighted: 'props.highlighted',
+ onHighlightChange: jest.fn().mockName('props.onHighlightChange'),
+ isLoaded: true,
+ fetchNextPage: null,
+ assetCount: 0,
+ allowLazyLoad: false,
+ };
+ const shallowWithIntl = (component) => shallow({component} );
+ test('snapshot: not loaded, show spinner', () => {
+ expect(shallowWithIntl( ).snapshot).toMatchSnapshot();
+ });
+ test('snapshot: loaded but no images, show empty gallery', () => {
+ expect(shallowWithIntl( ).snapshot).toMatchSnapshot();
+ });
+ test('snapshot: loaded but search returns no images, show 0 search result gallery', () => {
+ expect(shallowWithIntl( ).snapshot).toMatchSnapshot();
+ });
+ test('snapshot: loaded, show gallery', () => {
+ expect(shallowWithIntl( ).snapshot).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/SelectionModal/GalleryCard.jsx b/src/editors/sharedComponents/SelectionModal/GalleryCard.jsx
new file mode 100644
index 0000000000..e1864b3991
--- /dev/null
+++ b/src/editors/sharedComponents/SelectionModal/GalleryCard.jsx
@@ -0,0 +1,116 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import {
+ Badge,
+ Image,
+ Truncate,
+} from '@openedx/paragon';
+import { FormattedMessage, FormattedDate, FormattedTime } from '@edx/frontend-platform/i18n';
+
+// SelectableBox in paragon has a bug where you can't change selection. So we override it
+import SelectableBox from '../SelectableBox';
+import messages from './messages';
+import { formatDuration } from '../../utils';
+import LanguageNamesWidget from '../../containers/VideoEditor/components/VideoSettingsModal/components/VideoPreviewWidget/LanguageNamesWidget';
+
+const GalleryCard = ({
+ asset,
+ thumbnailFallback,
+}) => {
+ const [thumbnailError, setThumbnailError] = React.useState(false);
+ return (
+
+
+
+ {(thumbnailError && thumbnailFallback) ? (
+
+ { thumbnailFallback }
+
+ ) : (
+
setThumbnailError(true))}
+ />
+ )}
+ { asset.statusMessage && asset.statusBadgeVariant && (
+
+
+
+ )}
+ { asset.duration >= 0 && (
+
+ {formatDuration(asset.duration)}
+
+ )}
+
+
+
+ {asset.displayName}
+
+ { asset.transcripts && (
+
+
+
+ )}
+
+ ,
+ time: ,
+ }}
+ />
+
+
+
+
+ );
+};
+
+GalleryCard.defaultProps = {
+ thumbnailFallback: undefined,
+};
+GalleryCard.propTypes = {
+ asset: PropTypes.shape({
+ contentType: PropTypes.string,
+ displayName: PropTypes.string,
+ externalUrl: PropTypes.string,
+ id: PropTypes.string,
+ dateAdded: PropTypes.oneOfType([PropTypes.number, PropTypes.instanceOf(Date)]),
+ locked: PropTypes.bool,
+ portableUrl: PropTypes.string,
+ thumbnail: PropTypes.string,
+ url: PropTypes.string,
+ duration: PropTypes.number,
+ status: PropTypes.string,
+ statusMessage: PropTypes.objectOf(PropTypes.string),
+ statusBadgeVariant: PropTypes.string,
+ transcripts: PropTypes.arrayOf(PropTypes.string),
+ }).isRequired,
+ thumbnailFallback: PropTypes.element,
+};
+
+export default GalleryCard;
diff --git a/src/editors/sharedComponents/SelectionModal/GalleryCard.test.jsx b/src/editors/sharedComponents/SelectionModal/GalleryCard.test.jsx
new file mode 100644
index 0000000000..6cd930edf3
--- /dev/null
+++ b/src/editors/sharedComponents/SelectionModal/GalleryCard.test.jsx
@@ -0,0 +1,46 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+
+import { Image } from '@openedx/paragon';
+import GalleryCard from './GalleryCard';
+
+describe('GalleryCard component', () => {
+ const asset = {
+ externalUrl: 'props.img.externalUrl',
+ displayName: 'props.img.displayName',
+ dateAdded: 12345,
+ };
+ const thumbnailFallback = (Image failed to load );
+ let el;
+ beforeEach(() => {
+ el = shallow( );
+ });
+ test(`snapshot: dateAdded=${asset.dateAdded}`, () => {
+ expect(el.snapshot).toMatchSnapshot();
+ });
+ it('loads Image with src from image external url', () => {
+ expect(el.instance.findByType(Image)[0].props.src).toEqual(asset.externalUrl);
+ });
+ it('snapshot with thumbnail fallback and load error', () => {
+ el = shallow( );
+ el.instance.findByType(Image)[0].props.onError();
+ expect(el.snapshot).toMatchSnapshot();
+ });
+ it('snapshot with thumbnail fallback and no error', () => {
+ el = shallow( );
+ expect(el.snapshot).toMatchSnapshot();
+ });
+ it('snapshot with status badge', () => {
+ el = shallow( );
+ expect(el.snapshot).toMatchSnapshot();
+ });
+ it('snapshot with duration badge', () => {
+ el = shallow( );
+ expect(el.snapshot).toMatchSnapshot();
+ });
+ it('snapshot with duration transcripts', () => {
+ el = shallow( );
+ expect(el.snapshot).toMatchSnapshot();
+ });
+});
diff --git a/src/editors/sharedComponents/SelectionModal/GalleryLoadMoreButton.jsx b/src/editors/sharedComponents/SelectionModal/GalleryLoadMoreButton.jsx
new file mode 100644
index 0000000000..bdd53b119a
--- /dev/null
+++ b/src/editors/sharedComponents/SelectionModal/GalleryLoadMoreButton.jsx
@@ -0,0 +1,54 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { Icon, StatefulButton } from '@openedx/paragon';
+import { ExpandMore, SpinnerSimple } from '@openedx/paragon/icons';
+
+const GalleryLoadMoreButton = ({
+ assetCount,
+ displayListLength,
+ fetchNextPage,
+ isLoaded,
+}) => {
+ const [currentPage, setCurrentPage] = useState(1);
+
+ const handlePageChange = () => {
+ fetchNextPage({ pageNumber: currentPage });
+ setCurrentPage(currentPage + 1);
+ };
+ const buttonState = isLoaded ? 'default' : 'pending';
+ const buttonProps = {
+ labels: {
+ default: 'Load more',
+ pending: 'Loading',
+ },
+ icons: {
+ default: ,
+ pending: ,
+ },
+ disabledStates: ['pending'],
+ variant: 'primary',
+ };
+
+ return (
+
+ {displayListLength !== assetCount && (
+
+ )}
+
+ );
+};
+
+GalleryLoadMoreButton.propTypes = {
+ assetCount: PropTypes.number.isRequired,
+ displayListLength: PropTypes.number.isRequired,
+ fetchNextPage: PropTypes.func.isRequired,
+ currentPage: PropTypes.number.isRequired,
+ setCurrentPage: PropTypes.func.isRequired,
+ isLoaded: PropTypes.bool.isRequired,
+};
+
+export default GalleryLoadMoreButton;
diff --git a/src/editors/sharedComponents/SelectionModal/SearchSort.jsx b/src/editors/sharedComponents/SelectionModal/SearchSort.jsx
new file mode 100644
index 0000000000..a2470edc57
--- /dev/null
+++ b/src/editors/sharedComponents/SelectionModal/SearchSort.jsx
@@ -0,0 +1,140 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import {
+ ActionRow, Dropdown, Form, Icon, IconButton, SelectMenu, MenuItem,
+} from '@openedx/paragon';
+import { Check, Close, Search } from '@openedx/paragon/icons';
+import {
+ FormattedMessage,
+ useIntl,
+} from '@edx/frontend-platform/i18n';
+
+import messages from './messages';
+import './index.scss';
+import { sortKeys, sortMessages } from '../../containers/VideoGallery/utils';
+
+const SearchSort = ({
+ searchString,
+ onSearchChange,
+ clearSearchString,
+ sortBy,
+ onSortClick,
+ filterBy,
+ onFilterClick,
+ filterKeys,
+ filterMessages,
+ showSwitch,
+ switchMessage,
+ onSwitchClick,
+}) => {
+ const intl = useIntl();
+
+ return (
+
+
+
+ )
+ :
+ }
+ value={searchString}
+ />
+
+
+ { !showSwitch && }
+
+ {Object.keys(sortKeys).map(key => (
+
+
+
+
+
+
+
+ ))}
+
+
+ { onFilterClick && (
+
+
+
+
+
+ {Object.keys(filterKeys).map(key => (
+
+
+
+ ))}
+
+
+ )}
+
+ { showSwitch && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+ );
+};
+
+SearchSort.defaultProps = {
+ filterBy: '',
+ onFilterClick: null,
+ filterKeys: null,
+ filterMessages: null,
+ showSwitch: false,
+ onSwitchClick: null,
+};
+
+SearchSort.propTypes = {
+ searchString: PropTypes.string.isRequired,
+ onSearchChange: PropTypes.func.isRequired,
+ clearSearchString: PropTypes.func.isRequired,
+ sortBy: PropTypes.string.isRequired,
+ onSortClick: PropTypes.func.isRequired,
+ filterBy: PropTypes.string,
+ onFilterClick: PropTypes.func,
+ filterKeys: PropTypes.shape({}),
+ filterMessages: PropTypes.shape({}),
+ showSwitch: PropTypes.bool,
+ switchMessage: PropTypes.shape({}).isRequired,
+ onSwitchClick: PropTypes.func,
+};
+
+export default SearchSort;
diff --git a/src/editors/sharedComponents/SelectionModal/SearchSort.test.jsx b/src/editors/sharedComponents/SelectionModal/SearchSort.test.jsx
new file mode 100644
index 0000000000..8757d74e02
--- /dev/null
+++ b/src/editors/sharedComponents/SelectionModal/SearchSort.test.jsx
@@ -0,0 +1,92 @@
+import React from 'react';
+
+import '@testing-library/jest-dom';
+import {
+ act, fireEvent, render, screen,
+} from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import {
+ filterKeys, filterMessages, sortKeys, sortMessages,
+} from '../../containers/VideoGallery/utils';
+import SearchSort from './SearchSort';
+import messages from './messages';
+
+jest.unmock('react-redux');
+jest.unmock('@edx/frontend-platform/i18n');
+jest.unmock('@openedx/paragon');
+jest.unmock('@openedx/paragon/icons');
+
+describe('SearchSort component', () => {
+ const props = {
+ searchString: '',
+ onSearchChange: jest.fn()
+ .mockName('props.onSearchChange'),
+ clearSearchString: jest.fn()
+ .mockName('props.clearSearchString'),
+ sortBy: sortKeys.dateOldest,
+ sortKeys,
+ sortMessages,
+ onSortClick: jest.fn()
+ .mockName('props.onSortClick'),
+ switchMessage: {
+ id: 'test.id',
+ defaultMessage: 'test message',
+ },
+ filterBy: filterKeys.anyStatus,
+ onFilterClick: jest.fn(),
+ filterKeys,
+ filterMessages,
+ showSwitch: true,
+ };
+
+ function getComponent(overrideProps = {}) {
+ return render(
+
+
+ ,
+ );
+ }
+
+ test('adds a sort option for each sortKey', async () => {
+ const { getByRole } = getComponent();
+ await act(() => {
+ fireEvent.click(screen.getByRole('button', {
+ name: /By oldest/i,
+ }));
+ });
+ Object.values(sortMessages)
+ .forEach(({ defaultMessage }) => {
+ expect(getByRole('link', { name: `By ${defaultMessage}` }))
+ .toBeInTheDocument();
+ });
+ });
+ test('adds a sort option for each sortKey', async () => {
+ const { getByRole } = getComponent();
+ await act(() => {
+ fireEvent.click(screen.getByRole('button', { name: /oldest/i }));
+ });
+ Object.values(sortMessages)
+ .forEach(({ defaultMessage }) => {
+ expect(getByRole('link', { name: `By ${defaultMessage}` }))
+ .toBeInTheDocument();
+ });
+ });
+ test('adds a filter option for each filter key', async () => {
+ const { getByTestId } = getComponent();
+ act(() => {
+ fireEvent.click(getByTestId('dropdown-filter'));
+ });
+
+ Object.keys(filterMessages)
+ .forEach((key) => {
+ expect(getByTestId(`dropdown-filter-${key}`))
+ .toBeInTheDocument();
+ });
+ });
+ test('searchbox should show clear message button when not empty', async () => {
+ const { queryByRole } = getComponent({ searchString: 'some string' });
+ expect(queryByRole('button', { name: messages.clearSearch.defaultMessage }))
+ .toBeInTheDocument();
+ });
+});
diff --git a/src/editors/sharedComponents/SelectionModal/__snapshots__/Gallery.test.jsx.snap b/src/editors/sharedComponents/SelectionModal/__snapshots__/Gallery.test.jsx.snap
new file mode 100644
index 0000000000..a0651d470b
--- /dev/null
+++ b/src/editors/sharedComponents/SelectionModal/__snapshots__/Gallery.test.jsx.snap
@@ -0,0 +1,309 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TextEditor Image Gallery component component snapshot: loaded but no images, show empty gallery 1`] = `
+<[object Object]
+ value={
+ {
+ "$t": [Function],
+ "defaultFormats": {},
+ "defaultLocale": "en",
+ "defaultRichTextElements": undefined,
+ "fallbackOnEmptyString": true,
+ "formatDate": [Function],
+ "formatDateTimeRange": [Function],
+ "formatDateToParts": [Function],
+ "formatDisplayName": [Function],
+ "formatList": [Function],
+ "formatListToParts": [Function],
+ "formatMessage": [Function],
+ "formatNumber": [Function],
+ "formatNumberToParts": [Function],
+ "formatPlural": [Function],
+ "formatRelativeTime": [Function],
+ "formatTime": [Function],
+ "formatTimeToParts": [Function],
+ "formats": {},
+ "formatters": {
+ "getDateTimeFormat": [Function],
+ "getDisplayNames": [Function],
+ "getListFormat": [Function],
+ "getMessageFormat": [Function],
+ "getNumberFormat": [Function],
+ "getPluralRules": [Function],
+ "getRelativeTimeFormat": [Function],
+ },
+ "locale": "en",
+ "messages": {},
+ "onError": [Function],
+ "onWarn": [Function],
+ "textComponent": Symbol(react.fragment),
+ "timeZone": undefined,
+ "wrapRichTextChunksInFragment": undefined,
+ }
+ }
+>
+
+[object Object]>
+`;
+
+exports[`TextEditor Image Gallery component component snapshot: loaded but search returns no images, show 0 search result gallery 1`] = `
+<[object Object]
+ value={
+ {
+ "$t": [Function],
+ "defaultFormats": {},
+ "defaultLocale": "en",
+ "defaultRichTextElements": undefined,
+ "fallbackOnEmptyString": true,
+ "formatDate": [Function],
+ "formatDateTimeRange": [Function],
+ "formatDateToParts": [Function],
+ "formatDisplayName": [Function],
+ "formatList": [Function],
+ "formatListToParts": [Function],
+ "formatMessage": [Function],
+ "formatNumber": [Function],
+ "formatNumberToParts": [Function],
+ "formatPlural": [Function],
+ "formatRelativeTime": [Function],
+ "formatTime": [Function],
+ "formatTimeToParts": [Function],
+ "formats": {},
+ "formatters": {
+ "getDateTimeFormat": [Function],
+ "getDisplayNames": [Function],
+ "getListFormat": [Function],
+ "getMessageFormat": [Function],
+ "getNumberFormat": [Function],
+ "getPluralRules": [Function],
+ "getRelativeTimeFormat": [Function],
+ },
+ "locale": "en",
+ "messages": {},
+ "onError": [Function],
+ "onWarn": [Function],
+ "textComponent": Symbol(react.fragment),
+ "timeZone": undefined,
+ "wrapRichTextChunksInFragment": undefined,
+ }
+ }
+>
+
+[object Object]>
+`;
+
+exports[`TextEditor Image Gallery component component snapshot: loaded, show gallery 1`] = `
+<[object Object]
+ value={
+ {
+ "$t": [Function],
+ "defaultFormats": {},
+ "defaultLocale": "en",
+ "defaultRichTextElements": undefined,
+ "fallbackOnEmptyString": true,
+ "formatDate": [Function],
+ "formatDateTimeRange": [Function],
+ "formatDateToParts": [Function],
+ "formatDisplayName": [Function],
+ "formatList": [Function],
+ "formatListToParts": [Function],
+ "formatMessage": [Function],
+ "formatNumber": [Function],
+ "formatNumberToParts": [Function],
+ "formatPlural": [Function],
+ "formatRelativeTime": [Function],
+ "formatTime": [Function],
+ "formatTimeToParts": [Function],
+ "formats": {},
+ "formatters": {
+ "getDateTimeFormat": [Function],
+ "getDisplayNames": [Function],
+ "getListFormat": [Function],
+ "getMessageFormat": [Function],
+ "getNumberFormat": [Function],
+ "getPluralRules": [Function],
+ "getRelativeTimeFormat": [Function],
+ },
+ "locale": "en",
+ "messages": {},
+ "onError": [Function],
+ "onWarn": [Function],
+ "textComponent": Symbol(react.fragment),
+ "timeZone": undefined,
+ "wrapRichTextChunksInFragment": undefined,
+ }
+ }
+>
+
+[object Object]>
+`;
+
+exports[`TextEditor Image Gallery component component snapshot: not loaded, show spinner 1`] = `
+<[object Object]
+ value={
+ {
+ "$t": [Function],
+ "defaultFormats": {},
+ "defaultLocale": "en",
+ "defaultRichTextElements": undefined,
+ "fallbackOnEmptyString": true,
+ "formatDate": [Function],
+ "formatDateTimeRange": [Function],
+ "formatDateToParts": [Function],
+ "formatDisplayName": [Function],
+ "formatList": [Function],
+ "formatListToParts": [Function],
+ "formatMessage": [Function],
+ "formatNumber": [Function],
+ "formatNumberToParts": [Function],
+ "formatPlural": [Function],
+ "formatRelativeTime": [Function],
+ "formatTime": [Function],
+ "formatTimeToParts": [Function],
+ "formats": {},
+ "formatters": {
+ "getDateTimeFormat": [Function],
+ "getDisplayNames": [Function],
+ "getListFormat": [Function],
+ "getMessageFormat": [Function],
+ "getNumberFormat": [Function],
+ "getPluralRules": [Function],
+ "getRelativeTimeFormat": [Function],
+ },
+ "locale": "en",
+ "messages": {},
+ "onError": [Function],
+ "onWarn": [Function],
+ "textComponent": Symbol(react.fragment),
+ "timeZone": undefined,
+ "wrapRichTextChunksInFragment": undefined,
+ }
+ }
+>
+
+[object Object]>
+`;
diff --git a/src/editors/sharedComponents/SelectionModal/__snapshots__/GalleryCard.test.jsx.snap b/src/editors/sharedComponents/SelectionModal/__snapshots__/GalleryCard.test.jsx.snap
new file mode 100644
index 0000000000..f14a235f3e
--- /dev/null
+++ b/src/editors/sharedComponents/SelectionModal/__snapshots__/GalleryCard.test.jsx.snap
@@ -0,0 +1,505 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`GalleryCard component snapshot with duration badge 1`] = `
+
+
+
+
+
+ 01:00
+
+
+
+
+
+ props.img.displayName
+
+
+
+ ,
+ "time": ,
+ }
+ }
+ />
+
+
+
+
+`;
+
+exports[`GalleryCard component snapshot with duration transcripts 1`] = `
+
+
+
+
+
+
+
+
+ props.img.displayName
+
+
+
+
+
+
+ ,
+ "time": ,
+ }
+ }
+ />
+
+
+
+
+`;
+
+exports[`GalleryCard component snapshot with status badge 1`] = `
+
+
+
+
+
+
+
+
+ props.img.displayName
+
+
+
+ ,
+ "time": ,
+ }
+ }
+ />
+
+
+
+
+`;
+
+exports[`GalleryCard component snapshot with thumbnail fallback and load error 1`] = `
+
+
+
+
+
+
+
+
+ props.img.displayName
+
+
+
+ ,
+ "time": ,
+ }
+ }
+ />
+
+
+
+
+`;
+
+exports[`GalleryCard component snapshot with thumbnail fallback and no error 1`] = `
+
+
+
+
+
+
+
+
+ props.img.displayName
+
+
+
+ ,
+ "time": ,
+ }
+ }
+ />
+
+
+
+
+`;
+
+exports[`GalleryCard component snapshot: dateAdded=12345 1`] = `
+
+
+
+
+
+
+
+
+ props.img.displayName
+
+
+
+ ,
+ "time": ,
+ }
+ }
+ />
+
+
+
+
+`;
diff --git a/src/editors/sharedComponents/SelectionModal/index.jsx b/src/editors/sharedComponents/SelectionModal/index.jsx
new file mode 100644
index 0000000000..f96a29c832
--- /dev/null
+++ b/src/editors/sharedComponents/SelectionModal/index.jsx
@@ -0,0 +1,165 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Button, Stack } from '@openedx/paragon';
+import { Add } from '@openedx/paragon/icons';
+import {
+ FormattedMessage,
+ useIntl,
+} from '@edx/frontend-platform/i18n';
+
+import BaseModal from '../BaseModal';
+import SearchSort from './SearchSort';
+import Gallery from './Gallery';
+import { FileInput } from '../FileInput';
+import ErrorAlert from '../ErrorAlerts/ErrorAlert';
+import FetchErrorAlert from '../ErrorAlerts/FetchErrorAlert';
+import UploadErrorAlert from '../ErrorAlerts/UploadErrorAlert';
+
+import './index.scss';
+
+const SelectionModal = ({
+ isOpen,
+ close,
+ size,
+ isFullscreenScroll,
+ galleryError,
+ inputError,
+ fileInput,
+ galleryProps,
+ searchSortProps,
+ selectBtnProps,
+ acceptedFiles,
+ modalMessages,
+ isLoaded,
+ isFetchError,
+ isUploadError,
+}) => {
+ const intl = useIntl();
+ const {
+ confirmMsg,
+ uploadButtonMsg,
+ titleMsg,
+ fetchError,
+ uploadError,
+ } = modalMessages;
+
+ let background = '#FFFFFF';
+ let showGallery = true;
+ if (isLoaded && !isFetchError && !isUploadError && !inputError.show) {
+ background = '#E9E6E4';
+ } else if (isLoaded) {
+ showGallery = false;
+ }
+
+ const galleryPropsValues = {
+ isLoaded,
+ ...galleryProps,
+ };
+
+ return (
+
+
+
+ )}
+ isOpen={isOpen}
+ size={size}
+ isFullscreenScroll={isFullscreenScroll}
+ footerAction={(
+
+
+
+ )}
+ title={intl.formatMessage(titleMsg)}
+ bodyStyle={{ background }}
+ headerComponent={(
+
+
+
+ )}
+ className="selection-modal"
+ >
+ {/*
+ If the modal dialog content is zero height, it shows a bottom shadow
+ as if there was content to scroll to, so make the min-height 1px.
+ */}
+
+ {/* Error Alerts */}
+
+
+
+
+
+
+ {/* User Feedback Alerts */}
+
+
+
+ {showGallery && }
+
+
+
+ );
+};
+
+SelectionModal.defaultProps = {
+ size: 'lg',
+ isFullscreenScroll: true,
+};
+
+SelectionModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ close: PropTypes.func.isRequired,
+ size: PropTypes.string,
+ isFullscreenScroll: PropTypes.bool,
+ galleryError: PropTypes.shape({
+ dismiss: PropTypes.func.isRequired,
+ show: PropTypes.bool.isRequired,
+ set: PropTypes.func.isRequired,
+ message: PropTypes.shape({}).isRequired,
+ }).isRequired,
+ inputError: PropTypes.shape({
+ dismiss: PropTypes.func.isRequired,
+ show: PropTypes.bool.isRequired,
+ set: PropTypes.func.isRequired,
+ message: PropTypes.shape({}).isRequired,
+ }).isRequired,
+ fileInput: PropTypes.shape({
+ click: PropTypes.func.isRequired,
+ }).isRequired,
+ galleryProps: PropTypes.shape({}).isRequired,
+ searchSortProps: PropTypes.shape({}).isRequired,
+ selectBtnProps: PropTypes.shape({}).isRequired,
+ acceptedFiles: PropTypes.shape({}).isRequired,
+ modalMessages: PropTypes.shape({
+ confirmMsg: PropTypes.shape({}).isRequired,
+ uploadButtonMsg: PropTypes.shape({}).isRequired,
+ titleMsg: PropTypes.shape({}).isRequired,
+ fetchError: PropTypes.shape({}).isRequired,
+ uploadError: PropTypes.shape({}).isRequired,
+ }).isRequired,
+ isLoaded: PropTypes.bool.isRequired,
+ isFetchError: PropTypes.bool.isRequired,
+ isUploadError: PropTypes.bool.isRequired,
+};
+
+export default SelectionModal;
diff --git a/src/editors/sharedComponents/SelectionModal/index.scss b/src/editors/sharedComponents/SelectionModal/index.scss
new file mode 100644
index 0000000000..89e5978168
--- /dev/null
+++ b/src/editors/sharedComponents/SelectionModal/index.scss
@@ -0,0 +1,32 @@
+.search-sort-menu .pgn__menu-item-text {
+ text-transform: capitalize;
+}
+
+.search-sort-menu .pgn__menu .search-sort-menu-by {
+ display: none;
+}
+
+/* Sort options come in pairs of ascending and descending. */
+.search-sort-menu .pgn__menu > div:nth-child(even) {
+ border-bottom: 1px solid #F4F3F6;
+}
+
+.search-sort-menu .pgn__menu > div:last-child {
+ border-bottom: none;
+}
+
+.selection-modal .pgn__vstack > .alert {
+ margin-bottom: 0;
+ margin-top: 1.5rem;
+}
+
+/* Set top padding to 0 when there is an alert above. */
+.selection-modal .pgn__scrollable-body-content > .pgn__vstack > .alert + .gallery > .p-4 {
+ padding-top: 0 !important;
+}
+
+.selection-modal .pgn__vstack > .alert > .alert-icon {
+ /* Vertical margin equal to the vertical padding of the "Dismiss" button. */
+ margin-bottom: .4375rem;
+ margin-top: .4375rem;
+}
diff --git a/src/editors/sharedComponents/SelectionModal/index.test.jsx b/src/editors/sharedComponents/SelectionModal/index.test.jsx
new file mode 100644
index 0000000000..91186e8d69
--- /dev/null
+++ b/src/editors/sharedComponents/SelectionModal/index.test.jsx
@@ -0,0 +1,186 @@
+import React from 'react';
+
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { render, screen } from '@testing-library/react';
+
+import SelectionModal from '.';
+import '@testing-library/jest-dom';
+
+const props = {
+ isOpen: true,
+ close: jest.fn(),
+ size: 'fullscreen',
+ isFullscreenScroll: false,
+ galleryError: {
+ show: false,
+ set: jest.fn(),
+ dismiss: jest.fn(),
+ message: {
+ id: 'Gallery error id',
+ defaultMessage: 'Gallery error',
+ description: 'Gallery error',
+ },
+ },
+ inputError: {
+ show: false,
+ set: jest.fn(),
+ dismiss: jest.fn(),
+ message: {
+ id: 'Input error id',
+ defaultMessage: 'Input error',
+ description: 'Input error',
+ },
+ },
+ fileInput: {
+ addFile: 'imgHooks.fileInput.addFile',
+ click: 'imgHooks.fileInput.click',
+ ref: 'imgHooks.fileInput.ref',
+ },
+ galleryProps: {
+ gallery: 'props',
+ emptyGalleryLabel: {
+ id: 'emptyGalleryMsg',
+ defaultMessage: 'Empty Gallery',
+ },
+ },
+ searchSortProps: { search: 'sortProps' },
+ selectBtnProps: { select: 'btnProps' },
+ acceptedFiles: { png: '.png' },
+ modalMessages: {
+ confirmMsg: {
+ id: 'confirmMsg',
+ defaultMessage: 'confirmMsg',
+ description: 'confirmMsg',
+ },
+ uploadButtonMsg: {
+ id: 'uploadButtonMsg',
+ defaultMessage: 'uploadButtonMsg',
+ description: 'uploadButtonMsg',
+ },
+ titleMsg: {
+ id: 'titleMsg',
+ defaultMessage: 'titleMsg',
+ description: 'titleMsg',
+ },
+ fetchError: {
+ id: 'fetchError',
+ defaultMessage: 'fetchError',
+ description: 'fetchError',
+ },
+ uploadError: {
+ id: 'uploadError',
+ defaultMessage: 'uploadError',
+ description: 'uploadError',
+ },
+ },
+ isLoaded: true,
+ isFetchError: false,
+ isUploadError: false,
+};
+
+const mockGalleryFn = jest.fn();
+const mockFileInputFn = jest.fn();
+const mockFetchErrorAlertFn = jest.fn();
+const mockUploadErrorAlertFn = jest.fn();
+
+jest.mock('../BaseModal', () => 'BaseModal');
+jest.mock('./SearchSort', () => 'SearchSort');
+jest.mock('./Gallery', () => function mockGallery(componentProps) {
+ mockGalleryFn(componentProps);
+ return (Gallery
);
+});
+jest.mock('../FileInput', () => ({
+ FileInput: function mockFileInput(componentProps) {
+ mockFileInputFn(componentProps);
+ return (FileInput
);
+ },
+}));
+jest.mock('../ErrorAlerts/ErrorAlert', () => function mockErrorAlert() {
+ return ErrorAlert
;
+});
+jest.mock('../ErrorAlerts/FetchErrorAlert', () => function mockFetchErrorAlert(componentProps) {
+ mockFetchErrorAlertFn(componentProps);
+ return (FetchErrorAlert
);
+});
+jest.mock('../ErrorAlerts/UploadErrorAlert', () => function mockUploadErrorAlert(componentProps) {
+ mockUploadErrorAlertFn(componentProps);
+ return (UploadErrorAlert
);
+});
+
+describe('Selection Modal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ test('rendering correctly with expected Input', async () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Gallery')).toBeInTheDocument();
+ expect(screen.getByText('FileInput')).toBeInTheDocument();
+ expect(screen.getByText('FetchErrorAlert')).toBeInTheDocument();
+ expect(screen.getByText('UploadErrorAlert')).toBeInTheDocument();
+
+ expect(mockGalleryFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ...props.galleryProps,
+ isLoaded: props.isLoaded,
+ }),
+ );
+ expect(mockFetchErrorAlertFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isFetchError: props.isFetchError,
+ message: props.modalMessages.fetchError,
+ }),
+ );
+ expect(mockUploadErrorAlertFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isUploadError: props.isUploadError,
+ message: props.modalMessages.uploadError,
+ }),
+ );
+ expect(mockFileInputFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ acceptedFiles: '.png',
+ fileInput: props.fileInput,
+ }),
+ );
+ });
+ test('rendering correctly with errors', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByText('Gallery')).not.toBeInTheDocument();
+ expect(screen.getByText('FileInput')).toBeInTheDocument();
+ expect(screen.getByText('FetchErrorAlert')).toBeInTheDocument();
+ expect(screen.getByText('UploadErrorAlert')).toBeInTheDocument();
+
+ expect(mockFetchErrorAlertFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isFetchError: true,
+ message: props.modalMessages.fetchError,
+ }),
+ );
+ });
+ test('rendering correctly with loading', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Gallery')).toBeInTheDocument();
+ expect(screen.getByText('FileInput')).toBeInTheDocument();
+ expect(screen.getByText('FetchErrorAlert')).toBeInTheDocument();
+ expect(screen.getByText('UploadErrorAlert')).toBeInTheDocument();
+
+ expect(mockGalleryFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ...props.galleryProps,
+ isLoaded: false,
+ }),
+ );
+ });
+});
diff --git a/src/editors/sharedComponents/SelectionModal/messages.js b/src/editors/sharedComponents/SelectionModal/messages.js
new file mode 100644
index 0000000000..0fcf9b4214
--- /dev/null
+++ b/src/editors/sharedComponents/SelectionModal/messages.js
@@ -0,0 +1,34 @@
+const messages = {
+ searchPlaceholder: {
+ id: 'authoring.selectionmodal.search.placeholder',
+ defaultMessage: 'Search',
+ description: 'Placeholder text for search bar',
+ },
+ clearSearch: {
+ id: 'authoring.selectionmodal.search.clearSearchButton',
+ defaultMessage: 'Clear search query',
+ description: 'Button to clear search query',
+ },
+ emptySearchLabel: {
+ id: 'authoring.selectionmodal.emptySearchLabel',
+ defaultMessage: 'No search results.',
+ description: 'Label for when search returns nothing.',
+ },
+ loading: {
+ id: 'authoring.selectionmodal.spinner.readertext',
+ defaultMessage: 'loading...',
+ description: 'Gallery loading spinner screen-reader text',
+ },
+ addedDate: {
+ id: 'authoring.selectionmodal.addedDate.label',
+ defaultMessage: 'Added {date} at {time}',
+ description: 'File date-added string',
+ },
+ sortBy: {
+ id: 'authoring.selectionmodal.sortBy',
+ defaultMessage: 'By',
+ description: '"By" before sort option',
+ },
+};
+
+export default messages;
diff --git a/src/editors/sharedComponents/SourceCodeModal/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/SourceCodeModal/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..e0b26f6fec
--- /dev/null
+++ b/src/editors/sharedComponents/SourceCodeModal/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,47 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SourceCodeModal renders as expected with default behavior 1`] = `
+
+
+
+ }
+ footerAction={null}
+ headerComponent={null}
+ isFullscreenScroll={true}
+ isOpen={false}
+ size="xl"
+ title="Edit Source Code"
+>
+
+
+
+
+`;
diff --git a/src/editors/sharedComponents/SourceCodeModal/hooks.js b/src/editors/sharedComponents/SourceCodeModal/hooks.js
new file mode 100644
index 0000000000..0c35f1b4c3
--- /dev/null
+++ b/src/editors/sharedComponents/SourceCodeModal/hooks.js
@@ -0,0 +1,28 @@
+import { useRef } from 'react';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './hooks';
+
+export const getSaveBtnProps = ({ editorRef, ref, close }) => ({
+ onClick: () => {
+ if (editorRef && editorRef.current && ref && ref.current) {
+ const content = ref.current.state.doc.toString();
+ editorRef.current.setContent(content);
+ 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 });
+
+ if (editorRef && editorRef.current && typeof editorRef.current.getContent === 'function') {
+ const value = editorRef?.current?.getContent();
+ return { saveBtnProps, value, ref };
+ }
+ return { saveBtnProps, value: null, ref };
+};
diff --git a/src/editors/sharedComponents/SourceCodeModal/hooks.test.js b/src/editors/sharedComponents/SourceCodeModal/hooks.test.js
new file mode 100644
index 0000000000..08df5a2f73
--- /dev/null
+++ b/src/editors/sharedComponents/SourceCodeModal/hooks.test.js
@@ -0,0 +1,60 @@
+import React from 'react';
+
+import * as module from './hooks';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useRef: jest.fn(val => ({ current: val })),
+ useEffect: jest.fn(),
+ useCallback: (cb, prereqs) => ({ cb, prereqs }),
+}));
+
+describe('SourceCodeModal hooks', () => {
+ const mockContent = 'sOmEMockHtML';
+ const mockSetContent = jest.fn();
+ const mockEditorRef = {
+ current:
+ {
+ setContent: mockSetContent,
+ getContent: jest.fn(() => mockContent),
+ },
+ };
+ const mockClose = jest.fn();
+ test('getSaveBtnProps', () => {
+ const mockRef = {
+ current: {
+ state: {
+ doc: mockContent,
+ },
+ },
+ };
+ const input = {
+ ref: mockRef,
+ editorRef: mockEditorRef,
+ close: mockClose,
+ };
+ const resultProps = module.getSaveBtnProps(input);
+ resultProps.onClick();
+ expect(mockSetContent).toHaveBeenCalledWith(mockContent);
+ expect(mockClose).toHaveBeenCalled();
+ });
+
+ test('prepareSourceCodeModal', () => {
+ const props = {
+ close: mockClose,
+ editorRef: mockEditorRef,
+ };
+ const mockRef = { current: 'rEf' };
+ const spyRef = jest.spyOn(React, 'useRef').mockReturnValueOnce(mockRef);
+ const mockButton = 'mOcKBuTton';
+
+ const spyButtons = jest.spyOn(module, 'getSaveBtnProps').mockImplementation(
+ () => mockButton,
+ );
+
+ const result = module.prepareSourceCodeModal(props);
+ expect(spyRef).toHaveBeenCalled();
+ expect(spyButtons).toHaveBeenCalled();
+ expect(result).toStrictEqual({ saveBtnProps: mockButton, value: mockEditorRef.current.getContent(), ref: mockRef });
+ });
+});
diff --git a/src/editors/sharedComponents/SourceCodeModal/index.jsx b/src/editors/sharedComponents/SourceCodeModal/index.jsx
new file mode 100644
index 0000000000..88f7ff9f8e
--- /dev/null
+++ b/src/editors/sharedComponents/SourceCodeModal/index.jsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import {
+ FormattedMessage,
+ injectIntl,
+ intlShape,
+} from '@edx/frontend-platform/i18n';
+
+import { Button, useWindowSize } from '@openedx/paragon';
+import messages from './messages';
+import * as hooks from './hooks';
+import BaseModal from '../BaseModal';
+
+import CodeEditor from '../CodeEditor';
+
+const SourceCodeModal = ({
+ isOpen,
+ close,
+ editorRef,
+ // injected
+ intl,
+}) => {
+ const { saveBtnProps, value, ref } = hooks.prepareSourceCodeModal({ editorRef, close });
+ const { height } = useWindowSize();
+
+ return (
+
+
+
+ )}
+ isOpen={isOpen}
+ title={intl.formatMessage(messages.titleLabel)}
+ bodyStyle={{ maxHeight: (height - 180) }}
+ >
+
+
+
+
+ );
+};
+
+SourceCodeModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ close: PropTypes.func.isRequired,
+ editorRef: PropTypes.oneOfType([
+ PropTypes.func,
+ // eslint-disable-next-line react/forbid-prop-types
+ PropTypes.shape({ current: PropTypes.any }),
+ ]).isRequired,
+ // injected
+ intl: intlShape.isRequired,
+};
+
+export const SourceCodeModalInternal = SourceCodeModal; // For testing only
+export default injectIntl(SourceCodeModal);
diff --git a/src/editors/sharedComponents/SourceCodeModal/index.test.jsx b/src/editors/sharedComponents/SourceCodeModal/index.test.jsx
new file mode 100644
index 0000000000..a95b40e98a
--- /dev/null
+++ b/src/editors/sharedComponents/SourceCodeModal/index.test.jsx
@@ -0,0 +1,37 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+import * as hooks from './hooks';
+import { formatMessage } from '../../testUtils';
+
+import { SourceCodeModalInternal as SourceCodeModal } from '.';
+
+jest.mock('./hooks', () => ({
+ prepareSourceCodeModal: jest.fn(() => {
+
+ }),
+}));
+
+describe('SourceCodeModal', () => {
+ const mockClose = jest.fn();
+
+ const props = {
+ isOpen: false,
+ close: mockClose,
+ editorRef: {
+ current: jest.fn(),
+ },
+ intl: { formatMessage },
+ };
+ test('renders as expected with default behavior', () => {
+ const mocksaveBtnProps = 'SoMevAlue';
+ const mockvalue = 'mOckHtMl';
+ const mockref = 'moCKrEf';
+ hooks.prepareSourceCodeModal.mockReturnValueOnce({
+ saveBtnProps: mocksaveBtnProps,
+ value: mockvalue,
+ ref: mockref,
+ });
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+});
diff --git a/src/editors/sharedComponents/SourceCodeModal/messages.js b/src/editors/sharedComponents/SourceCodeModal/messages.js
new file mode 100644
index 0000000000..2f1bfe200e
--- /dev/null
+++ b/src/editors/sharedComponents/SourceCodeModal/messages.js
@@ -0,0 +1,17 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+
+ saveButtonLabel: {
+ id: 'authoring.texteditor.sourcecodemodal.next.label',
+ defaultMessage: 'Save',
+ description: 'Label for Save button for the source code editor',
+ },
+ titleLabel: {
+ id: 'authoring.texteditor.sourcecodemodal.title.label',
+ defaultMessage: 'Edit Source Code',
+ description: 'Title for the source code editor',
+ },
+});
+
+export default messages;
diff --git a/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000000..d28c1dd97c
--- /dev/null
+++ b/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,232 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TinyMceWidget snapshots ImageUploadModal is not rendered 1`] = `
+
+
+
+
+`;
+
+exports[`TinyMceWidget snapshots SourcecodeModal is not rendered 1`] = `
+
+
+
+
+`;
+
+exports[`TinyMceWidget snapshots renders as expected with default behavior 1`] = `
+
+
+
+
+
+`;
diff --git a/src/editors/sharedComponents/TinyMceWidget/customTinyMcePlugins/embedIframePlugin.js b/src/editors/sharedComponents/TinyMceWidget/customTinyMcePlugins/embedIframePlugin.js
new file mode 100644
index 0000000000..04057ca3a1
--- /dev/null
+++ b/src/editors/sharedComponents/TinyMceWidget/customTinyMcePlugins/embedIframePlugin.js
@@ -0,0 +1,205 @@
+function tinyMCEEmbedIframePlugin(editor) {
+ function openInsertIframeModal() {
+ const defaultConfig = {
+ title: 'Insert iframe',
+ body: {
+ type: 'tabpanel',
+ tabs: [
+ {
+ title: 'General',
+ items: [
+ {
+ type: 'input',
+ name: 'source',
+ label: 'Source URL',
+ multiline: false,
+ autofocus: true,
+ required: true,
+ },
+ {
+ type: 'selectbox',
+ name: 'sizeType',
+ label: 'Size',
+ items: [
+ { text: 'Inline Value', value: 'inline' },
+ { text: 'Big embed', value: 'big' },
+ { text: 'Small embed', value: 'small' },
+ ],
+ },
+
+ {
+ type: 'sizeinput',
+ name: 'size',
+ label: 'Dimensions',
+ },
+ ],
+ },
+ {
+ title: 'Advanced',
+ items: [
+ {
+ type: 'input',
+ name: 'name',
+ label: 'Name',
+ value: '',
+ },
+ {
+ type: 'input',
+ name: 'title',
+ label: 'Title',
+ value: '',
+ },
+ {
+ type: 'input',
+ name: 'longDescriptionURL',
+ label: 'Long description URL',
+ value: '',
+ },
+ {
+ type: 'checkbox',
+ name: 'border',
+ label: 'Show iframe border',
+ text: 'Border',
+ checked: false,
+ },
+ {
+ type: 'checkbox',
+ name: 'scrollbar',
+ label: 'Enable scrollbar',
+ text: 'Scrollbar',
+ checked: false,
+ },
+ ],
+ },
+ ],
+ },
+ buttons: [
+ {
+ type: 'cancel',
+ name: 'cancel',
+ text: 'Cancel',
+ },
+ {
+ type: 'submit',
+ name: 'save',
+ text: 'Save',
+ primary: true,
+ },
+ ],
+ onChange(api, field) {
+ const { name } = field;
+ const data = api.getData();
+ const { sizeType, ...fields } = data;
+ const isSizeTypeFiled = name === 'sizeType';
+ const hasCustomSize = sizeType === 'inline';
+
+ if (!hasCustomSize && isSizeTypeFiled) {
+ const {
+ body: {
+ tabs: [generalTab],
+ },
+ } = defaultConfig;
+
+ generalTab.items = generalTab.items.filter(
+ (item) => item.type !== 'sizeinput',
+ );
+
+ defaultConfig.initialData = { ...fields, sizeType };
+ api.redial(defaultConfig);
+ }
+
+ if (hasCustomSize && isSizeTypeFiled) {
+ const {
+ body: {
+ tabs: [generalTab],
+ },
+ } = defaultConfig;
+
+ const hasSizeInput = generalTab.items.some((item) => item.name === 'size');
+
+ if (!hasSizeInput) {
+ generalTab.items = [
+ ...generalTab.items,
+ {
+ type: 'sizeinput',
+ name: 'size',
+ label: 'Dimensions',
+ },
+ ];
+ }
+
+ defaultConfig.initialData = { ...fields, sizeType };
+ api.redial(defaultConfig);
+ }
+ },
+ onSubmit(api) {
+ const data = api.getData();
+ const sizeTypes = {
+ small: {
+ height: '100px',
+ width: '100px',
+ },
+ big: {
+ height: '800px',
+ width: '800px',
+ },
+ };
+ if (data.source) {
+ const {
+ size, sizeType, name, title, longDescriptionURL, border, scrollbar,
+ } = data;
+ const { width, height } = sizeTypes[sizeType] || { width: size.width, height: size.height };
+
+ const pxRegex = /^\d+px$/;
+ const widthFormat = pxRegex.test(width) ? width : '300px';
+ const heightFormat = pxRegex.test(height) ? height : '300px';
+ const hasScroll = scrollbar ? 'yes' : 'no';
+
+ let iframeCode = `';
+
+ iframeCode = ``
+ + `${iframeCode}`
+ + '
';
+
+ editor.insertContent(iframeCode);
+ }
+
+ api.close();
+ },
+ };
+
+ editor.windowManager.open(defaultConfig);
+ }
+
+ // Register the button
+ editor.ui.registry.addButton('embediframe', {
+ text: 'Embed iframe',
+ onAction: openInsertIframeModal,
+ });
+}
+
+((tinymce) => {
+ if (tinymce) {
+ tinymce.PluginManager.add('embediframe', tinyMCEEmbedIframePlugin);
+ }
+})(window.tinymce);
+
+export default tinyMCEEmbedIframePlugin;
diff --git a/src/editors/sharedComponents/TinyMceWidget/customTinyMcePlugins/embedIframePlugin.test.js b/src/editors/sharedComponents/TinyMceWidget/customTinyMcePlugins/embedIframePlugin.test.js
new file mode 100644
index 0000000000..521df48199
--- /dev/null
+++ b/src/editors/sharedComponents/TinyMceWidget/customTinyMcePlugins/embedIframePlugin.test.js
@@ -0,0 +1,408 @@
+import tinyMCEEmbedIframePlugin from './embedIframePlugin';
+
+const editorMock = {
+ windowManager: {
+ open: jest.fn(),
+ },
+ insertContent: jest.fn(),
+ ui: {
+ registry: {
+ addButton: jest.fn(),
+ },
+ },
+};
+
+describe('TinyMCE Embed IFrame Plugin', () => {
+ const pluginConfig = {
+ title: 'Insert iframe',
+ body: {
+ type: 'tabpanel',
+ tabs: [
+ {
+ title: 'General',
+ items: [
+ {
+ type: 'input',
+ name: 'source',
+ label: 'Source URL',
+ multiline: false,
+ autofocus: true,
+ required: true,
+ },
+ {
+ type: 'selectbox',
+ name: 'sizeType',
+ label: 'Size',
+ items: [
+ { text: 'Inline Value', value: 'inline' },
+ { text: 'Big embed', value: 'big' },
+ { text: 'Small embed', value: 'small' },
+ ],
+ },
+ {
+ type: 'sizeinput',
+ name: 'size',
+ label: 'Dimensions',
+ },
+ ],
+ },
+ {
+ title: 'Advanced',
+ items: [
+ {
+ type: 'input',
+ name: 'name',
+ label: 'Name',
+ value: '',
+ },
+ {
+ type: 'input',
+ name: 'title',
+ label: 'Title',
+ value: '',
+ },
+ {
+ type: 'input',
+ name: 'longDescriptionURL',
+ label: 'Long description URL',
+ value: '',
+ },
+ {
+ type: 'checkbox',
+ name: 'border',
+ label: 'Show iframe border',
+ text: 'Border',
+ checked: false,
+ },
+ {
+ type: 'checkbox',
+ name: 'scrollbar',
+ label: 'Enable scrollbar',
+ text: 'Scrollbar',
+ checked: false,
+ },
+ ],
+ },
+ ],
+ },
+ buttons: [
+ {
+ type: 'cancel',
+ name: 'cancel',
+ text: 'Cancel',
+ },
+ {
+ type: 'submit',
+ name: 'save',
+ text: 'Save',
+ primary: true,
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('opens insert iframe modal on button action', () => {
+ // Invoke the plugin
+ tinyMCEEmbedIframePlugin(editorMock);
+
+ editorMock.ui.registry.addButton.mock.calls[0][1].onAction();
+ expect(editorMock.windowManager.open).toHaveBeenCalled();
+ });
+
+ test('opens insert iframe modal on button action validate onSubmit and OnChange function', () => {
+ tinyMCEEmbedIframePlugin(editorMock);
+
+ editorMock.ui.registry.addButton.mock.calls[0][1].onAction();
+ expect(editorMock.windowManager.open).toHaveBeenCalledWith(
+ expect.objectContaining({
+ onSubmit: expect.any(Function),
+ onChange: expect.any(Function),
+ }),
+ );
+ });
+
+ test('opens insert iframe modal on button action validate title', () => {
+ tinyMCEEmbedIframePlugin(editorMock);
+
+ editorMock.ui.registry.addButton.mock.calls[0][1].onAction();
+ expect(editorMock.windowManager.open).toHaveBeenCalled();
+ expect(editorMock.windowManager.open).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: pluginConfig.title,
+ }),
+ );
+ });
+
+ test('opens insert iframe modal on button action validate buttons', () => {
+ tinyMCEEmbedIframePlugin(editorMock);
+
+ editorMock.ui.registry.addButton.mock.calls[0][1].onAction();
+ expect(editorMock.windowManager.open).toHaveBeenCalled();
+ expect(editorMock.windowManager.open).toHaveBeenCalledWith(
+ expect.objectContaining({
+ buttons: pluginConfig.buttons,
+ }),
+ );
+ });
+
+ test('opens insert iframe modal on button action validate tabs', () => {
+ tinyMCEEmbedIframePlugin(editorMock);
+
+ editorMock.ui.registry.addButton.mock.calls[0][1].onAction();
+ const [generalTab, advancedTab] = pluginConfig.body.tabs;
+
+ expect(editorMock.windowManager.open).toHaveBeenCalled();
+ expect(editorMock.windowManager.open).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: { type: 'tabpanel', tabs: [generalTab, advancedTab] },
+ }),
+ );
+ });
+ test('tests onChange function in plugin', () => {
+ tinyMCEEmbedIframePlugin(editorMock);
+ editorMock.ui.registry.addButton.mock.calls[0][1].onAction();
+
+ // Access the onChange function from the opened configuration
+ const onChangeFunction = editorMock.windowManager.open.mock.calls[0][0].onChange;
+
+ // Mock API and field for onChange
+ const apiMock = {
+ getData: jest.fn(() => ({ sizeType: 'big' })),
+ redial: jest.fn(),
+ };
+ const field = {
+ name: 'sizeType',
+ };
+
+ // Simulate calling the onChange function
+ onChangeFunction(apiMock, field);
+
+ expect(apiMock.getData).toHaveBeenCalled();
+ expect(apiMock.redial).toHaveBeenCalled();
+ });
+
+ test('modifies generalTab items when sizeType is not inline', () => {
+ tinyMCEEmbedIframePlugin(editorMock);
+ editorMock.ui.registry.addButton.mock.calls[0][1].onAction();
+
+ const onChangeFunction = editorMock.windowManager.open.mock.calls[0][0].onChange;
+
+ const apiMock = {
+ getData: jest.fn(() => ({ sizeType: 'big' })),
+ redial: jest.fn(),
+ };
+ const field = {
+ name: 'sizeType',
+ };
+
+ onChangeFunction(apiMock, field);
+
+ const [generalTab, advancedTab] = pluginConfig.body.tabs;
+ const generalTabExpected = generalTab.items.filter(
+ (item) => item.type !== 'sizeinput',
+ );
+
+ const expectedTabs = [
+ { title: generalTab.title, items: generalTabExpected, type: generalTab.type },
+ advancedTab,
+ ];
+
+ const expectedBody = {
+ type: pluginConfig.body.type,
+ tabs: expectedTabs,
+ };
+
+ expect(apiMock.redial).toHaveBeenCalledWith(expect.objectContaining({
+ body: expectedBody,
+ }));
+ });
+
+ test('adds sizeinput to generalTab items when sizeType is inline', () => {
+ tinyMCEEmbedIframePlugin(editorMock);
+ editorMock.ui.registry.addButton.mock.calls[0][1].onAction();
+
+ const onChangeFunction = editorMock.windowManager.open.mock.calls[0][0].onChange;
+
+ const apiMock = {
+ getData: jest.fn(() => ({ sizeType: 'inline' })),
+ redial: jest.fn(),
+ };
+ const field = {
+ name: 'sizeType',
+ };
+
+ onChangeFunction(apiMock, field);
+
+ const [generalTab, advancedTab] = pluginConfig.body.tabs;
+
+ expect(apiMock.redial).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: { type: 'tabpanel', tabs: [generalTab, advancedTab] },
+ }),
+ );
+ });
+
+ test('tests onSubmit function in plugin', () => {
+ const dataMock = {
+ source: 'https://www.example.com',
+ sizeType: 'big',
+ };
+ const apiMock = {
+ getData: jest.fn(() => dataMock),
+ close: jest.fn(),
+ };
+
+ tinyMCEEmbedIframePlugin(editorMock);
+ editorMock.ui.registry.addButton.mock.calls[0][1].onAction();
+
+ const onSubmitFunction = editorMock.windowManager.open.mock.calls[0][0].onSubmit;
+ onSubmitFunction(apiMock);
+
+ expect(apiMock.getData).toHaveBeenCalled();
+ expect(editorMock.insertContent).toHaveBeenCalled();
+ expect(apiMock.close).toHaveBeenCalled();
+ });
+
+ test('tests onSubmit function in plugin advanced properties', () => {
+ const dataMock = {
+ source: 'https://www.example.com',
+ sizeType: 'big',
+ name: 'iframeName',
+ title: 'iframeTitle',
+ longDescriptionURL: 'https://example.com/description',
+ border: true,
+ scrollbar: true,
+ };
+ const apiMock = {
+ getData: jest.fn(() => dataMock),
+ close: jest.fn(),
+ };
+
+ tinyMCEEmbedIframePlugin(editorMock);
+ editorMock.ui.registry.addButton.mock.calls[0][1].onAction();
+
+ const onSubmitFunction = editorMock.windowManager.open.mock.calls[0][0].onSubmit;
+ onSubmitFunction(apiMock);
+
+ expect(apiMock.getData).toHaveBeenCalled();
+
+ expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('width="800px"'));
+ expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('height="800px"'));
+ expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining(`name="${dataMock.name}"`));
+ expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining(`title="${dataMock.title}"`));
+ expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining(`longdesc="${dataMock.longDescriptionURL}"`));
+ expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('scrolling="yes"'));
+
+ expect(apiMock.close).toHaveBeenCalled();
+ });
+
+ describe('tests onSubmit function in plugin sizeType', () => {
+ test('tests onSubmit function in plugin with sizeType big', () => {
+ const dataMock = {
+ source: 'https://www.example.com',
+ sizeType: 'big',
+ };
+
+ const apiMock = {
+ getData: jest.fn(() => dataMock),
+ close: jest.fn(),
+ };
+
+ tinyMCEEmbedIframePlugin(editorMock);
+ editorMock.ui.registry.addButton.mock.calls[0][1].onAction();
+
+ const onSubmitFunction = editorMock.windowManager.open.mock.calls[0][0].onSubmit;
+ onSubmitFunction(apiMock);
+
+ expect(apiMock.getData).toHaveBeenCalled();
+
+ expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('width="800px"'));
+ expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('height="800px"'));
+
+ expect(apiMock.close).toHaveBeenCalled();
+ });
+
+ test('tests onSubmit function in plugin with sizeType small', () => {
+ const dataMock = {
+ source: 'https://www.example.com',
+ sizeType: 'small',
+ };
+
+ const apiMock = {
+ getData: jest.fn(() => dataMock),
+ close: jest.fn(),
+ };
+
+ tinyMCEEmbedIframePlugin(editorMock);
+ editorMock.ui.registry.addButton.mock.calls[0][1].onAction();
+
+ const onSubmitFunction = editorMock.windowManager.open.mock.calls[0][0].onSubmit;
+ onSubmitFunction(apiMock);
+
+ expect(apiMock.getData).toHaveBeenCalled();
+
+ expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('width="100px"'));
+ expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('height="100px"'));
+ expect(apiMock.close).toHaveBeenCalled();
+ });
+
+ test('tests onSubmit function in plugin with custom sizeType', () => {
+ const dataMock = {
+ source: 'https://www.example.com',
+ sizeType: 'inline',
+ size: {
+ width: '500px',
+ height: '700px',
+ },
+ };
+
+ const apiMock = {
+ getData: jest.fn(() => dataMock),
+ close: jest.fn(),
+ };
+
+ tinyMCEEmbedIframePlugin(editorMock);
+ editorMock.ui.registry.addButton.mock.calls[0][1].onAction();
+
+ const onSubmitFunction = editorMock.windowManager.open.mock.calls[0][0].onSubmit;
+ onSubmitFunction(apiMock);
+
+ expect(apiMock.getData).toHaveBeenCalled();
+
+ expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('width="500px"'));
+ expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('height="700px"'));
+ expect(apiMock.close).toHaveBeenCalled();
+ });
+
+ test('tests onSubmit function in plugin with custom sizeType invalid values', () => {
+ const dataMock = {
+ source: 'https://www.example.com',
+ sizeType: 'inline',
+ size: {
+ width: 'test',
+ height: 'test',
+ },
+ };
+
+ const apiMock = {
+ getData: jest.fn(() => dataMock),
+ close: jest.fn(),
+ };
+
+ tinyMCEEmbedIframePlugin(editorMock);
+ editorMock.ui.registry.addButton.mock.calls[0][1].onAction();
+
+ const onSubmitFunction = editorMock.windowManager.open.mock.calls[0][0].onSubmit;
+ onSubmitFunction(apiMock);
+
+ expect(apiMock.getData).toHaveBeenCalled();
+
+ expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('width="300px"'));
+ expect(editorMock.insertContent).toHaveBeenCalledWith(expect.stringContaining('height="300px"'));
+ expect(apiMock.close).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/TinyMceWidget/hooks.js b/src/editors/sharedComponents/TinyMceWidget/hooks.js
new file mode 100644
index 0000000000..2353a158ed
--- /dev/null
+++ b/src/editors/sharedComponents/TinyMceWidget/hooks.js
@@ -0,0 +1,463 @@
+import {
+ useState,
+ useRef,
+ useCallback,
+ useEffect,
+} from 'react';
+import { getLocale, isRtl } from '@edx/frontend-platform/i18n';
+import { a11ycheckerCss } from 'frontend-components-tinymce-advanced-plugins';
+import { isEmpty } from 'lodash';
+import tinyMCEStyles from '../../data/constants/tinyMCEStyles';
+import { StrictDict } from '../../utils';
+import pluginConfig from './pluginConfig';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './hooks';
+import * as tinyMCE from '../../data/constants/tinyMCE';
+import { getRelativeUrl, getStaticUrl, parseAssetName } from './utils';
+
+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, images, editorContentHtml }) => {
+ const imagesWithDimensions = Object.values(images).map((image) => {
+ const imageFragment = module.getImageFromHtmlString(editorContentHtml, image.url);
+ return { ...image, width: imageFragment?.width, height: imageFragment?.height };
+ });
+ // eslint-disable-next-line no-param-reassign
+ imagesRef.current = imagesWithDimensions;
+};
+
+export const useImages = ({ images, editorContentHtml }) => {
+ const imagesRef = useRef([]);
+
+ useEffect(() => {
+ module.addImagesAndDimensionsToRef({ imagesRef, images, editorContentHtml });
+ }, [images]);
+
+ return { imagesRef };
+};
+
+export const parseContentForLabels = ({ editor, updateContent }) => {
+ let content = editor.getContent();
+ if (content && content?.length > 0) {
+ const parsedLabels = content.split(/|<\/label>/gm);
+ let updatedContent;
+ parsedLabels.forEach((label, i) => {
+ if (!label.startsWith('<') && !label.endsWith('>')) {
+ let previousLabel = parsedLabels[i - 1];
+ let nextLabel = parsedLabels[i + 1];
+ if (!previousLabel.endsWith('')) {
+ previousLabel = `${previousLabel}
`;
+ updatedContent = content.replace(parsedLabels[i - 1], previousLabel);
+ content = updatedContent;
+ updateContent(content);
+ }
+ if (!nextLabel.startsWith('
')) {
+ nextLabel = `${nextLabel}`;
+ updatedContent = content.replace(parsedLabels[i + 1], nextLabel);
+ content = updatedContent;
+ updateContent(content);
+ }
+ }
+ });
+ } else {
+ updateContent(content);
+ }
+};
+
+export const replaceStaticWithAsset = ({
+ initialContent,
+ learningContextId,
+ editorType,
+ lmsEndpointUrl,
+}) => {
+ let content = initialContent;
+ let hasChanges = false;
+ const srcs = content.split(/(src="|src="|href="|href=")/g).filter(
+ src => src.startsWith('/static') || src.startsWith('/asset'),
+ );
+ if (!isEmpty(srcs)) {
+ srcs.forEach(src => {
+ const currentContent = content;
+ let staticFullUrl;
+ const isStatic = src.startsWith('/static/');
+ const assetSrc = src.substring(0, src.indexOf('"'));
+ const staticName = assetSrc.substring(8);
+ const assetName = parseAssetName(src);
+ const displayName = isStatic ? staticName : assetName;
+ const isCorrectAssetFormat = assetSrc.startsWith('/asset') && assetSrc.match(/\/asset-v1:\S+[+]\S+[@]\S+[+]\S+[@]/g)?.length >= 1;
+ // assets in expandable text areas so not support relative urls so all assets must have the lms
+ // endpoint prepended to the relative url
+ if (editorType === 'expandable') {
+ if (isCorrectAssetFormat) {
+ staticFullUrl = `${lmsEndpointUrl}${assetSrc}`;
+ } else {
+ staticFullUrl = `${lmsEndpointUrl}${getRelativeUrl({ courseId: learningContextId, displayName })}`;
+ }
+ } else if (!isCorrectAssetFormat) {
+ staticFullUrl = getRelativeUrl({ courseId: learningContextId, displayName });
+ }
+ if (staticFullUrl) {
+ const currentSrc = src.substring(0, src.indexOf('"'));
+ content = currentContent.replace(currentSrc, staticFullUrl);
+ hasChanges = true;
+ }
+ });
+ if (hasChanges) { return content; }
+ }
+ return false;
+};
+
+export const getImageResizeHandler = ({ editor, imagesRef, setImage }) => () => {
+ const {
+ src, alt, width, height,
+ } = editor.selection.getNode();
+
+ // eslint-disable-next-line no-param-reassign
+ 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,
+ editorType,
+ images,
+ setImage,
+ lmsEndpointUrl,
+ learningContextId,
+}) => (editor) => {
+ // image upload button
+ editor.ui.registry.addButton(tinyMCE.buttons.imageUploadButton, {
+ icon: 'image',
+ tooltip: 'Add Image',
+ onAction: openImgModal,
+ });
+ // editing an existing image
+ editor.ui.registry.addButton(tinyMCE.buttons.editImageSettings, {
+ icon: 'image',
+ tooltip: 'Edit Image Settings',
+ onAction: module.openModalWithSelectedImage({
+ editor, images, setImage, openImgModal,
+ }),
+ });
+ // overriding the code plugin's icon with 'HTML' text
+ editor.ui.registry.addButton(tinyMCE.buttons.code, {
+ text: 'HTML',
+ tooltip: 'Source code',
+ onAction: openSourceCodeModal,
+ });
+ // add a custom simple inline code block formatter.
+ const setupCodeFormatting = (api) => {
+ editor.formatter.formatChanged(
+ 'code',
+ (active) => api.setActive(active),
+ );
+ };
+ const toggleCodeFormatting = () => {
+ editor.formatter.toggle('code');
+ editor.undoManager.add();
+ editor.focus();
+ };
+ editor.ui.registry.addToggleButton(tinyMCE.buttons.codeBlock, {
+ icon: 'sourcecode',
+ tooltip: 'Code Block',
+ onAction: toggleCodeFormatting,
+ onSetup: setupCodeFormatting,
+ });
+ // add a custom simple inline label formatter.
+ const toggleLabelFormatting = () => {
+ editor.execCommand('mceToggleFormat', false, 'label');
+ };
+ editor.ui.registry.addIcon('textToSpeech', tinyMCE.textToSpeechIcon);
+ editor.ui.registry.addButton('customLabelButton', {
+ icon: 'textToSpeech',
+ text: 'Label',
+ tooltip: 'Apply a "Question" label to specific text, recognized by screen readers. Recommended to improve accessibility.',
+ onAction: toggleLabelFormatting,
+ });
+ if (editorType === 'expandable') {
+ editor.on('init', () => {
+ const initialContent = editor.getContent();
+ const newContent = module.replaceStaticWithAsset({
+ initialContent,
+ editorType,
+ lmsEndpointUrl,
+ learningContextId,
+ });
+ if (newContent) { updateContent(newContent); }
+ });
+ }
+ editor.on('ExecCommand', (e) => {
+ if (editorType === 'text' && e.command === 'mceFocus') {
+ const initialContent = editor.getContent();
+ const newContent = module.replaceStaticWithAsset({
+ initialContent,
+ learningContextId,
+ });
+ if (newContent) { editor.setContent(newContent); }
+ }
+ if (e.command === 'RemoveFormat') {
+ editor.formatter.remove('blockquote');
+ 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
+export const removeProtocolFromUrl = (url) => url.replace(/^https?:\/\//, '');
+
+export const editorConfig = ({
+ editorType,
+ setEditorRef,
+ editorContentHtml,
+ images,
+ lmsEndpointUrl,
+ studioEndpointUrl,
+ isLibrary,
+ placeholder,
+ initializeEditor,
+ openImgModal,
+ openSourceCodeModal,
+ setSelection,
+ updateContent,
+ content,
+ minHeight,
+ learningContextId,
+}) => {
+ const {
+ toolbar,
+ config,
+ plugins,
+ imageToolbar,
+ quickbarsInsertToolbar,
+ quickbarsSelectionToolbar,
+ } = pluginConfig({ isLibrary, placeholder, editorType });
+ const isLocaleRtl = isRtl(getLocale());
+
+ return {
+ onInit: (evt, editor) => {
+ setEditorRef(editor);
+ if (editorType === 'text') {
+ initializeEditor();
+ }
+ },
+ initialValue: editorContentHtml || '',
+ init: {
+ ...config,
+ skin: false,
+ content_css: false,
+ content_style: tinyMCEStyles + a11ycheckerCss,
+ min_height: minHeight,
+ contextmenu: 'link table',
+ directionality: isLocaleRtl ? 'rtl' : 'ltr',
+ document_base_url: lmsEndpointUrl,
+ imagetools_cors_hosts: [removeProtocolFromUrl(lmsEndpointUrl), removeProtocolFromUrl(studioEndpointUrl)],
+ imagetools_toolbar: imageToolbar,
+ formats: { label: { inline: 'label' } },
+ setup: module.setupCustomBehavior({
+ editorType,
+ updateContent,
+ openImgModal,
+ openSourceCodeModal,
+ lmsEndpointUrl,
+ setImage: setSelection,
+ content,
+ images,
+ learningContextId,
+ }),
+ quickbars_insert_toolbar: quickbarsInsertToolbar,
+ quickbars_selection_toolbar: quickbarsSelectionToolbar,
+ quickbars_image_toolbar: false,
+ toolbar,
+ plugins,
+ valid_children: '+body[style]',
+ valid_elements: '*[*]',
+ entity_encoding: 'utf-8',
+ },
+ };
+};
+
+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 };
+};
+
+export const imgModalToggle = () => {
+ const [isImgOpen, setIsOpen] = module.state.isImageModalOpen(false);
+ return {
+ isImgOpen,
+ openImgModal: () => setIsOpen(true),
+ closeImgModal: () => setIsOpen(false),
+ };
+};
+
+export const sourceCodeModalToggle = (editorRef) => {
+ const [isSourceCodeOpen, setIsOpen] = module.state.isSourceCodeModalOpen(false);
+ return {
+ isSourceCodeOpen,
+ openSourceCodeModal: () => setIsOpen(true),
+ closeSourceCodeModal: () => {
+ setIsOpen(false);
+ editorRef.current.focus();
+ },
+ };
+};
+
+/**
+ * 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: tinyMceHTML.src,
+ altText: tinyMceHTML.alt,
+ width,
+ height,
+ });
+
+ openImgModal();
+};
+
+export const setAssetToStaticUrl = ({ editorValue, lmsEndpointUrl }) => {
+ /* For assets to remain usable across course instances, we convert their url to be course-agnostic.
+ * For example, /assets/course//filename gets converted to /static/filename. This is
+ * important for rerunning courses and importing/exporting course as the /static/ part of the url
+ * allows the asset to be mapped to the new course run.
+ */
+
+ // TODO: should probably move this to when the assets are being looped through in the off chance that
+ // some of the text in the editor contains the lmsEndpointUrl
+ const regExLmsEndpointUrl = RegExp(lmsEndpointUrl, 'g');
+ let content = editorValue.replace(regExLmsEndpointUrl, '');
+
+ const assetSrcs = typeof content === 'string' ? content.split(/(src="|src="|href="|href=")/g) : [];
+ assetSrcs.filter(src => src.startsWith('/asset')).forEach(src => {
+ const nameFromEditorSrc = parseAssetName(src);
+ const portableUrl = getStaticUrl({ displayName: nameFromEditorSrc });
+ const currentSrc = src.substring(0, src.search(/("|")/));
+ const updatedContent = content.replace(currentSrc, portableUrl);
+ content = updatedContent;
+ });
+ return content;
+};
+
+export const selectedImage = (val) => {
+ const [selection, setSelection] = module.state.imageSelection(val);
+ return {
+ clearSelection: () => setSelection(null),
+ selection,
+ 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 };
+};
diff --git a/src/editors/sharedComponents/TinyMceWidget/hooks.test.js b/src/editors/sharedComponents/TinyMceWidget/hooks.test.js
new file mode 100644
index 0000000000..7f9ee94bcb
--- /dev/null
+++ b/src/editors/sharedComponents/TinyMceWidget/hooks.test.js
@@ -0,0 +1,577 @@
+import 'CourseAuthoring/editors/setupEditorTest';
+import { MockUseState } from '../../testUtils';
+
+import * as tinyMCE from '../../data/constants/tinyMCE';
+import { keyStore } from '../../utils';
+import pluginConfig from './pluginConfig';
+// This 'module' self-import hack enables mocking during tests.
+// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
+// should be re-thought and cleaned up to avoid this pattern.
+// eslint-disable-next-line import/no-self-import
+import * as module from './hooks';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ createRef: jest.fn(val => ({ ref: val })),
+ useRef: jest.fn(val => ({ current: val })),
+ useEffect: jest.fn(),
+ useCallback: (cb, prereqs) => ({ cb, prereqs }),
+}));
+
+const state = new MockUseState(module);
+const moduleKeys = keyStore(module);
+
+let hook;
+let output;
+
+const editorImageWidth = 2022;
+const editorImageHeight = 1619;
+
+const mockNode = {
+ src: 'http://localhost:18000/asset-v1:TestX+Test01+Test0101+type@asset+block/DALL_E_2023-03-10.png',
+ alt: 'aLt tExt',
+ 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 mockImages = {
+ [mockImage.id]: mockImage,
+};
+
+const mockEditorContentHtml = `
+
+
+
+
+`;
+const baseAssetUrl = 'asset-v1:org+test+run+type@asset+block';
+
+const mockImagesRef = { current: [mockImage] };
+
+describe('TinyMceEditor hooks', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockImagesRef.current = [mockImage];
+ });
+ describe('state hooks', () => {
+ state.testGetter(state.keys.isImageModalOpen);
+ state.testGetter(state.keys.isSourceCodeModalOpen);
+ state.testGetter(state.keys.imageSelection);
+ });
+
+ describe('non-state 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();
+ const addIcon = jest.fn();
+ const addToggleButton = jest.fn();
+ const openImgModal = jest.fn();
+ const openSourceCodeModal = jest.fn();
+ const setImage = jest.fn();
+ const updateContent = jest.fn();
+ const editorType = 'expandable';
+ const lmsEndpointUrl = 'sOmEvaLue.cOm';
+ const editor = {
+ ui: { registry: { addButton, addToggleButton, addIcon } },
+ on: jest.fn(),
+ };
+ const mockOpenModalWithImage = args => ({ openModalWithSelectedImage: args });
+ const expectedSettingsAction = mockOpenModalWithImage({ editor, setImage, openImgModal });
+ const toggleCodeFormatting = expect.any(Function);
+ const toggleLabelFormatting = expect.any(Function);
+ const setupCodeFormatting = expect.any(Function);
+ jest.spyOn(module, moduleKeys.openModalWithSelectedImage)
+ .mockImplementationOnce(mockOpenModalWithImage);
+
+ output = module.setupCustomBehavior({
+ editorType,
+ updateContent,
+ openImgModal,
+ openSourceCodeModal,
+ setImage,
+ lmsEndpointUrl,
+ })(editor);
+ expect(addIcon.mock.calls).toEqual([['textToSpeech', tinyMCE.textToSpeechIcon]]);
+ expect(addButton.mock.calls).toEqual([
+ [tinyMCE.buttons.imageUploadButton, { icon: 'image', tooltip: 'Add Image', onAction: openImgModal }],
+ [tinyMCE.buttons.editImageSettings, { icon: 'image', tooltip: 'Edit Image Settings', onAction: expectedSettingsAction }],
+ [tinyMCE.buttons.code, { text: 'HTML', tooltip: 'Source code', onAction: openSourceCodeModal }],
+ ['customLabelButton', {
+ icon: 'textToSpeech',
+ text: 'Label',
+ tooltip: 'Apply a "Question" label to specific text, recognized by screen readers. Recommended to improve accessibility.',
+ onAction: toggleLabelFormatting,
+ }],
+ ]);
+ expect(addToggleButton.mock.calls).toEqual([
+ [tinyMCE.buttons.codeBlock, {
+ icon: 'sourcecode', tooltip: 'Code Block', onAction: toggleCodeFormatting, onSetup: setupCodeFormatting,
+ }],
+ ]);
+ expect(openImgModal).not.toHaveBeenCalled();
+ expect(editor.on).toHaveBeenCalled();
+ });
+ });
+
+ describe('parseContentForLabels', () => {
+ test('it calls getContent and updateQuestion for some content', () => {
+ const editor = { getContent: jest.fn(() => 'Some question label
some content around a label followed by more text
') };
+ const updateContent = jest.fn();
+ const content = 'Some question label
some content
around a label
followed by more text
';
+ module.parseContentForLabels({ editor, updateContent });
+ expect(editor.getContent).toHaveBeenCalled();
+ expect(updateContent).toHaveBeenCalledWith(content);
+ });
+ test('it calls getContent and updateQuestion for empty content', () => {
+ const editor = { getContent: jest.fn(() => '') };
+ const updateContent = jest.fn();
+ const content = '';
+ module.parseContentForLabels({ editor, updateContent });
+ expect(editor.getContent).toHaveBeenCalled();
+ expect(updateContent).toHaveBeenCalledWith(content);
+ });
+ });
+
+ describe('replaceStaticWithAsset', () => {
+ const initialContent = `test `;
+ const learningContextId = 'course-v1:org+test+run';
+ const lmsEndpointUrl = 'sOmEvaLue.cOm';
+ it('returns updated src for text editor to update content', () => {
+ const expected = `test `;
+ const actual = module.replaceStaticWithAsset({ initialContent, learningContextId });
+ expect(actual).toEqual(expected);
+ });
+ it('returns updated src with absolute url for expandable editor to update content', () => {
+ const editorType = 'expandable';
+ const expected = `test `;
+ const actual = module.replaceStaticWithAsset({
+ initialContent,
+ editorType,
+ lmsEndpointUrl,
+ learningContextId,
+ });
+ expect(actual).toEqual(expected);
+ });
+ it('returns false when there are no srcs to update', () => {
+ const content = 'Hello world!
';
+ const actual = module.replaceStaticWithAsset({ initialContent: content, learningContextId });
+ expect(actual).toBeFalsy();
+ });
+ });
+ describe('setAssetToStaticUrl', () => {
+ it('returns content with updated img links', () => {
+ const editorValue = ` testing link `;
+ const lmsEndpointUrl = 'sOmEvaLue.cOm';
+ const content = module.setAssetToStaticUrl({ editorValue, lmsEndpointUrl });
+ expect(content).toEqual(' testing link ');
+ });
+ });
+
+ describe('editorConfig', () => {
+ const props = {
+ editorContentHtml: null,
+ editorType: 'text',
+ lmsEndpointUrl: 'sOmEuRl.cOm',
+ studioEndpointUrl: 'sOmEoThEruRl.cOm',
+ images: mockImagesRef,
+ isLibrary: false,
+ learningContextId: 'course+org+run',
+ };
+ const evt = 'fakeEvent';
+ const editor = 'myEditor';
+ const setupCustomBehavior = args => ({ setupCustomBehavior: args });
+
+ beforeEach(() => {
+ props.setEditorRef = jest.fn();
+ props.openImgModal = jest.fn();
+ props.openSourceCodeModal = jest.fn();
+ props.initializeEditor = jest.fn();
+ props.updateContent = jest.fn();
+ jest.spyOn(module, moduleKeys.setupCustomBehavior)
+ .mockImplementationOnce(setupCustomBehavior);
+ output = module.editorConfig(props);
+ });
+
+ describe('text editor plugins and toolbar', () => {
+ test('It configures plugins and toolbars correctly', () => {
+ const pluginProps = {
+ isLibrary: props.isLibrary,
+ editorType: props.editorType,
+ };
+ expect(output.init.plugins).toEqual(pluginConfig(pluginProps).plugins);
+ expect(output.init.imagetools_toolbar).toEqual(pluginConfig(pluginProps).imageToolbar);
+ expect(output.init.toolbar).toEqual(pluginConfig(pluginProps).toolbar);
+ Object.keys(pluginConfig(pluginProps).config).forEach(key => {
+ expect(output.init[key]).toEqual(pluginConfig(pluginProps).config[key]);
+ });
+ // Commented out as we investigate whether this is only needed for image proxy
+ // expect(output.init.imagetools_cors_hosts).toMatchObject([props.lmsEndpointUrl]);
+ });
+ });
+ describe('text editor plugins and toolbar for content library', () => {
+ test('It configures plugins and toolbars correctly', () => {
+ const pluginProps = {
+ isLibrary: true,
+ editorType: props.editorType,
+ };
+ output = module.editorConfig({ ...props, isLibrary: true });
+ expect(output.init.plugins).toEqual(pluginConfig(pluginProps).plugins);
+ expect(output.init.imagetools_toolbar).toEqual(pluginConfig(pluginProps).imageToolbar);
+ expect(output.init.toolbar).toEqual(pluginConfig(pluginProps).toolbar);
+ expect(output.init.quickbars_insert_toolbar).toEqual(pluginConfig(pluginProps).quickbarsInsertToolbar);
+ expect(output.init.quickbars_selection_toolbar).toEqual(pluginConfig(pluginProps).quickbarsSelectionToolbar);
+ Object.keys(pluginConfig(pluginProps).config).forEach(key => {
+ expect(output.init[key]).toEqual(pluginConfig(pluginProps).config[key]);
+ });
+ });
+ });
+ describe('problem editor question plugins and toolbar', () => {
+ test('It configures plugins and toolbars correctly', () => {
+ const pluginProps = {
+ isLibrary: props.isLibrary,
+ editorType: 'question',
+ placeholder: 'soMEtExT',
+ };
+ output = module.editorConfig({
+ ...props,
+ editorType: 'question',
+ placeholder: 'soMEtExT',
+ });
+ expect(output.init.plugins).toEqual(pluginConfig(pluginProps).plugins);
+ expect(output.init.imagetools_toolbar).toEqual(pluginConfig(pluginProps).imageToolbar);
+ expect(output.init.toolbar).toEqual(pluginConfig(pluginProps).toolbar);
+ expect(output.init.quickbars_insert_toolbar).toEqual(pluginConfig(pluginProps).quickbarsInsertToolbar);
+ expect(output.init.quickbars_selection_toolbar).toEqual(pluginConfig(pluginProps).quickbarsSelectionToolbar);
+ Object.keys(pluginConfig(pluginProps).config).forEach(key => {
+ expect(output.init[key]).toEqual(pluginConfig(pluginProps).config[key]);
+ });
+ });
+ });
+
+ describe('expandable text area plugins and toolbar', () => {
+ test('It configures plugins, toolbars, and quick toolbars correctly', () => {
+ const pluginProps = {
+ isLibrary: props.isLibrary,
+ editorType: 'expandable',
+ placeholder: 'soMEtExT',
+ };
+ output = module.editorConfig({
+ ...props,
+ editorType: 'expandable',
+ placeholder: 'soMEtExT',
+ });
+ expect(output.init.plugins).toEqual(pluginConfig(pluginProps).plugins);
+ expect(output.init.imagetools_toolbar).toEqual(pluginConfig(pluginProps).imageToolbar);
+ expect(output.init.toolbar).toEqual(pluginConfig(pluginProps).toolbar);
+ expect(output.init.quickbars_insert_toolbar).toEqual(pluginConfig(pluginProps).quickbarsInsertToolbar);
+ expect(output.init.quickbars_selection_toolbar).toEqual(pluginConfig(pluginProps).quickbarsSelectionToolbar);
+ Object.keys(pluginConfig(pluginProps).config).forEach(key => {
+ expect(output.init[key]).toEqual(pluginConfig(pluginProps).config[key]);
+ });
+ });
+ });
+ test('It creates an onInit which calls initializeEditor and setEditorRef', () => {
+ output.onInit(evt, editor);
+ expect(props.setEditorRef).toHaveBeenCalledWith(editor);
+ expect(props.initializeEditor).toHaveBeenCalled();
+ });
+ test('It sets the blockvalue to be empty string by default', () => {
+ expect(output.initialValue).toBe('');
+ });
+ test('It sets the blockvalue to be the blockvalue if nonempty', () => {
+ const editorContentHtml = 'SomE hTML content';
+ output = module.editorConfig({ ...props, editorContentHtml });
+ expect(output.initialValue).toBe(editorContentHtml);
+ });
+
+ it('calls setupCustomBehavior on setup', () => {
+ expect(output.init.setup).toEqual(
+ setupCustomBehavior({
+ editorType: props.editorType,
+ updateContent: props.updateContent,
+ openImgModal: props.openImgModal,
+ openSourceCodeModal: props.openSourceCodeModal,
+ setImage: props.setSelection,
+ images: mockImagesRef,
+ lmsEndpointUrl: props.lmsEndpointUrl,
+ learningContextId: props.learningContextId,
+ }),
+ );
+ });
+ });
+
+ describe('imgModalToggle', () => {
+ const hookKey = state.keys.isImageModalOpen;
+ beforeEach(() => {
+ hook = module.imgModalToggle();
+ });
+ test('isOpen: state value', () => {
+ expect(hook.isImgOpen).toEqual(state.stateVals[hookKey]);
+ });
+ test('openModal: calls setter with true', () => {
+ hook.openImgModal();
+ expect(state.setState[hookKey]).toHaveBeenCalledWith(true);
+ });
+ test('closeModal: calls setter with false', () => {
+ hook.closeImgModal();
+ expect(state.setState[hookKey]).toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe('sourceCodeModalToggle', () => {
+ const editorRef = { current: { focus: jest.fn() } };
+ const hookKey = state.keys.isSourceCodeModalOpen;
+ beforeEach(() => {
+ hook = module.sourceCodeModalToggle(editorRef);
+ });
+ test('isOpen: state value', () => {
+ expect(hook.isSourceCodeOpen).toEqual(state.stateVals[hookKey]);
+ });
+ test('openModal: calls setter with true', () => {
+ hook.openSourceCodeModal();
+ expect(state.setState[hookKey]).toHaveBeenCalledWith(true);
+ });
+ test('closeModal: calls setter with false', () => {
+ hook.closeSourceCodeModal();
+ expect(state.setState[hookKey]).toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe('openModalWithSelectedImage', () => {
+ 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: 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(() => {
+ hook = module.selectedImage(val);
+ });
+ test('selection: state value', () => {
+ expect(hook.selection).toEqual(state.stateVals[state.keys.imageSelection]);
+ });
+ test('setSelection: setter for value', () => {
+ expect(hook.setSelection).toEqual(state.setState[state.keys.imageSelection]);
+ });
+ test('clearSelection: calls setter with null', () => {
+ expect(hook.setSelection).not.toHaveBeenCalled();
+ hook.clearSelection();
+ 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 };
+ module.addImagesAndDimensionsToRef(
+ {
+ imagesRef,
+ images: mockImages,
+ 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);
+ });
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/TinyMceWidget/index.jsx b/src/editors/sharedComponents/TinyMceWidget/index.jsx
new file mode 100644
index 0000000000..601af1bf87
--- /dev/null
+++ b/src/editors/sharedComponents/TinyMceWidget/index.jsx
@@ -0,0 +1,143 @@
+import React from 'react';
+import { Provider, connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { Editor } from '@tinymce/tinymce-react';
+
+import 'tinymce';
+import 'tinymce/themes/silver';
+import 'tinymce/skins/ui/oxide/skin.css';
+import 'tinymce/icons/default';
+import 'frontend-components-tinymce-advanced-plugins';
+
+import store from '../../data/store';
+import { selectors } from '../../data/redux';
+import ImageUploadModal from '../ImageUploadModal';
+import SourceCodeModal from '../SourceCodeModal';
+import * as hooks from './hooks';
+import './customTinyMcePlugins/embedIframePlugin';
+
+export { prepareEditorRef } from './hooks';
+
+const editorConfigDefaultProps = {
+ setEditorRef: undefined,
+ placeholder: undefined,
+ initializeEditor: undefined,
+ updateContent: undefined,
+ content: undefined,
+ minHeight: undefined,
+};
+
+const editorConfigPropTypes = {
+ setEditorRef: PropTypes.func,
+ placeholder: PropTypes.any,
+ initializeEditor: PropTypes.func,
+ updateContent: PropTypes.func,
+ content: PropTypes.any,
+ minHeight: PropTypes.any,
+};
+
+const TinyMceWidget = ({
+ editorType,
+ editorRef,
+ disabled,
+ id,
+ editorContentHtml, // editorContent in html form
+ // redux
+ learningContextId,
+ images,
+ isLibrary,
+ lmsEndpointUrl,
+ studioEndpointUrl,
+ onChange,
+ ...editorConfig
+}) => {
+ const { isImgOpen, openImgModal, closeImgModal } = hooks.imgModalToggle();
+ const { isSourceCodeOpen, openSourceCodeModal, closeSourceCodeModal } = hooks.sourceCodeModalToggle(editorRef);
+ const { imagesRef } = hooks.useImages({ images, editorContentHtml });
+
+ const imageSelection = hooks.selectedImage(null);
+
+ return (
+
+ {isLibrary ? null : (
+
+ )}
+ {editorType === 'text' ? (
+
+ ) : null}
+
+
+ );
+};
+TinyMceWidget.defaultProps = {
+ isLibrary: null,
+ editorType: null,
+ editorRef: null,
+ lmsEndpointUrl: null,
+ studioEndpointUrl: null,
+ images: null,
+ id: null,
+ disabled: false,
+ editorContentHtml: undefined,
+ updateContent: undefined,
+ onChange: () => ({}),
+ ...editorConfigDefaultProps,
+};
+TinyMceWidget.propTypes = {
+ learningContextId: PropTypes.string,
+ editorType: PropTypes.string,
+ isLibrary: PropTypes.bool,
+ images: PropTypes.shape({}),
+ editorRef: PropTypes.shape({}),
+ lmsEndpointUrl: PropTypes.string,
+ studioEndpointUrl: PropTypes.string,
+ id: PropTypes.string,
+ disabled: PropTypes.bool,
+ editorContentHtml: PropTypes.string,
+ updateContent: PropTypes.func,
+ onChange: PropTypes.func,
+ ...editorConfigPropTypes,
+};
+
+export const mapStateToProps = (state) => ({
+ images: selectors.app.images(state),
+ lmsEndpointUrl: selectors.app.lmsEndpointUrl(state),
+ studioEndpointUrl: selectors.app.studioEndpointUrl(state),
+ isLibrary: selectors.app.isLibrary(state),
+ learningContextId: selectors.app.learningContextId(state),
+});
+
+export const TinyMceWidgetInternal = TinyMceWidget; // For testing only
+export default (connect(mapStateToProps)(TinyMceWidget));
diff --git a/src/editors/sharedComponents/TinyMceWidget/index.test.jsx b/src/editors/sharedComponents/TinyMceWidget/index.test.jsx
new file mode 100644
index 0000000000..c0064c2d2b
--- /dev/null
+++ b/src/editors/sharedComponents/TinyMceWidget/index.test.jsx
@@ -0,0 +1,134 @@
+import React from 'react';
+import { shallow } from '@edx/react-unit-test-utils';
+import { selectors } from '../../data/redux';
+import SourceCodeModal from '../SourceCodeModal';
+import ImageUploadModal from '../ImageUploadModal';
+import { imgModalToggle, sourceCodeModalToggle } from './hooks';
+import { TinyMceWidgetInternal as 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', () => {
+ const originalModule = jest.requireActual('@tinymce/tinymce-react');
+ return {
+ __esModule: true,
+ ...originalModule,
+ Editor: () => 'TiNYmCE EDitOR',
+ };
+});
+
+jest.mock('../ImageUploadModal', () => 'ImageUploadModal');
+jest.mock('../SourceCodeModal', () => 'SourceCodeModal');
+
+jest.mock('../../data/redux', () => ({
+ __esModule: true,
+ default: jest.fn(),
+ selectors: {
+ app: {
+ lmsEndpointUrl: jest.fn(state => ({ lmsEndpointUrl: state })),
+ studioEndpointUrl: jest.fn(state => ({ studioEndpointUrl: state })),
+ isLibrary: jest.fn(state => ({ isLibrary: state })),
+ images: jest.fn(state => ({ images: state })),
+ learningContextId: jest.fn(state => ({ learningContextId: state })),
+ },
+ },
+}));
+
+jest.mock('./hooks', () => ({
+ editorConfig: jest.fn(args => ({ editorConfig: args })),
+ imgModalToggle: jest.fn(() => ({
+ isImgOpen: true,
+ openImgModal: jest.fn().mockName('openModal'),
+ closeImgModal: jest.fn().mockName('closeModal'),
+ })),
+ sourceCodeModalToggle: jest.fn(() => ({
+ isSourceCodeOpen: true,
+ openSourceCodeModal: jest.fn().mockName('openModal'),
+ closeSourceCodeModal: jest.fn().mockName('closeModal'),
+ })),
+ selectedImage: jest.fn(() => ({
+ selection: 'hooks.selectedImage.selection',
+ setSelection: jest.fn().mockName('hooks.selectedImage.setSelection'),
+ clearSelection: jest.fn().mockName('hooks.selectedImage.clearSelection'),
+ })),
+ useImages: jest.fn(() => ({ imagesRef: { current: [{ externalUrl: staticUrl }] } })),
+}));
+
+jest.mock('react-redux', () => ({
+ Provider: 'Provider',
+ connect: (mapStateToProp, mapDispatchToProps) => (component) => ({
+ mapStateToProp,
+ mapDispatchToProps,
+ component,
+ }),
+}));
+
+describe('TinyMceWidget', () => {
+ const props = {
+ editorType: 'text',
+ editorRef: { current: { value: 'something' } },
+ isLibrary: false,
+ images: { sOmEaSsET: { staTICUrl: staticUrl } },
+ lmsEndpointUrl: 'sOmEvaLue.cOm',
+ studioEndpointUrl: 'sOmEoThERvaLue.cOm',
+ disabled: false,
+ id: 'sOMeiD',
+ updateContent: () => ({}),
+ learningContextId: 'course+org+run',
+ };
+ describe('snapshots', () => {
+ imgModalToggle.mockReturnValue({
+ isImgOpen: false,
+ openImgModal: jest.fn().mockName('modal.openModal'),
+ closeImgModal: jest.fn().mockName('modal.closeModal'),
+ });
+ sourceCodeModalToggle.mockReturnValue({
+ isSourceCodeOpen: false,
+ openSourceCodeModal: jest.fn().mockName('modal.openModal'),
+ closeSourceCodeModal: jest.fn().mockName('modal.closeModal'),
+ });
+ test('renders as expected with default behavior', () => {
+ expect(shallow( ).snapshot).toMatchSnapshot();
+ });
+ test('SourcecodeModal is not rendered', () => {
+ const wrapper = shallow( );
+ expect(wrapper.snapshot).toMatchSnapshot();
+ expect(wrapper.instance.findByType(SourceCodeModal).length).toBe(0);
+ });
+ test('ImageUploadModal is not rendered', () => {
+ const wrapper = shallow( );
+ expect(wrapper.snapshot).toMatchSnapshot();
+ expect(wrapper.instance.findByType(ImageUploadModal).length).toBe(0);
+ });
+ });
+ describe('mapStateToProps', () => {
+ const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
+ test('lmsEndpointUrl from app.lmsEndpointUrl', () => {
+ expect(
+ mapStateToProps(testState).lmsEndpointUrl,
+ ).toEqual(selectors.app.lmsEndpointUrl(testState));
+ });
+ test('studioEndpointUrl from app.studioEndpointUrl', () => {
+ expect(
+ mapStateToProps(testState).studioEndpointUrl,
+ ).toEqual(selectors.app.studioEndpointUrl(testState));
+ });
+ test('images from app.images', () => {
+ expect(
+ mapStateToProps(testState).images,
+ ).toEqual(selectors.app.images(testState));
+ });
+ test('isLibrary from app.isLibrary', () => {
+ expect(
+ mapStateToProps(testState).isLibrary,
+ ).toEqual(selectors.app.isLibrary(testState));
+ });
+ test('learningContextId from app.learningContextId', () => {
+ expect(
+ mapStateToProps(testState).learningContextId,
+ ).toEqual(selectors.app.learningContextId(testState));
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js
new file mode 100644
index 0000000000..7d7120dc25
--- /dev/null
+++ b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js
@@ -0,0 +1,112 @@
+import { StrictDict } from '../../utils';
+import { buttons, plugins } from '../../data/constants/tinyMCE';
+
+const mapToolbars = toolbars => toolbars.map(toolbar => toolbar.join(' ')).join(' | ');
+
+const pluginConfig = ({ isLibrary, placeholder, editorType }) => {
+ const image = isLibrary ? '' : plugins.image;
+ const imageTools = isLibrary ? '' : plugins.imagetools;
+ const imageUploadButton = isLibrary ? '' : buttons.imageUploadButton;
+ const editImageSettings = isLibrary ? '' : buttons.editImageSettings;
+ const codePlugin = editorType === 'text' ? plugins.code : '';
+ const codeButton = editorType === 'text' ? buttons.code : '';
+ const labelButton = editorType === 'question' ? buttons.customLabelButton : '';
+ const quickToolbar = editorType === 'expandable' ? plugins.quickbars : '';
+ const inline = editorType === 'expandable';
+ const toolbar = editorType !== 'expandable';
+ const defaultFormat = (editorType === 'question' || editorType === 'expandable') ? 'div' : 'p';
+ const hasStudioHeader = document.querySelector('.studio-header');
+
+ return (
+ StrictDict({
+ plugins: [
+ plugins.link,
+ plugins.lists,
+ plugins.codesample,
+ plugins.emoticons,
+ plugins.table,
+ plugins.hr,
+ plugins.charmap,
+ codePlugin,
+ plugins.autoresize,
+ image,
+ imageTools,
+ quickToolbar,
+ plugins.a11ychecker,
+ plugins.powerpaste,
+ plugins.embediframe,
+ ].join(' '),
+ menubar: false,
+ toolbar: toolbar ? mapToolbars([
+ [buttons.undo, buttons.redo],
+ [buttons.formatSelect],
+ [labelButton],
+ [buttons.bold, buttons.italic, buttons.underline, buttons.foreColor, buttons.backColor],
+ [
+ buttons.align.left,
+ buttons.align.center,
+ buttons.align.right,
+ buttons.align.justify,
+ ],
+ [
+ buttons.bullist,
+ buttons.numlist,
+ buttons.outdent,
+ buttons.indent,
+ ],
+ [imageUploadButton, buttons.link, buttons.unlink, buttons.blockQuote, buttons.codeBlock],
+ [buttons.table, buttons.emoticons, buttons.charmap, buttons.hr],
+ [buttons.removeFormat, codeButton, buttons.a11ycheck, buttons.embediframe],
+ ]) : false,
+ imageToolbar: mapToolbars([
+ // [buttons.rotate.left, buttons.rotate.right],
+ // [buttons.flip.horiz, buttons.flip.vert],
+ [editImageSettings],
+ ]),
+ quickbarsInsertToolbar: toolbar ? false : mapToolbars([
+ [buttons.undo, buttons.redo],
+ [buttons.formatSelect],
+ [buttons.bold, buttons.italic, buttons.underline, buttons.foreColor],
+ [
+ buttons.align.justify,
+ buttons.bullist,
+ buttons.numlist,
+ ],
+ [imageUploadButton, buttons.blockQuote, buttons.codeBlock],
+ [buttons.table, buttons.emoticons, buttons.charmap, buttons.removeFormat, buttons.a11ycheck],
+ ]),
+ quickbarsSelectionToolbar: toolbar ? false : mapToolbars([
+ [buttons.undo, buttons.redo],
+ [buttons.formatSelect],
+ [buttons.bold, buttons.italic, buttons.underline, buttons.foreColor],
+ [
+ buttons.align.justify,
+ buttons.bullist,
+ buttons.numlist,
+ ],
+ [imageUploadButton, buttons.blockQuote, buttons.codeBlock],
+ [buttons.table, buttons.emoticons, buttons.charmap, buttons.removeFormat, buttons.a11ycheck],
+ ]),
+ config: {
+ branding: false,
+ height: '100%',
+ menubar: false,
+ toolbar_mode: 'sliding',
+ toolbar_sticky: true,
+ toolbar_sticky_offset: hasStudioHeader ? 0 : 76,
+ relative_urls: true,
+ convert_urls: false,
+ placeholder,
+ inline,
+ block_formats: 'Header 1=h1;Header 2=h2;Header 3=h3;Header 4=h4;Header 5=h5;Header 6=h6;Div=div;Paragraph=p;Preformatted=pre',
+ forced_root_block: defaultFormat,
+ powerpaste_allow_local_images: true,
+ powerpaste_word_import: 'prompt',
+ powerpaste_html_import: 'prompt',
+ powerpaste_googledoc_import: 'prompt',
+ },
+ })
+ );
+};
+
+export default pluginConfig;
diff --git a/src/editors/sharedComponents/TinyMceWidget/utils.js b/src/editors/sharedComponents/TinyMceWidget/utils.js
new file mode 100644
index 0000000000..a7b1fc0017
--- /dev/null
+++ b/src/editors/sharedComponents/TinyMceWidget/utils.js
@@ -0,0 +1,28 @@
+const getLocatorSafeName = ({ displayName }) => {
+ const locatorSafeName = displayName.replace(/[^\w.%-]/gm, '');
+ return locatorSafeName;
+};
+
+export const getStaticUrl = ({ displayName }) => (`/static/${getLocatorSafeName({ displayName })}`);
+
+export const getRelativeUrl = ({ courseId, displayName }) => {
+ if (displayName) {
+ const assetCourseId = courseId.replace('course', 'asset');
+ const assetPathShell = `/${assetCourseId}+type@asset+block@`;
+ return `${assetPathShell}${displayName}`;
+ }
+ return '';
+};
+
+export const parseAssetName = (relativeUrl) => {
+ let assetName = '';
+ if (relativeUrl.match(/\/asset-v1:\S+[+]\S+[@]\S+[+]\S+\/\w/)?.length >= 1) {
+ const assetBlockName = relativeUrl.substring(0, relativeUrl.search(/("|")/));
+ const dividedSrc = assetBlockName.split(/\/asset-v1:\S+[+]\S+[@]\S+[+]\S+\//);
+ [, assetName] = dividedSrc;
+ } else {
+ const assetBlockName = relativeUrl.substring(relativeUrl.indexOf('@') + 1, relativeUrl.search(/("|")/));
+ assetName = assetBlockName.substring(assetBlockName.indexOf('@') + 1);
+ }
+ return assetName;
+};
diff --git a/src/editors/sharedComponents/TypeaheadDropdown/FormGroup.jsx b/src/editors/sharedComponents/TypeaheadDropdown/FormGroup.jsx
new file mode 100644
index 0000000000..f0739e1e4b
--- /dev/null
+++ b/src/editors/sharedComponents/TypeaheadDropdown/FormGroup.jsx
@@ -0,0 +1,99 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import _ from 'lodash';
+import { Form } from '@openedx/paragon';
+
+const FormGroup = (props) => {
+ const handleFocus = (e) => {
+ if (props.handleFocus) { props.handleFocus(e); }
+ };
+ const handleClick = (e) => {
+ if (props.handleClick) { props.handleClick(e); }
+ };
+ const handleOnBlur = (e) => {
+ if (!e.currentTarget.contains(e.relatedTarget)) {
+ if (props.handleBlur) { props.handleBlur(e); }
+ }
+ };
+ return (
+
+
+ {props.options ? props.options() : null}
+
+
+ {props.children}
+
+ {props.helpText && _.isEmpty(props.errorMessage) && (
+
+ {props.helpText}
+
+ )}
+
+ {!_.isEmpty(props.errorMessage) && (
+
+ {props.errorMessage}
+
+ )}
+
+ );
+};
+
+FormGroup.defaultProps = {
+ as: 'input',
+ errorMessage: '',
+ autoComplete: null,
+ readOnly: false,
+ handleBlur: null,
+ handleChange: () => {},
+ handleFocus: null,
+ handleClick: null,
+ helpText: '',
+ placeholder: '',
+ options: null,
+ trailingElement: null,
+ type: 'text',
+ children: null,
+ className: '',
+ controlClassName: '',
+};
+
+FormGroup.propTypes = {
+ as: PropTypes.string,
+ errorMessage: PropTypes.string,
+ autoComplete: PropTypes.string,
+ readOnly: PropTypes.bool,
+ floatingLabel: PropTypes.string.isRequired,
+ handleBlur: PropTypes.func,
+ handleChange: PropTypes.func,
+ handleFocus: PropTypes.func,
+ handleClick: PropTypes.func,
+ helpText: PropTypes.string,
+ placeholder: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ options: PropTypes.func,
+ trailingElement: PropTypes.element,
+ type: PropTypes.string,
+ value: PropTypes.string.isRequired,
+ children: PropTypes.element,
+ className: PropTypes.string,
+ controlClassName: PropTypes.string,
+};
+
+export default FormGroup;
diff --git a/src/editors/sharedComponents/TypeaheadDropdown/FormGroup.test.jsx b/src/editors/sharedComponents/TypeaheadDropdown/FormGroup.test.jsx
new file mode 100644
index 0000000000..7c24ad03e5
--- /dev/null
+++ b/src/editors/sharedComponents/TypeaheadDropdown/FormGroup.test.jsx
@@ -0,0 +1,83 @@
+import {
+ fireEvent,
+ render,
+ screen,
+} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom/extend-expect';
+import FormGroup from './FormGroup';
+
+jest.unmock('@openedx/paragon');
+jest.unmock('@openedx/paragon/icons');
+
+const mockHandleChange = jest.fn();
+const mockHandleFocus = jest.fn();
+const mockHandleClick = jest.fn();
+const mockHandleBlur = jest.fn();
+const defaultProps = {
+ as: 'input',
+ errorMessage: '',
+ borderClass: '',
+ autoComplete: null,
+ readOnly: false,
+ handleBlur: mockHandleBlur,
+ handleChange: mockHandleChange,
+ handleFocus: mockHandleFocus,
+ handleClick: mockHandleClick,
+ helpText: 'helpText text',
+ options: null,
+ trailingElement: null,
+ type: 'text',
+ children: null,
+ className: '',
+ floatingLabel: 'floatingLabel text',
+ name: 'title',
+ value: '',
+};
+
+const renderComponent = (props) => render( );
+
+describe('FormGroup', () => {
+ it('renders component without error', () => {
+ renderComponent(defaultProps);
+ expect(screen.getByText(defaultProps.floatingLabel)).toBeVisible();
+ expect(screen.getByText(defaultProps.helpText)).toBeVisible();
+ expect(screen.queryByTestId('errorMessage')).toBeNull();
+ });
+ it('renders component with error', () => {
+ const newProps = {
+ ...defaultProps,
+ errorMessage: 'error message',
+ };
+ renderComponent(newProps);
+ expect(screen.getByText(defaultProps.floatingLabel)).toBeVisible();
+ expect(screen.getByText(newProps.errorMessage)).toBeVisible();
+ expect(screen.queryByText(defaultProps.helpText)).toBeNull();
+ });
+ it('handles element focus', async () => {
+ renderComponent(defaultProps);
+ const formInput = screen.getByTestId('formControl');
+ fireEvent.focus(formInput);
+ expect(mockHandleFocus).toHaveBeenCalled();
+ });
+ it('handles element blur', () => {
+ renderComponent(defaultProps);
+ const formInput = screen.getByTestId('formControl');
+ fireEvent.focus(formInput);
+ fireEvent.focusOut(formInput);
+ expect(mockHandleBlur).toHaveBeenCalled();
+ });
+ it('handles element click', () => {
+ renderComponent(defaultProps);
+ const formInput = screen.getByTestId('formControl');
+ fireEvent.click(formInput);
+ expect(mockHandleClick).toHaveBeenCalled();
+ });
+ it('handles element change', () => {
+ renderComponent(defaultProps);
+ const formInput = screen.getByTestId('formControl');
+ fireEvent.focus(formInput);
+ userEvent.type(formInput, 'opt1');
+ expect(mockHandleChange).toHaveBeenCalled();
+ });
+});
diff --git a/src/editors/sharedComponents/TypeaheadDropdown/index.jsx b/src/editors/sharedComponents/TypeaheadDropdown/index.jsx
new file mode 100644
index 0000000000..23e0ea1a77
--- /dev/null
+++ b/src/editors/sharedComponents/TypeaheadDropdown/index.jsx
@@ -0,0 +1,271 @@
+import React from 'react';
+import {
+ Icon,
+ IconButton,
+ Button,
+ ActionRow,
+} from '@openedx/paragon';
+import { Add, ExpandLess, ExpandMore } from '@openedx/paragon/icons';
+import PropTypes from 'prop-types';
+import { sortBy } from 'lodash';
+// eslint-disable-next-line import/no-unresolved
+import onClickOutside from 'react-onclickoutside';
+import FormGroup from './FormGroup';
+
+class TypeaheadDropdown extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isFocused: false,
+ displayValue: '',
+ icon: this.expandMoreButton(),
+ dropDownItems: [],
+ };
+
+ this.handleFocus = this.handleFocus.bind(this);
+ this.handleOnBlur = this.handleOnBlur.bind(this);
+ }
+
+ shouldComponentUpdate(nextProps) {
+ if (this.props.value !== nextProps.value && nextProps.value !== '') {
+ const opt = this.props.options.find((o) => o === nextProps.value);
+ if (opt && opt !== this.state.displayValue) {
+ this.setState({ displayValue: opt });
+ }
+ return false;
+ }
+
+ return true;
+ }
+
+ // eslint-disable-next-line react/sort-comp
+ getItems(strToFind = '') {
+ let { options } = this.props;
+
+ if (strToFind.length > 0) {
+ options = options.filter((option) => (option.toLowerCase().includes(strToFind.toLowerCase())));
+ }
+
+ const sortedOptions = sortBy(options, (option) => option.toLowerCase());
+
+ return sortedOptions.map((opt) => {
+ let value = opt;
+ if (value.length > 30) {
+ value = value.substring(0, 30).concat('...');
+ }
+
+ return (
+ { this.handleItemClick(e); }}
+ >
+ {value}
+
+ );
+ });
+ }
+
+ setValue(value) {
+ if (this.props.value === value) {
+ return;
+ }
+
+ if (this.props.handleChange) {
+ this.props.handleChange(value);
+ }
+
+ const opt = this.props.options.find((o) => o === value);
+ if (opt && opt !== this.state.displayValue) {
+ this.setState({ displayValue: opt });
+ }
+ }
+
+ setDisplayValue(value) {
+ const normalized = value.toLowerCase();
+ const opt = this.props.options.find((o) => o.toLowerCase() === normalized);
+ if (opt) {
+ this.setValue(opt);
+ this.setState({ displayValue: opt });
+ } else {
+ this.setValue('');
+ this.setState({ displayValue: value });
+ }
+ }
+
+ handleClick = (e) => {
+ const dropDownItems = this.getItems(e.target.value);
+ if (dropDownItems.length > 1) {
+ this.setState({ dropDownItems, icon: this.expandLessButton() });
+ }
+
+ if (this.state.dropDownItems.length > 0) {
+ this.setState({ dropDownItems: '', icon: this.expandMoreButton() });
+ }
+ };
+
+ handleOnChange = (e) => {
+ const findstr = e.target.value;
+
+ if (findstr.length) {
+ const filteredItems = this.getItems(findstr);
+ this.setState({ dropDownItems: filteredItems, icon: this.expandLessButton() });
+ } else {
+ this.setState({ dropDownItems: '', icon: this.expandMoreButton() });
+ }
+
+ this.setDisplayValue(e.target.value);
+ };
+
+ // eslint-disable-next-line react/no-unused-class-component-methods
+ handleClickOutside = () => {
+ if (this.state.dropDownItems.length > 0) {
+ this.setState(() => ({
+ icon: this.expandMoreButton(),
+ dropDownItems: '',
+ }));
+ }
+ };
+
+ handleExpandLess() {
+ this.setState({ dropDownItems: '', icon: this.expandMoreButton() });
+ }
+
+ handleExpandMore(e) {
+ const dropDownItems = this.getItems(e.target.value);
+ this.setState({ dropDownItems, icon: this.expandLessButton() });
+ }
+
+ handleFocus(e) {
+ this.setState({ isFocused: true });
+ if (this.props.handleFocus) { this.props.handleFocus(e); }
+ }
+
+ handleOnBlur(e) {
+ this.setState({ isFocused: false });
+ if (this.props.handleBlur) { this.props.handleBlur(e); }
+ }
+
+ handleItemClick(e) {
+ this.setValue(e.target.value);
+ this.setState({ dropDownItems: '', icon: this.expandMoreButton() });
+ }
+
+ expandMoreButton() {
+ return (
+ { this.handleExpandMore(e); }}
+ />
+ );
+ }
+
+ expandLessButton() {
+ return (
+ { this.handleExpandLess(e); }}
+ />
+ );
+ }
+
+ render() {
+ const noOptionsMessage = (
+
+ {this.props.noOptionsMessage}
+
+ {this.props.allowNewOption && (
+
+ {this.props.newOptionButtonLabel}
+
+ )}
+
+ );
+ const dropDownEmptyList = this.state.dropDownItems && this.state.isFocused ? noOptionsMessage : null;
+ return (
+
+
+
+ { this.state.dropDownItems.length > 0 ? this.state.dropDownItems : dropDownEmptyList }
+
+
+
+ );
+ }
+}
+
+TypeaheadDropdown.defaultProps = {
+ options: null,
+ floatingLabel: null,
+ handleFocus: null,
+ handleChange: null,
+ handleBlur: null,
+ helpMessage: '',
+ placeholder: '',
+ value: null,
+ errorMessage: null,
+ readOnly: false,
+ controlClassName: '',
+ allowNewOption: false,
+ newOptionButtonLabel: '',
+ addNewOption: null,
+};
+
+TypeaheadDropdown.propTypes = {
+ noOptionsMessage: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ options: PropTypes.arrayOf(PropTypes.string),
+ floatingLabel: PropTypes.string,
+ handleFocus: PropTypes.func,
+ handleChange: PropTypes.func,
+ handleBlur: PropTypes.func,
+ helpMessage: PropTypes.string,
+ placeholder: PropTypes.string,
+ value: PropTypes.string,
+ errorMessage: PropTypes.string,
+ readOnly: PropTypes.bool,
+ controlClassName: PropTypes.string,
+ allowNewOption: PropTypes.bool,
+ newOptionButtonLabel: PropTypes.string,
+ addNewOption: PropTypes.func,
+};
+
+export default onClickOutside(TypeaheadDropdown);
diff --git a/src/editors/sharedComponents/TypeaheadDropdown/index.test.jsx b/src/editors/sharedComponents/TypeaheadDropdown/index.test.jsx
new file mode 100644
index 0000000000..8c133c8685
--- /dev/null
+++ b/src/editors/sharedComponents/TypeaheadDropdown/index.test.jsx
@@ -0,0 +1,137 @@
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom/extend-expect';
+import TypeaheadDropdown from '.';
+
+jest.unmock('@openedx/paragon');
+jest.unmock('@openedx/paragon/icons');
+
+const defaultProps = {
+ as: 'input',
+ name: 'OrganizationDropdown',
+ floatingLabel: 'floatingLabel text',
+ options: null,
+ handleFocus: null,
+ handleChange: null,
+ handleBlur: null,
+ value: null,
+ errorMessage: null,
+ errorCode: null,
+ readOnly: false,
+ noOptionsMessage: 'No options',
+};
+const renderComponent = (props) => render( );
+
+describe('common/OrganizationDropdown.jsx', () => {
+ it('renders component without error', () => {
+ renderComponent(defaultProps);
+ expect(screen.getByText(defaultProps.floatingLabel)).toBeVisible();
+ });
+ it('handles element focus', () => {
+ const mockHandleFocus = jest.fn();
+ const newProps = { ...defaultProps, handleFocus: mockHandleFocus };
+ renderComponent(newProps);
+ const formInput = screen.getByTestId('formControl');
+ fireEvent.focus(formInput);
+ expect(mockHandleFocus).toHaveBeenCalled();
+ });
+ it('handles element blur', () => {
+ const mockHandleBlur = jest.fn();
+ const newProps = { ...defaultProps, handleBlur: mockHandleBlur };
+ renderComponent(newProps);
+ const formInput = screen.getByTestId('formControl');
+ fireEvent.focus(formInput);
+ fireEvent.focusOut(formInput);
+ expect(mockHandleBlur).toHaveBeenCalled();
+ });
+ it('renders component with options', async () => {
+ const newProps = { ...defaultProps, options: ['opt2', 'opt1'] };
+ renderComponent(newProps);
+ const formInput = screen.getByTestId('formControl');
+ await waitFor(() => fireEvent.click(formInput));
+ const optionsList = within(screen.getByTestId('dropdown-container')).getAllByRole('button');
+ expect(optionsList.length).toEqual(newProps.options.length);
+ });
+ it('selects option', () => {
+ const newProps = { ...defaultProps, options: ['opt1', 'opt2'] };
+ renderComponent(newProps);
+ const formInput = screen.getByTestId('formControl');
+ fireEvent.click(formInput);
+ const optionsList = within(screen.getByTestId('dropdown-container')).getAllByRole('button');
+ fireEvent.click(optionsList.at([0]));
+ expect(formInput.value).toEqual(newProps.options[0]);
+ });
+ it('toggles options list', async () => {
+ const newProps = { ...defaultProps, options: ['opt1', 'opt2'] };
+ renderComponent(newProps);
+ const optionsList = within(screen.getByTestId('dropdown-container')).queryAllByRole('button');
+ expect(optionsList.length).toEqual(0);
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('expand-more-button'));
+ });
+ expect(within(screen.getByTestId('dropdown-container'))
+ .queryAllByRole('button').length).toEqual(newProps.options.length);
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('expand-less-button'));
+ });
+ expect(within(screen.getByTestId('dropdown-container'))
+ .queryAllByRole('button').length).toEqual(0);
+ });
+ it('shows options list depends on field value', () => {
+ const newProps = { ...defaultProps, options: ['opt1', 'opt2'] };
+ renderComponent(newProps);
+ const formInput = screen.getByTestId('formControl');
+ fireEvent.focus(formInput);
+ userEvent.type(formInput, 'opt1');
+ expect(within(screen.getByTestId('dropdown-container'))
+ .queryAllByRole('button').length).toEqual(1);
+ });
+ it('closes options list on click outside', async () => {
+ const newProps = { ...defaultProps, options: ['opt1', 'opt2'] };
+ renderComponent(newProps);
+ const formInput = screen.getByTestId('formControl');
+ fireEvent.click(formInput);
+ expect(within(screen.getByTestId('dropdown-container'))
+ .queryAllByRole('button').length).toEqual(2);
+ userEvent.click(document.body);
+ expect(within(screen.getByTestId('dropdown-container'))
+ .queryAllByRole('button').length).toEqual(0);
+ });
+ describe('empty options list', () => {
+ it('shows empty options list depends on field value', () => {
+ const newProps = { ...defaultProps, options: ['opt1', 'opt2'] };
+ renderComponent(newProps);
+ const formInput = screen.getByTestId('formControl');
+ fireEvent.focus(formInput);
+ userEvent.type(formInput, '3');
+ const noOptionsList = within(screen.getByTestId('dropdown-container')).getByText('No options');
+ const addButton = within(screen.getByTestId('dropdown-container')).queryByTestId('add-option-button');
+ expect(noOptionsList).toBeVisible();
+ expect(addButton).toBeNull();
+ });
+ it('shows empty options list with add option button', () => {
+ const newProps = {
+ ...defaultProps,
+ options: ['opt1', 'opt2'],
+ allowNewOption: true,
+ newOptionButtonLabel: 'Add new option',
+ addNewOption: jest.fn(),
+ };
+ renderComponent(newProps);
+ const formInput = screen.getByTestId('formControl');
+ fireEvent.focus(formInput);
+ userEvent.type(formInput, '3');
+ const noOptionsList = within(screen.getByTestId('dropdown-container')).getByText('No options');
+ expect(noOptionsList).toBeVisible();
+ const addButton = within(screen.getByTestId('dropdown-container')).getByTestId('add-option-button');
+ expect(addButton).toHaveTextContent(newProps.newOptionButtonLabel);
+ });
+ });
+});
diff --git a/src/editors/supportedEditors.js b/src/editors/supportedEditors.js
new file mode 100644
index 0000000000..82d9c568a7
--- /dev/null
+++ b/src/editors/supportedEditors.js
@@ -0,0 +1,20 @@
+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
+
+import { blockTypes } from './data/constants/app';
+
+const supportedEditors = {
+ [blockTypes.html]: TextEditor,
+ [blockTypes.video]: VideoEditor,
+ [blockTypes.problem]: ProblemEditor,
+ [blockTypes.video_upload]: VideoUploadEditor,
+ // ADDED_EDITORS GO BELOW
+ [blockTypes.game]: GameEditor,
+};
+
+export default supportedEditors;
diff --git a/src/editors/testUtils.js b/src/editors/testUtils.js
new file mode 100644
index 0000000000..c7bfe5417f
--- /dev/null
+++ b/src/editors/testUtils.js
@@ -0,0 +1,186 @@
+/* istanbul ignore file */
+import react from 'react';
+import { StrictDict } from './utils';
+/**
+ * Mocked formatMessage provided by react-intl
+ */
+export const formatMessage = (msg, values) => {
+ let message = msg.defaultMessage;
+ if (values === undefined) {
+ return message;
+ }
+ Object.keys(values).forEach((key) => {
+ // eslint-disable-next-line
+ message = message.replace(`{${key}}`, values[key]);
+ });
+ return message;
+};
+
+/**
+ * Mock a single component, or a nested component so that its children render nicely
+ * in snapshots.
+ * @param {string} name - parent component name
+ * @param {obj} contents - object of child components with intended component
+ * render name.
+ * @return {func} - mock component with nested children.
+ *
+ * usage:
+ * mockNestedComponent('Card', { Body: 'Card.Body', Form: { Control: { Feedback: 'Form.Control.Feedback' }}... });
+ * mockNestedComponent('IconButton', 'IconButton');
+ */
+export const mockNestedComponent = (name, contents) => {
+ if (typeof contents !== 'object') {
+ return contents;
+ }
+ const fn = () => name;
+ Object.defineProperty(fn, 'name', { value: name });
+ Object.keys(contents).forEach((nestedName) => {
+ const value = contents[nestedName];
+ fn[nestedName] = typeof value !== 'object'
+ ? value
+ : mockNestedComponent(`${name}.${nestedName}`, value);
+ });
+ return fn;
+};
+
+/**
+ * Mock a module of components. nested components will be rendered nicely in snapshots.
+ * @param {obj} mapping - component module mock config.
+ * @return {obj} - module of flat and nested components that will render nicely in snapshots.
+ * usage:
+ * mockNestedComponents({
+ * Card: { Body: 'Card.Body' },
+ * IconButton: 'IconButton',
+ * })
+ */
+export const mockNestedComponents = (mapping) => Object.entries(mapping).reduce(
+ (obj, [name, value]) => ({
+ ...obj,
+ [name]: mockNestedComponent(name, value),
+ }),
+ {},
+);
+
+/**
+ * Mock utility for working with useState in a hooks module.
+ * Expects/requires an object containing the state object in order to ensure
+ * the mock behavior works appropriately.
+ *
+ * Expected format:
+ * hooks = { state: { : (val) => React.createRef(val), ... } }
+ *
+ * Returns a utility for mocking useState and providing access to specific state values
+ * and setState methods, as well as allowing per-test configuration of useState value returns.
+ *
+ * Example usage:
+ * // hooks.js
+ * import * as module from './hooks';
+ * const state = {
+ * isOpen: (val) => React.useState(val),
+ * hasDoors: (val) => React.useState(val),
+ * selected: (val) => React.useState(val),
+ * };
+ * ...
+ * export const exampleHook = () => {
+ * const [isOpen, setIsOpen] = module.state.isOpen(false);
+ * if (!isOpen) { return null; }
+ * return { isOpen, setIsOpen };
+ * }
+ * ...
+ *
+ * // hooks.test.js
+ * import * as hooks from './hooks';
+ * const state = new MockUseState(hooks)
+ * ...
+ * describe('state hooks', () => {
+ * state.testGetter(state.keys.isOpen);
+ * state.testGetter(state.keys.hasDoors);
+ * state.testGetter(state.keys.selected);
+ * });
+ * describe('exampleHook', () => {
+ * beforeEach(() => { state.mock(); });
+ * it('returns null if isOpen is default value', () => {
+ * expect(hooks.exampleHook()).toEqual(null);
+ * });
+ * it('returns isOpen and setIsOpen if isOpen is not null', () => {
+ * state.mockVal(state.keys.isOpen, true);
+ * expect(hooks.exampleHook()).toEqual({
+ * isOpen: true,
+ * setIsOpen: state.setState[state.keys.isOpen],
+ * });
+ * });
+ * afterEach(() => { state.restore(); });
+ * });
+ *
+ * @param {obj} hooks - hooks module containing a 'state' object
+ */
+export class MockUseState {
+ constructor(hooks) {
+ this.hooks = hooks;
+ this.oldState = null;
+ this.setState = {};
+ this.stateVals = {};
+
+ this.mock = this.mock.bind(this);
+ this.restore = this.restore.bind(this);
+ this.mockVal = this.mockVal.bind(this);
+ this.testGetter = this.testGetter.bind(this);
+ }
+
+ /**
+ * @return {object} - StrictDict of state object keys
+ */
+ get keys() {
+ return StrictDict(Object.keys(this.hooks.state).reduce(
+ (obj, key) => ({ ...obj, [key]: key }),
+ {},
+ ));
+ }
+
+ /**
+ * Replace the hook module's state object with a mocked version, initialized to default values.
+ */
+ mock() {
+ this.oldState = this.hooks.state;
+ Object.keys(this.keys).forEach(key => {
+ this.hooks.state[key] = jest.fn(val => {
+ this.stateVals[key] = val;
+ return [val, this.setState[key]];
+ });
+ });
+ this.setState = Object.keys(this.keys).reduce(
+ (obj, key) => ({
+ ...obj,
+ [key]: jest.fn(val => {
+ this.hooks.state[key] = val;
+ }),
+ }),
+ {},
+ );
+ }
+
+ /**
+ * Restore the hook module's state object to the actual code.
+ */
+ restore() {
+ this.hooks.state = this.oldState;
+ }
+
+ /**
+ * Mock the state getter associated with a single key to return a specific value one time.
+ * @param {string} key - state key (from this.keys)
+ * @param {any} val - new value to be returned by the useState call.
+ */
+ mockVal(key, val) {
+ this.hooks.state[key].mockReturnValueOnce([val, this.setState[key]]);
+ }
+
+ testGetter(key) {
+ test(`${key} state getter should return useState passthrough`, () => {
+ const testValue = 'some value';
+ const useState = (val) => ({ useState: val });
+ jest.spyOn(react, 'useState').mockImplementationOnce(useState);
+ expect(this.hooks.state[key](testValue)).toEqual(useState(testValue));
+ });
+ }
+}
diff --git a/src/editors/utils/StrictDict.js b/src/editors/utils/StrictDict.js
new file mode 100644
index 0000000000..d73ac811da
--- /dev/null
+++ b/src/editors/utils/StrictDict.js
@@ -0,0 +1,24 @@
+/* eslint-disable no-console */
+const strictGet = (target, name) => {
+ if (name === Symbol.toStringTag) {
+ return target;
+ }
+
+ if (name in target || name === '_reactFragment') {
+ return target[name];
+ }
+
+ if (name === '$$typeof') {
+ return typeof target;
+ }
+
+ console.log(name.toString());
+ console.error({ target, name });
+ const e = Error(`invalid property "${name.toString()}"`);
+ console.error(e.stack);
+ return undefined;
+};
+
+const StrictDict = (dict) => new Proxy(dict, { get: strictGet });
+
+export default StrictDict;
diff --git a/src/editors/utils/StrictDict.test.js b/src/editors/utils/StrictDict.test.js
new file mode 100644
index 0000000000..276f50d2c1
--- /dev/null
+++ b/src/editors/utils/StrictDict.test.js
@@ -0,0 +1,62 @@
+import StrictDict from './StrictDict';
+
+const value1 = 'valUE1';
+const value2 = 'vALue2';
+const key1 = 'Key1';
+const key2 = 'keY2';
+
+jest.spyOn(window, 'Error').mockImplementation(error => ({ stack: error }));
+
+describe('StrictDict', () => {
+ let consoleError;
+ let consoleLog;
+ let windowError;
+ beforeEach(() => {
+ consoleError = window.console.error;
+ consoleLog = window.console.lot;
+ windowError = window.Error;
+ window.console.error = jest.fn();
+ window.console.log = jest.fn();
+ window.Error = jest.fn(error => ({ stack: error }));
+ });
+ afterAll(() => {
+ window.console.error = consoleError;
+ window.console.log = consoleLog;
+ window.Error = windowError;
+ });
+ const rawDict = {
+ [key1]: value1,
+ [key2]: value2,
+ };
+ const dict = StrictDict(rawDict);
+ it('provides key access like a normal dict object', () => {
+ expect(dict[key1]).toEqual(value1);
+ });
+ it('allows key listing', () => {
+ expect(Object.keys(dict)).toEqual([key1, key2]);
+ });
+ it('allows item listing', () => {
+ expect(Object.values(dict)).toEqual([value1, value2]);
+ });
+ it('allows stringification', () => {
+ expect(dict.toString()).toEqual(rawDict.toString());
+ expect({ ...dict }).toEqual({ ...rawDict });
+ });
+ it('allows entry listing', () => {
+ expect(Object.entries(dict)).toEqual(Object.entries(rawDict));
+ });
+ describe('missing key', () => {
+ it('logs error with target, name, and error stack', () => {
+ // eslint-ignore-next-line no-unused-vars
+ const callBadKey = () => dict.fakeKey;
+ callBadKey();
+ expect(window.console.error.mock.calls).toEqual([
+ [{ target: dict, name: 'fakeKey' }],
+ [Error('invalid property "fakeKey"').stack],
+ ]);
+ });
+ it('returns undefined', () => {
+ expect(dict.fakeKey).toEqual(undefined);
+ });
+ });
+});
diff --git a/src/editors/utils/camelizeKeys.js b/src/editors/utils/camelizeKeys.js
new file mode 100644
index 0000000000..9a69870baa
--- /dev/null
+++ b/src/editors/utils/camelizeKeys.js
@@ -0,0 +1,15 @@
+import { camelCase } from 'lodash';
+
+const camelizeKeys = (obj) => {
+ if (Array.isArray(obj)) {
+ return obj.map(v => camelizeKeys(v));
+ }
+ if (obj != null && obj.constructor === Object) {
+ return Object.keys(obj).reduce(
+ (result, key) => ({ ...result, [camelCase(key)]: camelizeKeys(obj[key]) }),
+ {},
+ );
+ }
+ return obj;
+};
+export default camelizeKeys;
diff --git a/src/editors/utils/camelizeKeys.test.js b/src/editors/utils/camelizeKeys.test.js
new file mode 100644
index 0000000000..3bdd209c59
--- /dev/null
+++ b/src/editors/utils/camelizeKeys.test.js
@@ -0,0 +1,32 @@
+import { camelizeKeys } from './index';
+
+const snakeCaseObject = {
+ some_attribute:
+ {
+ another_attribute: [
+ { a_list: 'a lIsT' },
+ { of_attributes: 'iN diFferent' },
+ { different_cases: 'to Test' },
+ ],
+ },
+ a_final_attribute: null,
+ a_last_one: undefined,
+};
+const camelCaseObject = {
+ someAttribute:
+ {
+ anotherAttribute: [
+ { aList: 'a lIsT' },
+ { ofAttributes: 'iN diFferent' },
+ { differentCases: 'to Test' },
+ ],
+ },
+ aFinalAttribute: null,
+ aLastOne: undefined,
+};
+
+describe('camelizeKeys', () => {
+ it('converts keys of objects to be camelCase', () => {
+ expect(camelizeKeys(snakeCaseObject)).toEqual(camelCaseObject);
+ });
+});
diff --git a/src/editors/utils/formatDuration.js b/src/editors/utils/formatDuration.js
new file mode 100644
index 0000000000..8458f51fc2
--- /dev/null
+++ b/src/editors/utils/formatDuration.js
@@ -0,0 +1,18 @@
+import * as moment from 'moment-shortformat';
+
+const formatDuration = (duration) => {
+ const d = moment.duration(duration, 'seconds');
+ if (d.hours() > 0) {
+ return (
+ `${d.hours().toString().padStart(2, '0')}:`
+ + `${d.minutes().toString().padStart(2, '0')}:`
+ + `${d.seconds().toString().padStart(2, '0')}`
+ );
+ }
+ return (
+ `${d.minutes().toString().padStart(2, '0')}:`
+ + `${d.seconds().toString().padStart(2, '0')}`
+ );
+};
+
+export default formatDuration;
diff --git a/src/editors/utils/formatDuration.test.js b/src/editors/utils/formatDuration.test.js
new file mode 100644
index 0000000000..6720130d7c
--- /dev/null
+++ b/src/editors/utils/formatDuration.test.js
@@ -0,0 +1,12 @@
+import formatDuration from './formatDuration';
+
+describe('formatDuration', () => {
+ test.each([
+ [60, '01:00'],
+ [35, '00:35'],
+ [60 * 10 + 15, '10:15'],
+ [60 * 60 + 60 * 15 + 13, '01:15:13'],
+ ])('correct functionality of formatDuration with duration as %p', (duration, expected) => {
+ expect(formatDuration(duration)).toEqual(expected);
+ });
+});
diff --git a/src/editors/utils/index.js b/src/editors/utils/index.js
new file mode 100644
index 0000000000..e314669159
--- /dev/null
+++ b/src/editors/utils/index.js
@@ -0,0 +1,7 @@
+/* eslint-disable import/prefer-default-export */
+export { default as StrictDict } from './StrictDict';
+export { default as keyStore } from './keyStore';
+export { default as camelizeKeys } from './camelizeKeys';
+export { default as removeItemOnce } from './removeOnce';
+export { default as formatDuration } from './formatDuration';
+export { default as snakeCaseKeys } from './snakeCaseKeys';
diff --git a/src/editors/utils/keyStore.js b/src/editors/utils/keyStore.js
new file mode 100644
index 0000000000..a670f436f6
--- /dev/null
+++ b/src/editors/utils/keyStore.js
@@ -0,0 +1,10 @@
+import StrictDict from './StrictDict';
+
+const keyStore = (collection) => StrictDict(
+ Object.keys(collection).reduce(
+ (obj, key) => ({ ...obj, [key]: key }),
+ {},
+ ),
+);
+
+export default keyStore;
diff --git a/src/editors/utils/removeOnce.js b/src/editors/utils/removeOnce.js
new file mode 100644
index 0000000000..739f7dcb1a
--- /dev/null
+++ b/src/editors/utils/removeOnce.js
@@ -0,0 +1,13 @@
+const removeItemOnce = (arr, value) => {
+ // create a deep copy as array.splice doesn't work if the array has been dereferenced.
+ // structuredClone works in node >11, and we are on node 16.
+ // eslint-disable-next-line
+ const deepCopy = structuredClone(arr);
+ const index = deepCopy.indexOf(value);
+ if (index > -1) {
+ deepCopy.splice(index, 1);
+ }
+ return deepCopy;
+};
+
+export default removeItemOnce;
diff --git a/src/editors/utils/snakeCaseKeys.js b/src/editors/utils/snakeCaseKeys.js
new file mode 100644
index 0000000000..f522ce44bd
--- /dev/null
+++ b/src/editors/utils/snakeCaseKeys.js
@@ -0,0 +1,15 @@
+import { snakeCase } from 'lodash';
+
+const snakeCaseKeys = (obj) => {
+ if (Array.isArray(obj)) {
+ return obj.map(v => snakeCaseKeys(v));
+ }
+ if (obj != null && obj.constructor === Object) {
+ return Object.keys(obj).reduce(
+ (result, key) => ({ ...result, [snakeCase(key)]: snakeCaseKeys(obj[key]) }),
+ {},
+ );
+ }
+ return obj;
+};
+export default snakeCaseKeys;
diff --git a/src/editors/utils/snakeCaseKeys.test.js b/src/editors/utils/snakeCaseKeys.test.js
new file mode 100644
index 0000000000..3bdd209c59
--- /dev/null
+++ b/src/editors/utils/snakeCaseKeys.test.js
@@ -0,0 +1,32 @@
+import { camelizeKeys } from './index';
+
+const snakeCaseObject = {
+ some_attribute:
+ {
+ another_attribute: [
+ { a_list: 'a lIsT' },
+ { of_attributes: 'iN diFferent' },
+ { different_cases: 'to Test' },
+ ],
+ },
+ a_final_attribute: null,
+ a_last_one: undefined,
+};
+const camelCaseObject = {
+ someAttribute:
+ {
+ anotherAttribute: [
+ { aList: 'a lIsT' },
+ { ofAttributes: 'iN diFferent' },
+ { differentCases: 'to Test' },
+ ],
+ },
+ aFinalAttribute: null,
+ aLastOne: undefined,
+};
+
+describe('camelizeKeys', () => {
+ it('converts keys of objects to be camelCase', () => {
+ expect(camelizeKeys(snakeCaseObject)).toEqual(camelCaseObject);
+ });
+});
diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx
index 6c3ef01b43..be100bd55b 100644
--- a/src/files-and-videos/files-page/FilesPage.jsx
+++ b/src/files-and-videos/files-page/FilesPage.jsx
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
import { CheckboxFilter, Container } from '@openedx/paragon';
-import Placeholder from '@edx/frontend-lib-content-components';
+import Placeholder from '../../editors/Placeholder';
import { RequestStatus } from '../../data/constants';
import { useModels, useModel } from '../../generic/model-store';
diff --git a/src/files-and-videos/generic/EditFileErrors.jsx b/src/files-and-videos/generic/EditFileErrors.jsx
index 97b5c32ce4..a964fbc9da 100644
--- a/src/files-and-videos/generic/EditFileErrors.jsx
+++ b/src/files-and-videos/generic/EditFileErrors.jsx
@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert } from '@openedx/paragon';
-import { ErrorAlert } from '@edx/frontend-lib-content-components';
+import ErrorAlert from '../../editors/sharedComponents/ErrorAlerts/ErrorAlert';
import { RequestStatus } from '../../data/constants';
import messages from './messages';
diff --git a/src/files-and-videos/generic/table-components/sort-and-filter-modal/SortAndFilterModal.jsx b/src/files-and-videos/generic/table-components/sort-and-filter-modal/SortAndFilterModal.jsx
index fef4b8c01c..b45c29e80b 100644
--- a/src/files-and-videos/generic/table-components/sort-and-filter-modal/SortAndFilterModal.jsx
+++ b/src/files-and-videos/generic/table-components/sort-and-filter-modal/SortAndFilterModal.jsx
@@ -1,8 +1,6 @@
import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
-// SelectableBox in paragon has a bug only visible on stage where you can't change selection. So we override it
-import { SelectableBox } from '@edx/frontend-lib-content-components';
import {
ActionRow,
Button,
@@ -12,6 +10,8 @@ import {
useCheckboxSetValues,
} from '@openedx/paragon';
import messages from './messages';
+// SelectableBox in paragon has a bug only visible on stage where you can't change selection. So we override it
+import SelectableBox from '../../../../editors/sharedComponents/SelectableBox';
import { getCheckedFilters, getFilterOptions, processFilters } from './utils';
const SortAndFilterModal = ({
diff --git a/src/files-and-videos/videos-page/VideosPage.jsx b/src/files-and-videos/videos-page/VideosPage.jsx
index 4f006572b5..bf1f78528c 100644
--- a/src/files-and-videos/videos-page/VideosPage.jsx
+++ b/src/files-and-videos/videos-page/VideosPage.jsx
@@ -14,8 +14,8 @@ import {
Container,
useToggle,
} from '@openedx/paragon';
-import Placeholder from '@edx/frontend-lib-content-components';
+import Placeholder from '../../editors/Placeholder';
import { RequestStatus } from '../../data/constants';
import { useModels, useModel } from '../../generic/model-store';
import {
diff --git a/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.jsx b/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.jsx
index 57bebc1367..6c08860b0d 100644
--- a/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.jsx
+++ b/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.jsx
@@ -2,10 +2,11 @@ import React, { useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { isEmpty } from 'lodash';
-import { ErrorAlert } from '@edx/frontend-lib-content-components';
import { Button, Stack } from '@openedx/paragon';
import { Add } from '@openedx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+
+import ErrorAlert from '../../../editors/sharedComponents/ErrorAlerts/ErrorAlert';
import { getLanguages, getSortedTranscripts } from '../data/utils';
import Transcript from './transcript-item';
import {
diff --git a/src/files-and-videos/videos-page/transcript-settings/OrderTranscriptForm.jsx b/src/files-and-videos/videos-page/transcript-settings/OrderTranscriptForm.jsx
index 76e66f1c7c..28e344e36c 100644
--- a/src/files-and-videos/videos-page/transcript-settings/OrderTranscriptForm.jsx
+++ b/src/files-and-videos/videos-page/transcript-settings/OrderTranscriptForm.jsx
@@ -7,7 +7,8 @@ import {
intlShape,
} from '@edx/frontend-platform/i18n';
import { Button, Stack } from '@openedx/paragon';
-import { ErrorAlert, SelectableBox } from '@edx/frontend-lib-content-components';
+import ErrorAlert from '../../../editors/sharedComponents/ErrorAlerts/ErrorAlert';
+import SelectableBox from '../../../editors/sharedComponents/SelectableBox';
import Cielo24Form from './Cielo24Form';
import ThreePlayMediaForm from './ThreePlayMediaForm';
import { RequestStatus } from '../../../data/constants';
diff --git a/src/generic/WysiwygEditor.jsx b/src/generic/WysiwygEditor.jsx
index b506b74ba5..dd326181cf 100644
--- a/src/generic/WysiwygEditor.jsx
+++ b/src/generic/WysiwygEditor.jsx
@@ -3,10 +3,7 @@ import PropTypes from 'prop-types';
import { connect, Provider, useSelector } from 'react-redux';
import { createStore } from 'redux';
import { getConfig } from '@edx/frontend-platform';
-import {
- prepareEditorRef,
- TinyMceWidget,
-} from '@edx/frontend-lib-content-components';
+import TinyMceWidget, { prepareEditorRef } from '../editors/sharedComponents/TinyMceWidget';
import { DEFAULT_EMPTY_WYSIWYG_VALUE } from '../constants';
diff --git a/src/generic/create-or-rerun-course/CreateOrRerunCourseForm.jsx b/src/generic/create-or-rerun-course/CreateOrRerunCourseForm.jsx
index fc4f48666e..cffbf53f24 100644
--- a/src/generic/create-or-rerun-course/CreateOrRerunCourseForm.jsx
+++ b/src/generic/create-or-rerun-course/CreateOrRerunCourseForm.jsx
@@ -12,7 +12,7 @@ import {
TransitionReplace,
} from '@openedx/paragon';
import { Info as InfoIcon } from '@openedx/paragon/icons';
-import { TypeaheadDropdown } from '@edx/frontend-lib-content-components';
+import TypeaheadDropdown from '../../editors/sharedComponents/TypeaheadDropdown';
import AlertMessage from '../alert-message';
import { STATEFUL_BUTTON_STATES } from '../../constants';
diff --git a/src/pages-and-resources/SettingsComponent.jsx b/src/pages-and-resources/SettingsComponent.jsx
index a45d64db10..bf6d295300 100644
--- a/src/pages-and-resources/SettingsComponent.jsx
+++ b/src/pages-and-resources/SettingsComponent.jsx
@@ -2,8 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import { useParams, useNavigate } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
-import { ErrorAlert } from '@edx/frontend-lib-content-components';
+import ErrorAlert from '../editors/sharedComponents/ErrorAlerts/ErrorAlert';
import messages from './messages';
const PluginLoadFailedError = () => {
diff --git a/src/schedule-and-details/ScheduleAndDetails.test.jsx b/src/schedule-and-details/ScheduleAndDetails.test.jsx
index 72891fda46..0afee52fee 100644
--- a/src/schedule-and-details/ScheduleAndDetails.test.jsx
+++ b/src/schedule-and-details/ScheduleAndDetails.test.jsx
@@ -36,9 +36,9 @@ jest.mock('@tinymce/tinymce-react', () => {
};
});
-// Mock the TinyMceWidget from frontend-lib-content-components
-jest.mock('@edx/frontend-lib-content-components', () => ({
- TinyMceWidget: () => Widget
,
+jest.mock('../editors/sharedComponents/TinyMceWidget', () => ({
+ __esModule: true, // Required to mock a default export
+ default: () => Widget
,
prepareEditorRef: jest.fn(() => ({
refReady: true,
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
diff --git a/src/schedule-and-details/index.jsx b/src/schedule-and-details/index.jsx
index 40fdf1c019..f8d114f44d 100644
--- a/src/schedule-and-details/index.jsx
+++ b/src/schedule-and-details/index.jsx
@@ -10,8 +10,8 @@ import {
Warning as WarningIcon,
} from '@openedx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
-import Placeholder from '@edx/frontend-lib-content-components';
+import Placeholder from '../editors/Placeholder';
import { RequestStatus } from '../data/constants';
import { useModel } from '../generic/model-store';
import AlertMessage from '../generic/alert-message';
diff --git a/src/schedule-and-details/introducing-section/IntroducingSection.test.jsx b/src/schedule-and-details/introducing-section/IntroducingSection.test.jsx
index f0b3feb93e..9b7dd765f2 100644
--- a/src/schedule-and-details/introducing-section/IntroducingSection.test.jsx
+++ b/src/schedule-and-details/introducing-section/IntroducingSection.test.jsx
@@ -19,9 +19,10 @@ jest.mock('@tinymce/tinymce-react', () => {
};
});
-// Mock the TinyMceWidget from frontend-lib-content-components
-jest.mock('@edx/frontend-lib-content-components', () => ({
- TinyMceWidget: () => Widget
,
+// Mock the TinyMceWidget
+jest.mock('../../editors/sharedComponents/TinyMceWidget', () => ({
+ __esModule: true, // Required to mock a default export
+ default: () => Widget
,
prepareEditorRef: jest.fn(() => ({
refReady: true,
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
diff --git a/src/selectors/VideoSelectorContainer.jsx b/src/selectors/VideoSelectorContainer.jsx
index e37ef6ba70..ea83315d5a 100644
--- a/src/selectors/VideoSelectorContainer.jsx
+++ b/src/selectors/VideoSelectorContainer.jsx
@@ -1,8 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useParams } from 'react-router-dom';
-import { VideoSelectorPage } from '@edx/frontend-lib-content-components';
import { getConfig } from '@edx/frontend-platform';
+import VideoSelectorPage from '../editors/VideoSelectorPage';
const VideoSelectorContainer = ({
courseId,
diff --git a/src/selectors/VideoSelectorContainer.test.jsx b/src/selectors/VideoSelectorContainer.test.jsx
index c4064b3e23..f32053f40f 100644
--- a/src/selectors/VideoSelectorContainer.test.jsx
+++ b/src/selectors/VideoSelectorContainer.test.jsx
@@ -2,7 +2,10 @@ import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import VideoSelectorContainer from './VideoSelectorContainer';
-jest.mock('@edx/frontend-lib-content-components', () => ({ VideoSelectorPage: () => 'HeaderTitle' }));
+jest.mock('../editors/VideoSelectorPage', () => ({
+ default: function VideoSelectorPage() { return 'HeaderTitle'; },
+ __esModule: true, // Required to mock a default export
+}));
jest.mock('react-router', () => ({
...jest.requireActual('react-router'), // use actual for all non-hook parts
diff --git a/src/studio-home/organization-section/index.jsx b/src/studio-home/organization-section/index.jsx
index f64a7205cc..c7cd0688cf 100644
--- a/src/studio-home/organization-section/index.jsx
+++ b/src/studio-home/organization-section/index.jsx
@@ -4,8 +4,8 @@ import { useDispatch, useSelector } from 'react-redux';
import { isEmpty } from 'lodash';
import { Button, Form, FormLabel } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
-import { TypeaheadDropdown } from '@edx/frontend-lib-content-components';
+import TypeaheadDropdown from '../../editors/sharedComponents/TypeaheadDropdown';
import { getOrganizations } from '../../generic/data/selectors';
import { fetchOrganizationsQuery } from '../../generic/data/thunks';
import messages from '../messages';