From f3ae225d641ffde833aec2da42aba178e1c349db Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Mon, 17 Jun 2024 09:52:49 -0400 Subject: [PATCH] feat: improve asset loading (#484) * fix: update initialize to only call required functions * feat: update asset urls without asset object * feat: add pagination to select image modal * fix: lint errors * chore: update tests * fix: asset pattern regex match * feat: update pagination to be button to prevent page skipping * fix: e.target.error for feedback fields * fix: failing snapshots --- .../EditProblemView/AnswerWidget/hooks.js | 24 +-- .../__snapshots__/index.test.jsx.snap | 2 +- .../ExplanationWidget/index.jsx | 11 +- .../ExplanationWidget/index.test.jsx | 12 +- .../EditProblemView/QuestionWidget/index.jsx | 11 +- .../QuestionWidget/index.test.jsx | 10 +- .../components/EditProblemView/hooks.js | 5 +- .../components/EditProblemView/index.jsx | 6 - .../containers/ProblemEditor/index.jsx | 13 +- .../containers/ProblemEditor/index.test.jsx | 23 --- .../__snapshots__/index.test.jsx.snap | 28 +--- src/editors/containers/TextEditor/hooks.js | 6 +- .../containers/TextEditor/hooks.test.jsx | 13 +- src/editors/containers/TextEditor/index.jsx | 36 ++--- .../containers/TextEditor/index.test.jsx | 31 ++-- src/editors/data/constants/requests.js | 4 +- src/editors/data/redux/app/reducer.js | 15 +- src/editors/data/redux/app/reducer.test.js | 20 ++- src/editors/data/redux/app/selectors.js | 21 +-- src/editors/data/redux/app/selectors.test.js | 38 +---- src/editors/data/redux/requests/reducer.js | 2 +- src/editors/data/redux/thunkActions/app.js | 40 +++-- .../data/redux/thunkActions/app.test.js | 143 +++++++++++++++--- .../data/redux/thunkActions/requests.js | 11 +- .../data/redux/thunkActions/requests.test.js | 20 +-- src/editors/data/services/cms/api.js | 13 +- src/editors/data/services/cms/api.test.js | 13 +- src/editors/data/services/cms/mockApi.js | 2 +- src/editors/data/services/cms/urls.js | 2 +- src/editors/data/services/cms/urls.test.js | 2 +- .../SelectImageModal/hooks.js | 28 +++- .../SelectImageModal/hooks.test.js | 16 +- .../SelectImageModal/index.jsx | 14 +- .../SelectionModal/Gallery.jsx | 25 ++- .../SelectionModal/Gallery.test.jsx | 3 + .../SelectionModal/GalleryCard.jsx | 5 +- .../SelectionModal/GalleryLoadMoreButton.jsx | 54 +++++++ .../SelectionModal/SearchSort.jsx | 1 + .../__snapshots__/Gallery.test.jsx.snap | 12 ++ .../__snapshots__/GalleryCard.test.jsx.snap | 54 ++++++- .../sharedComponents/SelectionModal/index.jsx | 1 + .../__snapshots__/index.test.jsx.snap | 3 + .../sharedComponents/TinyMceWidget/hooks.js | 137 +++++++---------- .../TinyMceWidget/hooks.test.js | 62 +++----- .../sharedComponents/TinyMceWidget/index.jsx | 14 +- .../TinyMceWidget/index.test.jsx | 18 ++- .../sharedComponents/TinyMceWidget/utils.js | 15 ++ 47 files changed, 635 insertions(+), 404 deletions(-) create mode 100644 src/editors/sharedComponents/SelectionModal/GalleryLoadMoreButton.jsx create mode 100644 src/editors/sharedComponents/TinyMceWidget/utils.js diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.js index 7f040f6fd..0d7b423f6 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.js @@ -39,19 +39,23 @@ export const setAnswerTitle = ({ }; export const setSelectedFeedback = ({ answer, hasSingleAnswer, dispatch }) => (e) => { - dispatch(actions.problem.updateAnswer({ - id: answer.id, - hasSingleAnswer, - selectedFeedback: e.target.value, - })); + if (e.target) { + dispatch(actions.problem.updateAnswer({ + id: answer.id, + hasSingleAnswer, + selectedFeedback: e.target.value, + })); + } }; export const setUnselectedFeedback = ({ answer, hasSingleAnswer, dispatch }) => (e) => { - dispatch(actions.problem.updateAnswer({ - id: answer.id, - hasSingleAnswer, - unselectedFeedback: e.target.value, - })); + if (e.target) { + dispatch(actions.problem.updateAnswer({ + id: answer.id, + hasSingleAnswer, + unselectedFeedback: e.target.value, + })); + } }; export const useFeedback = (answer) => { diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/__snapshots__/index.test.jsx.snap index 468b4b388..8a9deb930 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/__snapshots__/index.test.jsx.snap @@ -23,7 +23,7 @@ exports[`SolutionWidget render snapshot: renders correct default 1`] = ` /> <[object Object] - editorContentHtml="This is my question" + editorContentHtml="This is my solution" editorType="solution" id="solution" minHeight={150} diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.jsx index 0dccc5a95..308b1165c 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.jsx @@ -6,15 +6,20 @@ import { selectors } from '../../../../../data/redux'; import messages from './messages'; import TinyMceWidget from '../../../../../sharedComponents/TinyMceWidget'; -import { prepareEditorRef } from '../../../../../sharedComponents/TinyMceWidget/hooks'; +import { prepareEditorRef, replaceStaticWithAsset } from '../../../../../sharedComponents/TinyMceWidget/hooks'; export const ExplanationWidget = ({ // redux settings, + learningContextId, // injected intl, }) => { const { editorRef, refReady, setEditorRef } = prepareEditorRef(); + const solutionContent = replaceStaticWithAsset({ + initialContent: settings?.solutionExplanation, + learningContextId, + }); if (!refReady) { return null; } return (
@@ -28,7 +33,7 @@ export const ExplanationWidget = ({ id="solution" editorType="solution" editorRef={editorRef} - editorContentHtml={settings?.solutionExplanation} + editorContentHtml={solutionContent} setEditorRef={setEditorRef} minHeight={150} placeholder={intl.formatMessage(messages.placeholder)} @@ -41,11 +46,13 @@ ExplanationWidget.propTypes = { // redux // eslint-disable-next-line settings: PropTypes.any.isRequired, + learningContextId: PropTypes.string.isRequired, // injected intl: intlShape.isRequired, }; export const mapStateToProps = (state) => ({ settings: selectors.problem.settings(state), + learningContextId: selectors.app.learningContextId(state), }); export default injectIntl(connect(mapStateToProps)(ExplanationWidget)); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.test.jsx index 7eedd86d5..48f784b6a 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.test.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.test.jsx @@ -12,6 +12,9 @@ jest.mock('../../../../../data/redux', () => ({ problem: { settings: jest.fn(state => ({ question: state })), }, + app: { + learningContextId: jest.fn(state => ({ learningContextId: state })), + }, }, thunkActions: { video: { @@ -25,11 +28,13 @@ jest.mock('../../../../../sharedComponents/TinyMceWidget/hooks', () => ({ refReady: true, setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'), })), + replaceStaticWithAsset: jest.fn(() => 'This is my solution'), })); describe('SolutionWidget', () => { const props = { - settings: { solutionExplanation: 'This is my question' }, + settings: { solutionExplanation: 'This is my solution' }, + learningContextId: 'course+org+run', // injected intl: { formatMessage }, }; @@ -40,8 +45,11 @@ describe('SolutionWidget', () => { }); describe('mapStateToProps', () => { const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; - test('question from problem.question', () => { + test('settings from problem.settings', () => { expect(mapStateToProps(testState).settings).toEqual(selectors.problem.settings(testState)); }); + test('learningContextId from app.learningContextId', () => { + expect(mapStateToProps(testState).learningContextId).toEqual(selectors.app.learningContextId(testState)); + }); }); }); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.jsx index df21809db..6be330f5a 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.jsx @@ -6,15 +6,20 @@ import { selectors } from '../../../../../data/redux'; import messages from './messages'; import TinyMceWidget from '../../../../../sharedComponents/TinyMceWidget'; -import { prepareEditorRef } from '../../../../../sharedComponents/TinyMceWidget/hooks'; +import { prepareEditorRef, replaceStaticWithAsset } from '../../../../../sharedComponents/TinyMceWidget/hooks'; export const QuestionWidget = ({ // redux question, + learningContextId, // injected intl, }) => { const { editorRef, refReady, setEditorRef } = prepareEditorRef(); + const questionContent = replaceStaticWithAsset({ + initialContent: question, + learningContextId, + }); if (!refReady) { return null; } return (
@@ -25,7 +30,7 @@ export const QuestionWidget = ({ id="question" editorType="question" editorRef={editorRef} - editorContentHtml={question} + editorContentHtml={questionContent} setEditorRef={setEditorRef} minHeight={150} placeholder={intl.formatMessage(messages.placeholder)} @@ -37,11 +42,13 @@ export const QuestionWidget = ({ QuestionWidget.propTypes = { // redux question: PropTypes.string.isRequired, + learningContextId: PropTypes.string.isRequired, // injected intl: intlShape.isRequired, }; export const mapStateToProps = (state) => ({ question: selectors.problem.question(state), + learningContextId: selectors.app.learningContextId(state), }); export default injectIntl(connect(mapStateToProps)(QuestionWidget)); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.test.jsx index 5e01ae0b6..d18a6bc38 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.test.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.test.jsx @@ -15,9 +15,7 @@ jest.mock('../../../../../data/redux', () => ({ }, selectors: { app: { - isLibrary: jest.fn(state => ({ isLibrary: state })), - lmsEndpointUrl: jest.fn(state => ({ lmsEndpointUrl: state })), - studioEndpointUrl: jest.fn(state => ({ studioEndpointUrl: state })), + learningContextId: jest.fn(state => ({ learningContextId: state })), }, problem: { question: jest.fn(state => ({ question: state })), @@ -35,13 +33,14 @@ jest.mock('../../../../../sharedComponents/TinyMceWidget/hooks', () => ({ refReady: true, setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'), })), - // problemEditorConfig: jest.fn(args => ({ problemEditorConfig: args })), + replaceStaticWithAsset: jest.fn(() => 'This is my question'), })); describe('QuestionWidget', () => { const props = { question: 'This is my question', updateQuestion: jest.fn(), + learningContextId: 'course+org+run', // injected intl: { formatMessage }, }; @@ -55,5 +54,8 @@ describe('QuestionWidget', () => { test('question from problem.question', () => { expect(mapStateToProps(testState).question).toEqual(selectors.problem.question(testState)); }); + test('learningContextId from app.learningContextId', () => { + expect(mapStateToProps(testState).learningContextId).toEqual(selectors.app.learningContextId(testState)); + }); }); }); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js index 5b338f127..6a11b3f71 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js @@ -56,14 +56,13 @@ export const parseState = ({ problem, isAdvanced, ref, - assets, lmsEndpointUrl, }) => () => { const rawOLX = ref?.current?.state.doc.toString(); const editorObject = fetchEditorContent({ format: '' }); const reactOLXParser = new ReactStateOLXParser({ problem, editorObject }); const reactSettingsParser = new ReactStateSettingsParser({ problem, rawOLX }); - const reactBuiltOlx = setAssetToStaticUrl({ editorValue: reactOLXParser.buildOLX(), assets, lmsEndpointUrl }); + const reactBuiltOlx = setAssetToStaticUrl({ editorValue: reactOLXParser.buildOLX(), lmsEndpointUrl }); return { settings: isAdvanced ? reactSettingsParser.parseRawOlxSettings() : reactSettingsParser.getSettings(), olx: isAdvanced ? rawOLX : reactBuiltOlx, @@ -143,7 +142,6 @@ export const getContent = ({ openSaveWarningModal, isAdvancedProblemType, editorRef, - assets, lmsEndpointUrl, }) => { const problem = problemState; @@ -161,7 +159,6 @@ export const getContent = ({ isAdvanced: isAdvancedProblemType, ref: editorRef, problem, - assets, lmsEndpointUrl, })(); return data; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx index 3b08bd806..e05a4ae4b 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/index.jsx @@ -29,7 +29,6 @@ export const EditProblemView = ({ // redux problemType, problemState, - assets, lmsEndpointUrl, returnUrl, analytics, @@ -48,7 +47,6 @@ export const EditProblemView = ({ openSaveWarningModal, isAdvancedProblemType, editorRef, - assets, lmsEndpointUrl, })} returnFunction={returnFunction} @@ -70,7 +68,6 @@ export const EditProblemView = ({ problem: problemState, isAdvanced: isAdvancedProblemType, ref: editorRef, - assets, lmsEndpointUrl, })(), returnFunction, @@ -118,7 +115,6 @@ export const EditProblemView = ({ }; EditProblemView.defaultProps = { - assets: null, lmsEndpointUrl: null, returnFunction: null, }; @@ -128,7 +124,6 @@ EditProblemView.propTypes = { returnFunction: PropTypes.func, // eslint-disable-next-line problemState: PropTypes.any.isRequired, - assets: PropTypes.shape({}), analytics: PropTypes.shape({}).isRequired, lmsEndpointUrl: PropTypes.string, returnUrl: PropTypes.string.isRequired, @@ -137,7 +132,6 @@ EditProblemView.propTypes = { }; export const mapStateToProps = (state) => ({ - assets: selectors.app.assets(state), analytics: selectors.app.analytics(state), lmsEndpointUrl: selectors.app.lmsEndpointUrl(state), returnUrl: selectors.app.returnUrl(state), diff --git a/src/editors/containers/ProblemEditor/index.jsx b/src/editors/containers/ProblemEditor/index.jsx index cbc06adb8..fcb05d194 100644 --- a/src/editors/containers/ProblemEditor/index.jsx +++ b/src/editors/containers/ProblemEditor/index.jsx @@ -16,19 +16,17 @@ export const ProblemEditor = ({ problemType, blockFinished, blockFailed, - studioViewFinished, blockValue, initializeProblemEditor, - assetsFinished, advancedSettingsFinished, }) => { React.useEffect(() => { - if (blockFinished && studioViewFinished && assetsFinished && !blockFailed) { + if (blockFinished && !blockFailed) { initializeProblemEditor(blockValue); } - }, [blockFinished, studioViewFinished, assetsFinished, blockFailed]); + }, [blockFinished, blockFailed]); - if (!blockFinished || !studioViewFinished || !assetsFinished || !advancedSettingsFinished) { + if (!blockFinished || !advancedSettingsFinished) { return (
({ blockFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchBlock }), blockFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchBlock }), - studioViewFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchStudioView }), problemType: selectors.problem.problemType(state), blockValue: selectors.app.blockValue(state), - assetsFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchAssets }), advancedSettingsFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchAdvancedSettings }), }); diff --git a/src/editors/containers/ProblemEditor/index.test.jsx b/src/editors/containers/ProblemEditor/index.test.jsx index 954378fec..6c171b22a 100644 --- a/src/editors/containers/ProblemEditor/index.test.jsx +++ b/src/editors/containers/ProblemEditor/index.test.jsx @@ -45,9 +45,7 @@ describe('ProblemEditor', () => { blockValue: { data: { data: 'eDiTablE Text' } }, blockFinished: false, blockFailed: false, - studioViewFinished: false, initializeProblemEditor: jest.fn().mockName('args.intializeProblemEditor'), - assetsFinished: false, advancedSettingsFinished: false, }; describe('snapshots', () => { @@ -58,14 +56,6 @@ describe('ProblemEditor', () => { const wrapper = shallow(); expect(wrapper.instance.findByType(Spinner)).toBeTruthy(); }); - test('studio view loaded, block and assets not yet loaded, Spinner appears', () => { - const wrapper = shallow(); - expect(wrapper.instance.findByType(Spinner)).toBeTruthy(); - }); - test('assets loaded, block and studio view 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(); @@ -75,7 +65,6 @@ describe('ProblemEditor', () => { {...props} blockFinished studioViewFinished - assetsFinished advancedSettingsFinished blockFailed />); @@ -86,7 +75,6 @@ describe('ProblemEditor', () => { {...props} blockFinished studioViewFinished - assetsFinished advancedSettingsFinished />); expect(wrapper.instance.findByType('SelectTypeModal')).toHaveLength(1); @@ -97,7 +85,6 @@ describe('ProblemEditor', () => { problemType="multiplechoiceresponse" blockFinished studioViewFinished - assetsFinished advancedSettingsFinished />); expect(wrapper.instance.findByType('EditProblemView')).toHaveLength(1); @@ -121,16 +108,6 @@ describe('ProblemEditor', () => { mapStateToProps(testState).blockFinished, ).toEqual(selectors.requests.isFinished(testState, { requestKey: RequestKeys.fetchBlock })); }); - test('studioViewFinished from requests.isFinished', () => { - expect( - mapStateToProps(testState).studioViewFinished, - ).toEqual(selectors.requests.isFinished(testState, { requestKey: RequestKeys.fetchStudioView })); - }); - test('assetsFinished from requests.isFinished', () => { - expect( - mapStateToProps(testState).assetsFinished, - ).toEqual(selectors.requests.isFinished(testState, { requestKey: RequestKeys.fetchAssets })); - }); test('advancedSettingsFinished from requests.isFinished', () => { expect( mapStateToProps(testState).advancedSettingsFinished, diff --git a/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap b/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap index 2e95bf873..3273658c8 100644 --- a/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap @@ -5,17 +5,12 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = ` getContent={ { "getContent": { - "assets": { - "sOmEaSsET": { - "staTICUrl": "/assets/sOmEaSsET", - }, - }, "editorRef": { "current": { "value": "something", }, }, - "isRaw": false, + "showRawEditor": false, }, } } @@ -59,17 +54,12 @@ exports[`TextEditor snapshots loaded, raw editor 1`] = ` getContent={ { "getContent": { - "assets": { - "sOmEaSsET": { - "staTICUrl": "/assets/sOmEaSsET", - }, - }, "editorRef": { "current": { "value": "something", }, }, - "isRaw": true, + "showRawEditor": true, }, } } @@ -115,17 +105,12 @@ exports[`TextEditor snapshots not yet loaded, Spinner appears 1`] = ` getContent={ { "getContent": { - "assets": { - "sOmEaSsET": { - "staTICUrl": "/assets/sOmEaSsET", - }, - }, "editorRef": { "current": { "value": "something", }, }, - "isRaw": false, + "showRawEditor": false, }, } } @@ -163,17 +148,12 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = ` getContent={ { "getContent": { - "assets": { - "sOmEaSsET": { - "staTICUrl": "/assets/sOmEaSsET", - }, - }, "editorRef": { "current": { "value": "something", }, }, - "isRaw": false, + "showRawEditor": false, }, } } diff --git a/src/editors/containers/TextEditor/hooks.js b/src/editors/containers/TextEditor/hooks.js index 2f4d74b89..3d725f114 100644 --- a/src/editors/containers/TextEditor/hooks.js +++ b/src/editors/containers/TextEditor/hooks.js @@ -3,9 +3,9 @@ import { setAssetToStaticUrl } from '../../sharedComponents/TinyMceWidget/hooks' export const { nullMethod, navigateCallback, navigateTo } = appHooks; -export const getContent = ({ editorRef, isRaw, assets }) => () => { - const content = (isRaw && editorRef && editorRef.current +export const getContent = ({ editorRef, showRawEditor }) => () => { + const content = (showRawEditor && editorRef && editorRef.current ? editorRef.current.state.doc.toString() : editorRef.current?.getContent()); - return setAssetToStaticUrl({ editorValue: content, assets }); + return setAssetToStaticUrl({ editorValue: content }); }; diff --git a/src/editors/containers/TextEditor/hooks.test.jsx b/src/editors/containers/TextEditor/hooks.test.jsx index e2fa7722f..0580139fa 100644 --- a/src/editors/containers/TextEditor/hooks.test.jsx +++ b/src/editors/containers/TextEditor/hooks.test.jsx @@ -43,18 +43,17 @@ describe('TextEditor hooks', () => { tinyMceHooks, tinyMceHookKeys.setAssetToStaticUrl, ).mockReturnValueOnce(rawContent); - const assets = []; - test('returns correct content based on isRaw equals false', () => { - const getContent = module.getContent({ editorRef, isRaw: false, assets })(); + 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, assets }); + expect(spies.visualHtml).toHaveBeenCalledWith({ editorValue: visualContent }); expect(getContent).toEqual(visualContent); }); - test('returns correct content based on isRaw equals true', () => { + test('returns correct content based on showRawEditor equals true', () => { jest.clearAllMocks(); - const getContent = module.getContent({ editorRef, isRaw: true, assets })(); + const getContent = module.getContent({ editorRef, showRawEditor: true })(); expect(spies.rawHtml.mock.calls.length).toEqual(1); - expect(spies.rawHtml).toHaveBeenCalledWith({ editorValue: rawContent, assets }); + 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 index 521abbf8e..a8e0a9286 100644 --- a/src/editors/containers/TextEditor/index.jsx +++ b/src/editors/containers/TextEditor/index.jsx @@ -16,27 +16,31 @@ import RawEditor from '../../sharedComponents/RawEditor'; import * as hooks from './hooks'; import messages from './messages'; import TinyMceWidget from '../../sharedComponents/TinyMceWidget'; -import { prepareEditorRef } from '../../sharedComponents/TinyMceWidget/hooks'; +import { prepareEditorRef, replaceStaticWithAsset } from '../../sharedComponents/TinyMceWidget/hooks'; export const TextEditor = ({ onClose, returnFunction, // redux - isRaw, + showRawEditor, blockValue, blockFailed, initializeEditor, - assetsFinished, - assets, + blockFinished, + learningContextId, // inject intl, }) => { const { editorRef, refReady, setEditorRef } = prepareEditorRef(); + const editorContent = blockValue ? replaceStaticWithAsset({ + initialContent: blockValue.data.data, + learningContextId, + }) : ''; if (!refReady) { return null; } const selectEditor = () => { - if (isRaw) { + if (showRawEditor) { return ( @@ -68,7 +72,7 @@ export const TextEditor = ({ - {(!assetsFinished) + {(!blockFinished) ? (
({ blockValue: selectors.app.blockValue(state), blockFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchBlock }), - isRaw: selectors.app.isRaw(state), - assetsFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchAssets }), - assets: selectors.app.assets(state), + showRawEditor: selectors.app.showRawEditor(state), + blockFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchBlock }), + learningContextId: selectors.app.learningContextId(state), }); export const mapDispatchToProps = { diff --git a/src/editors/containers/TextEditor/index.test.jsx b/src/editors/containers/TextEditor/index.test.jsx index 16465c8ed..9268d533e 100644 --- a/src/editors/containers/TextEditor/index.test.jsx +++ b/src/editors/containers/TextEditor/index.test.jsx @@ -30,6 +30,7 @@ jest.mock('../../sharedComponents/TinyMceWidget/hooks', () => ({ refReady: true, setEditorRef: jest.fn().mockName('hooks.prepareEditorRef.setEditorRef'), })), + replaceStaticWithAsset: jest.fn(() => 'eDiTablE Text'), })); jest.mock('react', () => { @@ -54,9 +55,9 @@ jest.mock('../../data/redux', () => ({ blockValue: jest.fn(state => ({ blockValue: state })), lmsEndpointUrl: jest.fn(state => ({ lmsEndpointUrl: state })), studioEndpointUrl: jest.fn(state => ({ studioEndpointUrl: state })), - isRaw: jest.fn(state => ({ isRaw: state })), + showRawEditor: jest.fn(state => ({ showRawEditor: state })), isLibrary: jest.fn(state => ({ isLibrary: state })), - assets: jest.fn(state => ({ assets: state })), + learningContextId: jest.fn(state => ({ learningContextId: state })), }, requests: { isFailed: jest.fn((state, params) => ({ isFailed: { state, params } })), @@ -77,9 +78,9 @@ describe('TextEditor', () => { blockValue: { data: { data: 'eDiTablE Text' } }, blockFailed: false, initializeEditor: jest.fn().mockName('args.intializeEditor'), - isRaw: false, - assetsFinished: true, - assets: { sOmEaSsET: { staTICUrl: '/assets/sOmEaSsET' } }, + showRawEditor: false, + blockFinished: true, + learningContextId: 'course+org+run', // inject intl: { formatMessage }, }; @@ -88,10 +89,10 @@ describe('TextEditor', () => { expect(shallow().snapshot).toMatchSnapshot(); }); test('not yet loaded, Spinner appears', () => { - expect(shallow().snapshot).toMatchSnapshot(); + expect(shallow().snapshot).toMatchSnapshot(); }); test('loaded, raw editor', () => { - expect(shallow().snapshot).toMatchSnapshot(); + expect(shallow().snapshot).toMatchSnapshot(); }); test('block failed to load, Toast is shown', () => { expect(shallow().snapshot).toMatchSnapshot(); @@ -105,20 +106,20 @@ describe('TextEditor', () => { mapStateToProps(testState).blockValue, ).toEqual(selectors.app.blockValue(testState)); }); - test('assets from app.assets', () => { - expect( - mapStateToProps(testState).assets, - ).toEqual(selectors.app.assets(testState)); - }); test('blockFailed from requests.isFailed', () => { expect( mapStateToProps(testState).blockFailed, ).toEqual(selectors.requests.isFailed(testState, { requestKey: RequestKeys.fetchBlock })); }); - test('assetssFinished from requests.isFinished', () => { + 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).assetsFinished, - ).toEqual(selectors.requests.isFinished(testState, { requestKey: RequestKeys.fetchAssets })); + mapStateToProps(testState).learningContextId, + ).toEqual(selectors.app.learningContextId(testState)); }); }); diff --git a/src/editors/data/constants/requests.js b/src/editors/data/constants/requests.js index 989661420..ec736f27e 100644 --- a/src/editors/data/constants/requests.js +++ b/src/editors/data/constants/requests.js @@ -8,14 +8,12 @@ export const RequestStates = StrictDict({ }); export const RequestKeys = StrictDict({ - fetchAssets: 'fetchAssets', fetchVideos: 'fetchVideos', fetchBlock: 'fetchBlock', fetchImages: 'fetchImages', fetchUnit: 'fetchUnit', fetchStudioView: 'fetchStudioView', saveBlock: 'saveBlock', - uploadAsset: 'uploadAsset', uploadVideo: 'uploadVideo', allowThumbnailUpload: 'allowThumbnailUpload', uploadThumbnail: 'uploadThumbnail', @@ -26,7 +24,7 @@ export const RequestKeys = StrictDict({ getTranscriptFile: 'getTranscriptFile', checkTranscriptsForImport: 'checkTranscriptsForImport', importTranscript: 'importTranscript', - uploadImage: 'uploadImage', + uploadAsset: 'uploadAsset', fetchAdvancedSettings: 'fetchAdvancedSettings', fetchVideoFeatures: 'fetchVideoFeatures', }); diff --git a/src/editors/data/redux/app/reducer.js b/src/editors/data/redux/app/reducer.js index 405c9b860..1951983f4 100644 --- a/src/editors/data/redux/app/reducer.js +++ b/src/editors/data/redux/app/reducer.js @@ -15,9 +15,11 @@ const initialState = { editorInitialized: false, studioEndpointUrl: null, lmsEndpointUrl: null, - assets: {}, + images: {}, + imageCount: 0, videos: {}, courseDetails: {}, + showRawEditor: false, }; // eslint-disable-next-line no-unused-vars @@ -40,15 +42,22 @@ const app = createSlice({ 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 }), - setAssets: (state, { payload }) => ({ ...state, assets: payload }), + 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', + }), }, }); diff --git a/src/editors/data/redux/app/reducer.test.js b/src/editors/data/redux/app/reducer.test.js index f23e8c14c..c125185d8 100644 --- a/src/editors/data/redux/app/reducer.test.js +++ b/src/editors/data/redux/app/reducer.test.js @@ -47,10 +47,18 @@ describe('app reducer', () => { ['setBlockContent', 'blockContent'], ['setBlockTitle', 'blockTitle'], ['setSaveResponse', 'saveResponse'], - ['setAssets', 'assets'], ['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' }; @@ -61,6 +69,16 @@ describe('app reducer', () => { }); }); }); + 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({ diff --git a/src/editors/data/redux/app/selectors.js b/src/editors/data/redux/app/selectors.js index 870739781..c9d23c2e0 100644 --- a/src/editors/data/redux/app/selectors.js +++ b/src/editors/data/redux/app/selectors.js @@ -21,8 +21,9 @@ export const simpleSelectors = { studioEndpointUrl: mkSimpleSelector(app => app.studioEndpointUrl), unitUrl: mkSimpleSelector(app => app.unitUrl), blockTitle: mkSimpleSelector(app => app.blockTitle), - assets: mkSimpleSelector(app => app.assets), + images: mkSimpleSelector(app => app.images), videos: mkSimpleSelector(app => app.videos), + showRawEditor: mkSimpleSelector(app => app.showRawEditor), }; export const returnUrl = createSelector( @@ -72,23 +73,6 @@ export const analytics = createSelector( ), ); -export const isRaw = createSelector( - [module.simpleSelectors.studioView], - (studioView) => { - if (!studioView?.data) { - return null; - } - const { html, content } = studioView.data; - if (html && html.includes('data-editor="raw"')) { - return true; - } - if (content && content.includes('data-editor="raw"')) { - return true; - } - return false; - }, -); - export const isLibrary = createSelector( [ module.simpleSelectors.learningContextId, @@ -111,6 +95,5 @@ export default { returnUrl, displayTitle, analytics, - isRaw, isLibrary, }; diff --git a/src/editors/data/redux/app/selectors.test.js b/src/editors/data/redux/app/selectors.test.js index 58bff4d18..33a022e0b 100644 --- a/src/editors/data/redux/app/selectors.test.js +++ b/src/editors/data/redux/app/selectors.test.js @@ -47,8 +47,9 @@ describe('app selectors unit tests', () => { simpleKeys.unitUrl, simpleKeys.blockTitle, simpleKeys.studioView, - simpleKeys.assets, + simpleKeys.images, simpleKeys.videos, + simpleKeys.showRawEditor, ].map(testSimpleSelector); }); }); @@ -120,41 +121,6 @@ describe('app selectors unit tests', () => { }); }); - describe('isRaw', () => { - const studioViewCourseRaw = { - data: { - html: 'data-editor="raw"', - }, - }; - const studioViewV2LibraryRaw = { - data: { - content: 'data-editor="raw"', - }, - }; - const studioViewVisual = { - data: { - html: 'sOmEthIngElse', - }, - }; - it('is memoized based on studioView', () => { - expect(selectors.isRaw.preSelectors).toEqual([ - simpleSelectors.studioView, - ]); - }); - it('returns null if studioView is null', () => { - expect(selectors.isRaw.cb(null)).toEqual(null); - }); - it('returns true if course studioView is raw', () => { - expect(selectors.isRaw.cb(studioViewCourseRaw)).toEqual(true); - }); - it('returns true if v2 library studioView is raw', () => { - expect(selectors.isRaw.cb(studioViewV2LibraryRaw)).toEqual(true); - }); - it('returns false if the studioView is not Raw', () => { - expect(selectors.isRaw.cb(studioViewVisual)).toEqual(false); - }); - }); - describe('isLibrary', () => { const learningContextIdLibrary = 'library-v1:name'; const learningContextIdCourse = 'course-v1:name'; diff --git a/src/editors/data/redux/requests/reducer.js b/src/editors/data/redux/requests/reducer.js index 15274560f..583c39064 100644 --- a/src/editors/data/redux/requests/reducer.js +++ b/src/editors/data/redux/requests/reducer.js @@ -15,7 +15,7 @@ const initialState = { [RequestKeys.uploadTranscript]: { status: RequestStates.inactive }, [RequestKeys.deleteTranscript]: { status: RequestStates.inactive }, [RequestKeys.fetchCourseDetails]: { status: RequestStates.inactive }, - [RequestKeys.fetchAssets]: { status: RequestStates.inactive }, + [RequestKeys.fetchImages]: { status: RequestStates.inactive }, [RequestKeys.fetchVideos]: { status: RequestStates.inactive }, [RequestKeys.uploadVideo]: { status: RequestStates.inactive }, [RequestKeys.checkTranscriptsForImport]: { status: RequestStates.inactive }, diff --git a/src/editors/data/redux/thunkActions/app.js b/src/editors/data/redux/thunkActions/app.js index 9c4e4aea6..a248279d2 100644 --- a/src/editors/data/redux/thunkActions/app.js +++ b/src/editors/data/redux/thunkActions/app.js @@ -7,7 +7,10 @@ import { RequestKeys } from '../../constants/requests'; export const fetchBlock = () => (dispatch) => { dispatch(requests.fetchBlock({ - onSuccess: (response) => dispatch(actions.app.setBlockValue(response)), + onSuccess: (response) => { + dispatch(actions.app.setBlockValue(response)); + dispatch(actions.app.setShowRawEditor(response)); + }, onFailure: (error) => dispatch(actions.requests.failRequest({ requestKey: RequestKeys.fetchBlock, error, @@ -35,11 +38,12 @@ export const fetchUnit = () => (dispatch) => { })); }; -export const fetchAssets = () => (dispatch) => { - dispatch(requests.fetchAssets({ - onSuccess: (response) => dispatch(actions.app.setAssets(response)), +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.fetchAssets, + requestKey: RequestKeys.fetchImages, error, })), })); @@ -72,13 +76,25 @@ export const fetchCourseDetails = () => (dispatch) => { * @param {string} blockType */ export const initialize = (data) => (dispatch) => { + const editorType = data.blockType; dispatch(actions.app.initialize(data)); dispatch(module.fetchBlock()); dispatch(module.fetchUnit()); - dispatch(module.fetchStudioView()); - dispatch(module.fetchAssets()); - dispatch(module.fetchVideos()); - dispatch(module.fetchCourseDetails()); + 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; + } }; /** @@ -95,7 +111,7 @@ export const saveBlock = (content, returnToUnit) => (dispatch) => { })); }; -export const uploadImage = ({ file, setSelection }) => (dispatch) => { +export const uploadAsset = ({ file, setSelection }) => (dispatch) => { dispatch(requests.uploadAsset({ asset: file, onSuccess: (response) => setSelection(camelizeKeys(response.data.asset)), @@ -110,6 +126,6 @@ export default StrictDict({ fetchVideos, initialize, saveBlock, - fetchAssets, - uploadImage, + fetchImages, + uploadAsset, }); diff --git a/src/editors/data/redux/thunkActions/app.test.js b/src/editors/data/redux/thunkActions/app.test.js index dce0a212d..2c962b285 100644 --- a/src/editors/data/redux/thunkActions/app.test.js +++ b/src/editors/data/redux/thunkActions/app.test.js @@ -10,7 +10,7 @@ jest.mock('./requests', () => ({ saveBlock: (args) => ({ saveBlock: args }), uploadAsset: (args) => ({ uploadAsset: args }), fetchStudioView: (args) => ({ fetchStudioView: args }), - fetchAssets: (args) => ({ fetchAssets: args }), + fetchImages: (args) => ({ fetchImages: args }), fetchVideos: (args) => ({ fetchVideos: args }), fetchCourseDetails: (args) => ({ fetchCourseDetails: args }), })); @@ -101,24 +101,24 @@ describe('app thunkActions', () => { })); }); }); - describe('fetchAssets', () => { + describe('fetchImages', () => { beforeEach(() => { - thunkActions.fetchAssets()(dispatch); + thunkActions.fetchImages({ pageNumber: 0 })(dispatch); [[dispatchedAction]] = dispatch.mock.calls; }); - it('dispatches fetchAssets action', () => { - expect(dispatchedAction.fetchAssets).not.toEqual(undefined); + it('dispatches fetchImages action', () => { + expect(dispatchedAction.fetchImages).not.toEqual(undefined); }); - it('dispatches actions.app.setAssets on success', () => { + it('dispatches actions.app.setImages on success', () => { dispatch.mockClear(); - dispatchedAction.fetchAssets.onSuccess(testValue); - expect(dispatch).toHaveBeenCalledWith(actions.app.setAssets(testValue)); + dispatchedAction.fetchImages.onSuccess({ images: {}, imageCount: 0 }); + expect(dispatch).toHaveBeenCalledWith(actions.app.setImages({ images: {}, imageCount: 0 })); }); - it('dispatches failRequest with fetchAssets requestKey on failure', () => { + it('dispatches failRequest with fetchImages requestKey on failure', () => { dispatch.mockClear(); - dispatchedAction.fetchAssets.onFailure(testValue); + dispatchedAction.fetchImages.onFailure(testValue); expect(dispatch).toHaveBeenCalledWith(actions.requests.failRequest({ - requestKey: RequestKeys.fetchAssets, + requestKey: RequestKeys.fetchImages, error: testValue, })); }); @@ -128,7 +128,7 @@ describe('app thunkActions', () => { thunkActions.fetchVideos()(dispatch); [[dispatchedAction]] = dispatch.mock.calls; }); - it('dispatches fetchAssets action', () => { + it('dispatches fetchImages action', () => { expect(dispatchedAction.fetchVideos).not.toEqual(undefined); }); it('dispatches actions.app.setVideos on success', () => { @@ -167,20 +167,20 @@ describe('app thunkActions', () => { })); }); }); - describe('initialize', () => { + describe('initialize without block type defined', () => { it('dispatches actions.app.initialize, and then fetches both block and unit', () => { const { fetchBlock, fetchUnit, fetchStudioView, - fetchAssets, + fetchImages, fetchVideos, fetchCourseDetails, } = thunkActions; thunkActions.fetchBlock = () => 'fetchBlock'; thunkActions.fetchUnit = () => 'fetchUnit'; thunkActions.fetchStudioView = () => 'fetchStudioView'; - thunkActions.fetchAssets = () => 'fetchAssets'; + thunkActions.fetchImages = () => 'fetchImages'; thunkActions.fetchVideos = () => 'fetchVideos'; thunkActions.fetchCourseDetails = () => 'fetchCourseDetails'; thunkActions.initialize(testValue)(dispatch); @@ -188,15 +188,118 @@ describe('app thunkActions', () => { [actions.app.initialize(testValue)], [thunkActions.fetchBlock()], [thunkActions.fetchUnit()], - [thunkActions.fetchStudioView()], - [thunkActions.fetchAssets()], + ]); + 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.fetchAssets = fetchAssets; + thunkActions.fetchImages = fetchImages; thunkActions.fetchVideos = fetchVideos; thunkActions.fetchCourseDetails = fetchCourseDetails; }); @@ -225,10 +328,10 @@ describe('app thunkActions', () => { expect(returnToUnit).toHaveBeenCalled(); }); }); - describe('uploadImage', () => { + describe('uploadAsset', () => { const setSelection = jest.fn(); beforeEach(() => { - thunkActions.uploadImage({ file: testValue, setSelection })(dispatch); + thunkActions.uploadAsset({ file: testValue, setSelection })(dispatch); [[dispatchedAction]] = dispatch.mock.calls; }); it('dispatches uploadAsset action', () => { diff --git a/src/editors/data/redux/thunkActions/requests.js b/src/editors/data/redux/thunkActions/requests.js index b0cb34eb2..f419c9a4b 100644 --- a/src/editors/data/redux/thunkActions/requests.js +++ b/src/editors/data/redux/thunkActions/requests.js @@ -124,15 +124,16 @@ export const uploadAsset = ({ asset, ...rest }) => (dispatch, getState) => { })); }; -export const fetchAssets = ({ ...rest }) => (dispatch, getState) => { +export const fetchImages = ({ pageNumber, ...rest }) => (dispatch, getState) => { dispatch(module.networkRequest({ - requestKey: RequestKeys.fetchAssets, + requestKey: RequestKeys.fetchImages, promise: api - .fetchAssets({ + .fetchImages({ + pageNumber, studioEndpointUrl: selectors.app.studioEndpointUrl(getState()), learningContextId: selectors.app.learningContextId(getState()), }) - .then((response) => loadImages(response.data.assets)), + .then(({ data }) => ({ images: loadImages(data.assets), imageCount: data.totalCount })), ...rest, })); }; @@ -312,7 +313,7 @@ export default StrictDict({ fetchStudioView, fetchUnit, saveBlock, - fetchAssets, + fetchImages, fetchVideos, uploadAsset, allowThumbnailUpload, diff --git a/src/editors/data/redux/thunkActions/requests.test.js b/src/editors/data/redux/thunkActions/requests.test.js index 7525498aa..4b5961b9e 100644 --- a/src/editors/data/redux/thunkActions/requests.test.js +++ b/src/editors/data/redux/thunkActions/requests.test.js @@ -26,7 +26,7 @@ jest.mock('../../services/cms/api', () => ({ fetchByUnitId: ({ id, url }) => ({ id, url }), fetchCourseDetails: (args) => args, saveBlock: (args) => args, - fetchAssets: ({ id, url }) => ({ id, url }), + fetchImages: ({ id, url }) => ({ id, url }), fetchVideos: ({ id, url }) => ({ id, url }), uploadAsset: (args) => args, loadImages: jest.fn(), @@ -237,8 +237,8 @@ describe('requests thunkActions module', () => { }, }); }); - describe('fetchAssets', () => { - let fetchAssets; + describe('fetchImages', () => { + let fetchImages; let loadImages; let dispatchedAction; const expectedArgs = { @@ -246,12 +246,12 @@ describe('requests thunkActions module', () => { learningContextId: selectors.app.learningContextId(testState), }; beforeEach(() => { - fetchAssets = jest.fn((args) => new Promise((resolve) => { - resolve({ data: { assets: { fetchAssets: args } } }); + fetchImages = jest.fn((args) => new Promise((resolve) => { + resolve({ data: { assets: { fetchImages: args } } }); })); - jest.spyOn(api, apiKeys.fetchAssets).mockImplementationOnce(fetchAssets); + jest.spyOn(api, apiKeys.fetchImages).mockImplementationOnce(fetchImages); loadImages = jest.spyOn(api, apiKeys.loadImages).mockImplementationOnce(() => ({})); - requests.fetchAssets({ ...fetchParams, onSuccess, onFailure })(dispatch, () => testState); + requests.fetchImages({ ...fetchParams, onSuccess, onFailure })(dispatch, () => testState); [[dispatchedAction]] = dispatch.mock.calls; }); it('dispatches networkRequest', () => { @@ -261,11 +261,11 @@ describe('requests thunkActions module', () => { expect(dispatchedAction.networkRequest.onSuccess).toEqual(onSuccess); expect(dispatchedAction.networkRequest.onFailure).toEqual(onFailure); }); - test('api.fetchAssets promise called with studioEndpointUrl and learningContextId', () => { - expect(fetchAssets).toHaveBeenCalledWith(expectedArgs); + test('api.fetchImages promise called with studioEndpointUrl and learningContextId', () => { + expect(fetchImages).toHaveBeenCalledWith(expectedArgs); }); test('promise is chained with api.loadImages', () => { - expect(loadImages).toHaveBeenCalledWith({ fetchAssets: expectedArgs }); + expect(loadImages).toHaveBeenCalledWith({ fetchImages: expectedArgs }); }); }); describe('fetchVideos', () => { diff --git a/src/editors/data/services/cms/api.js b/src/editors/data/services/cms/api.js index b97ea81c1..38abad715 100644 --- a/src/editors/data/services/cms/api.js +++ b/src/editors/data/services/cms/api.js @@ -26,9 +26,16 @@ export const apiMethods = { fetchStudioView: ({ blockId, studioEndpointUrl }) => get( urls.blockStudioView({ studioEndpointUrl, blockId }), ), - fetchAssets: ({ learningContextId, studioEndpointUrl }) => get( - urls.courseAssets({ studioEndpointUrl, learningContextId }), - ), + 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 }), ), diff --git a/src/editors/data/services/cms/api.test.js b/src/editors/data/services/cms/api.test.js index 2edb7ea7b..116efbf62 100644 --- a/src/editors/data/services/cms/api.test.js +++ b/src/editors/data/services/cms/api.test.js @@ -126,10 +126,17 @@ describe('cms api', () => { }); }); - describe('fetchAssets', () => { + describe('fetchImages', () => { it('should call get with url.courseAssets', () => { - apiMethods.fetchAssets({ learningContextId, studioEndpointUrl }); - expect(get).toHaveBeenCalledWith(urls.courseAssets({ studioEndpointUrl, learningContextId })); + apiMethods.fetchImages({ learningContextId, studioEndpointUrl, pageNumber: 0 }); + const params = { + asset_type: 'Images', + page: 0, + }; + expect(get).toHaveBeenCalledWith( + urls.courseAssets({ studioEndpointUrl, learningContextId }), + { params }, + ); }); }); diff --git a/src/editors/data/services/cms/mockApi.js b/src/editors/data/services/cms/mockApi.js index 2353bb748..5c4440b3e 100644 --- a/src/editors/data/services/cms/mockApi.js +++ b/src/editors/data/services/cms/mockApi.js @@ -69,7 +69,7 @@ export const fetchByUnitId = ({ blockId, studioEndpointUrl }) => mockPromise({ data: { ancestors: [{ id: 'unitUrl' }] }, }); // eslint-disable-next-line -export const fetchAssets = ({ learningContextId, studioEndpointUrl }) => mockPromise({ +export const fetchImages = ({ learningContextId, studioEndpointUrl }) => mockPromise({ data: { assets: [ { diff --git a/src/editors/data/services/cms/urls.js b/src/editors/data/services/cms/urls.js index 14b7ff338..de4e9f158 100644 --- a/src/editors/data/services/cms/urls.js +++ b/src/editors/data/services/cms/urls.js @@ -52,7 +52,7 @@ export const blockStudioView = ({ studioEndpointUrl, blockId }) => ( ); export const courseAssets = ({ studioEndpointUrl, learningContextId }) => ( - `${studioEndpointUrl}/assets/${learningContextId}/?page_size=500` + `${studioEndpointUrl}/assets/${learningContextId}/` ); export const thumbnailUpload = ({ studioEndpointUrl, learningContextId, videoId }) => ( diff --git a/src/editors/data/services/cms/urls.test.js b/src/editors/data/services/cms/urls.test.js index e9bbed2f0..bbaf1cbcf 100644 --- a/src/editors/data/services/cms/urls.test.js +++ b/src/editors/data/services/cms/urls.test.js @@ -119,7 +119,7 @@ describe('cms url methods', () => { describe('courseAssets', () => { it('returns url with studioEndpointUrl and learningContextId', () => { expect(courseAssets({ studioEndpointUrl, learningContextId })) - .toEqual(`${studioEndpointUrl}/assets/${learningContextId}/?page_size=500`); + .toEqual(`${studioEndpointUrl}/assets/${learningContextId}/`); }); }); describe('thumbnailUpload', () => { diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.js b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.js index efc396762..801f9c0dc 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.js +++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.js @@ -43,7 +43,14 @@ export const displayList = ({ sortBy, searchString, images }) => ( imageList: images, }).sort(sortFunctions[sortBy in sortKeys ? sortKeys[sortBy] : sortKeys.dateNewest])); -export const imgListHooks = ({ searchSortProps, setSelection, images }) => { +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, @@ -73,6 +80,9 @@ export const imgListHooks = ({ searchSortProps, setSelection, images }) => { 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: { @@ -118,7 +128,7 @@ export const fileInputHooks = ({ setSelection, clearSelection, imgList }) => { }, })) { dispatch( - thunkActions.app.uploadImage({ + thunkActions.app.uploadAsset({ file: selectedFile, setSelection, }), @@ -133,9 +143,19 @@ export const fileInputHooks = ({ setSelection, clearSelection, imgList }) => { }; }; -export const imgHooks = ({ setSelection, clearSelection, images }) => { +export const imgHooks = ({ + setSelection, + clearSelection, + images, + imageCount, +}) => { const searchSortProps = module.searchAndSortHooks(); - const imgList = module.imgListHooks({ setSelection, searchSortProps, images }); + const imgList = module.imgListHooks({ + setSelection, + searchSortProps, + images, + imageCount, + }); const fileInput = module.fileInputHooks({ setSelection, clearSelection, diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.test.js b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.test.js index 7128d857b..2f560e994 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.test.js +++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.test.js @@ -27,7 +27,7 @@ jest.mock('react-redux', () => { jest.mock('../../../data/redux', () => ({ thunkActions: { app: { - uploadImage: jest.fn(), + uploadAsset: jest.fn(), }, }, })); @@ -248,7 +248,7 @@ describe('SelectImageModal hooks', () => { hook.click(); expect(click).toHaveBeenCalled(); }); - describe('addFile (uploadImage args)', () => { + 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', () => { @@ -259,14 +259,14 @@ describe('SelectImageModal hooks', () => { expect(spies.checkValidFileSize.mock.calls.length).toEqual(1); expect(spies.checkValidFileSize).toHaveReturnedWith(false); }); - it('dispatches uploadImage thunkAction with the first target file and setSelection', () => { + 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.uploadImage({ + expect(dispatch).toHaveBeenCalledWith(thunkActions.app.uploadAsset({ file: testValue, setSelection, })); @@ -281,6 +281,7 @@ describe('SelectImageModal hooks', () => { 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(); @@ -292,9 +293,11 @@ describe('SelectImageModal hooks', () => { .mockReturnValueOnce(searchAndSortHooks); spies.file = jest.spyOn(hooks, hookKeys.fileInputHooks) .mockReturnValueOnce(fileInputHooks); - hook = hooks.imgHooks({ setSelection, clearSelection, images }); + hook = hooks.imgHooks({ + setSelection, clearSelection, images, imageCount, + }); }); - it('forwards fileInputHooks as fileInput, called with uploadImage prop', () => { + 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({ @@ -307,6 +310,7 @@ describe('SelectImageModal hooks', () => { setSelection, searchSortProps: searchAndSortHooks, images, + imageCount, }); }); it('forwards searchAndSortHooks as searchSortProps', () => { diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.jsx b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.jsx index 54c75e793..262433100 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.jsx +++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.jsx @@ -17,6 +17,7 @@ export const SelectImageModal = ({ isLoaded, isFetchError, isUploadError, + imageCount, }) => { const { galleryError, @@ -25,7 +26,12 @@ export const SelectImageModal = ({ galleryProps, searchSortProps, selectBtnProps, - } = hooks.imgHooks({ setSelection, clearSelection, images: images.current }); + } = hooks.imgHooks({ + setSelection, + clearSelection, + images: images.current, + imageCount, + }); const modalMessages = { confirmMsg: messages.nextButtonLabel, @@ -66,12 +72,14 @@ SelectImageModal.propTypes = { 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.fetchAssets }), - isFetchError: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchAssets }), + 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 = {}; diff --git a/src/editors/sharedComponents/SelectionModal/Gallery.jsx b/src/editors/sharedComponents/SelectionModal/Gallery.jsx index 5c54605e6..9124ce80d 100644 --- a/src/editors/sharedComponents/SelectionModal/Gallery.jsx +++ b/src/editors/sharedComponents/SelectionModal/Gallery.jsx @@ -11,6 +11,7 @@ import { import SelectableBox from '../SelectableBox'; import messages from './messages'; import GalleryCard from './GalleryCard'; +import GalleryLoadMoreButton from './GalleryLoadMoreButton'; export const Gallery = ({ galleryIsEmpty, @@ -23,9 +24,13 @@ export const Gallery = ({ height, isLoaded, thumbnailFallback, + allowLazyLoad, + fetchNextPage, + assetCount, }) => { const intl = useIntl(); - if (!isLoaded) { + + if (!isLoaded && !allowLazyLoad) { return (
- { displayList.map(asset => ( + {displayList.map(asset => ( )) } + {allowLazyLoad && ( + + )}
); }; @@ -84,6 +99,9 @@ Gallery.defaultProps = { height: '375px', show: true, thumbnailFallback: undefined, + allowLazyLoad: false, + fetchNextPage: null, + assetCount: 0, }; Gallery.propTypes = { show: PropTypes.bool, @@ -97,6 +115,9 @@ Gallery.propTypes = { 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 index efd818be4..bddbe3776 100644 --- a/src/editors/sharedComponents/SelectionModal/Gallery.test.jsx +++ b/src/editors/sharedComponents/SelectionModal/Gallery.test.jsx @@ -28,6 +28,9 @@ describe('TextEditor Image Gallery component', () => { 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', () => { diff --git a/src/editors/sharedComponents/SelectionModal/GalleryCard.jsx b/src/editors/sharedComponents/SelectionModal/GalleryCard.jsx index 062507eb4..6e73c9fb2 100644 --- a/src/editors/sharedComponents/SelectionModal/GalleryCard.jsx +++ b/src/editors/sharedComponents/SelectionModal/GalleryCard.jsx @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import { Badge, Image, + Truncate, } from '@openedx/paragon'; import { FormattedMessage, FormattedDate, FormattedTime } from '@edx/frontend-platform/i18n'; @@ -64,7 +65,9 @@ export const GalleryCard = ({ )}
-

{asset.displayName}

+

+ {asset.displayName} +

{ asset.transcripts && (
{ + 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 index 57ef43db6..ac9576faa 100644 --- a/src/editors/sharedComponents/SelectionModal/SearchSort.jsx +++ b/src/editors/sharedComponents/SelectionModal/SearchSort.jsx @@ -29,6 +29,7 @@ export const SearchSort = ({ onSwitchClick, }) => { const intl = useIntl(); + return ( diff --git a/src/editors/sharedComponents/SelectionModal/__snapshots__/Gallery.test.jsx.snap b/src/editors/sharedComponents/SelectionModal/__snapshots__/Gallery.test.jsx.snap index ad2464da5..a0651d470 100644 --- a/src/editors/sharedComponents/SelectionModal/__snapshots__/Gallery.test.jsx.snap +++ b/src/editors/sharedComponents/SelectionModal/__snapshots__/Gallery.test.jsx.snap @@ -43,6 +43,8 @@ exports[`TextEditor Image Gallery component component snapshot: loaded but no im } > - props.img.displayName + + props.img.displayName +

- props.img.displayName + + props.img.displayName +

- props.img.displayName + + props.img.displayName +

- props.img.displayName + + props.img.displayName +

- props.img.displayName + + props.img.displayName +

- props.img.displayName + + props.img.displayName +

useState(val), }); -export const addImagesAndDimensionsToRef = ({ imagesRef, assets, editorContentHtml }) => { - const imagesWithDimensions = module.filterAssets({ assets }).map((image) => { +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 }; }); - imagesRef.current = imagesWithDimensions; }; -export const useImages = ({ assets, editorContentHtml }) => { +export const useImages = ({ images, editorContentHtml }) => { const imagesRef = useRef([]); useEffect(() => { - module.addImagesAndDimensionsToRef({ imagesRef, assets, editorContentHtml }); - }, []); + module.addImagesAndDimensionsToRef({ imagesRef, images, editorContentHtml }); + }, [images]); return { imagesRef }; }; @@ -69,45 +70,45 @@ export const parseContentForLabels = ({ editor, updateContent }) => { } }; -export const replaceStaticwithAsset = ({ - editor, - imageUrls, +export const replaceStaticWithAsset = ({ + initialContent, + learningContextId, editorType, lmsEndpointUrl, - updateContent, }) => { - let content = editor.getContent(); - const imageSrcs = content.split('src="'); - imageSrcs.forEach(src => { + let content = initialContent; + const srcs = content.split(/(src="|src="|href="|href=")/g).filter( + src => src.startsWith('/static') || src.startsWith('/asset'), + ); + if (isEmpty(srcs)) { + return initialContent; + } + srcs.forEach(src => { const currentContent = content; let staticFullUrl; const isStatic = src.startsWith('/static/'); - const isExpandableAsset = src.startsWith('/assets/') && editorType === 'expandable'; - if ((isStatic || isExpandableAsset) && imageUrls.length > 0) { - const assetSrc = src.substring(0, src.indexOf('"')); - const assetName = assetSrc.replace(/\/assets\/.+[^/]\//g, ''); - const staticName = assetSrc.substring(8); - imageUrls.forEach((url) => { - if (isExpandableAsset && assetName === url.displayName) { - staticFullUrl = `${lmsEndpointUrl}${url.staticFullUrl}`; - } else if (staticName === url.displayName) { - staticFullUrl = url.staticFullUrl; - if (isExpandableAsset) { - staticFullUrl = `${lmsEndpointUrl}${url.staticFullUrl}`; - } - } - }); - if (staticFullUrl) { - const currentSrc = src.substring(0, src.indexOf('"')); - content = currentContent.replace(currentSrc, staticFullUrl); - if (editorType === 'expandable') { - updateContent(content); - } else { - editor.setContent(content); - } + const assetSrc = src.substring(0, src.indexOf('"')); + const staticName = assetSrc.substring(8); + const assetName = assetSrc.replace(/\/assets\/.+[^/]\//g, ''); + const displayName = isStatic ? staticName : assetName; + const isCorrectAssetFormat = 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); } }); + return content; }; export const getImageResizeHandler = ({ editor, imagesRef, setImage }) => () => { @@ -132,10 +133,10 @@ export const setupCustomBehavior = ({ openImgModal, openSourceCodeModal, editorType, - imageUrls, images, setImage, lmsEndpointUrl, + learningContextId, }) => (editor) => { // image upload button editor.ui.registry.addButton(tinyMCE.buttons.imageUploadButton, { @@ -188,18 +189,24 @@ export const setupCustomBehavior = ({ }); if (editorType === 'expandable') { editor.on('init', () => { - module.replaceStaticwithAsset({ - editor, - imageUrls, + const initialContent = editor.getContent(); + const newContent = module.replaceStaticWithAsset({ + initialContent, editorType, lmsEndpointUrl, - updateContent, + learningContextId, }); + updateContent(newContent); }); } editor.on('ExecCommand', (e) => { if (editorType === 'text' && e.command === 'mceFocus') { - module.replaceStaticwithAsset({ editor, imageUrls }); + const initialContent = editor.getContent(); + const newContent = module.replaceStaticWithAsset({ + initialContent, + learningContextId, + }); + editor.setContent(newContent); } if (e.command === 'RemoveFormat') { editor.formatter.remove('blockquote'); @@ -229,6 +236,7 @@ export const editorConfig = ({ updateContent, content, minHeight, + learningContextId, }) => { const { toolbar, @@ -267,7 +275,7 @@ export const editorConfig = ({ setImage: setSelection, content, images, - imageUrls: module.fetchImageUrls(images), + learningContextId, }), quickbars_insert_toolbar: quickbarsInsertToolbar, quickbars_selection_toolbar: quickbarsSelectionToolbar, @@ -380,16 +388,7 @@ export const openModalWithSelectedImage = ({ openImgModal(); }; -export const filterAssets = ({ assets }) => { - let images = []; - const assetsList = Object.values(assets); - if (assetsList.length > 0) { - images = assetsList.filter(asset => asset?.contentType?.startsWith('image/')); - } - return images; -}; - -export const setAssetToStaticUrl = ({ editorValue, assets, lmsEndpointUrl }) => { +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 @@ -401,42 +400,20 @@ export const setAssetToStaticUrl = ({ editorValue, assets, lmsEndpointUrl }) => const regExLmsEndpointUrl = RegExp(lmsEndpointUrl, 'g'); let content = editorValue.replace(regExLmsEndpointUrl, ''); - const assetUrls = []; - const assetsList = Object.values(assets); - assetsList.forEach(asset => { - assetUrls.push({ portableUrl: asset.portableUrl, displayName: asset.displayName }); - }); const assetSrcs = typeof content === 'string' ? content.split(/(src="|src="|href="|href=")/g) : []; assetSrcs.forEach(src => { - if (src.startsWith('/asset') && assetUrls.length > 0) { + if (src.startsWith('/asset')) { const assetBlockName = src.substring(src.indexOf('@') + 1, src.search(/("|")/)); const nameFromEditorSrc = assetBlockName.substring(assetBlockName.indexOf('@') + 1); - const nameFromStudioSrc = assetBlockName.substring(assetBlockName.indexOf('/') + 1); - let portableUrl; - assetUrls.forEach((url) => { - const displayName = url.displayName.replace(/\s/g, '_'); - if (displayName === nameFromEditorSrc || displayName === nameFromStudioSrc) { - portableUrl = url.portableUrl; - } - }); - if (portableUrl) { - const currentSrc = src.substring(0, src.search(/("|")/)); - const updatedContent = content.replace(currentSrc, portableUrl); - content = updatedContent; - } + const portableUrl = getStaticUrl({ displayName: nameFromEditorSrc }); + const currentSrc = src.substring(0, src.search(/("|")/)); + const updatedContent = content.replace(currentSrc, portableUrl); + content = updatedContent; } }); return content; }; -export const fetchImageUrls = (images) => { - const imageUrls = []; - images.current.forEach(image => { - imageUrls.push({ staticFullUrl: image.staticFullUrl, displayName: image.displayName }); - }); - return imageUrls; -}; - export const selectedImage = (val) => { const [selection, setSelection] = module.state.imageSelection(val); return { diff --git a/src/editors/sharedComponents/TinyMceWidget/hooks.test.js b/src/editors/sharedComponents/TinyMceWidget/hooks.test.js index 2eb52c412..67096eb91 100644 --- a/src/editors/sharedComponents/TinyMceWidget/hooks.test.js +++ b/src/editors/sharedComponents/TinyMceWidget/hooks.test.js @@ -49,7 +49,7 @@ const mockImage = { height: initialContentHeight, }; -const mockAssets = { +const mockImages = { [mockImage.id]: mockImage, }; @@ -181,41 +181,32 @@ describe('TinyMceEditor hooks', () => { }); }); - describe('replaceStaticwithAsset', () => { - test('it calls getContent and setContent for text editor', () => { - const editor = { getContent: jest.fn(() => ''), setContent: jest.fn() }; - const imageUrls = [{ staticFullUrl: '/assets/soMEImagEURl1.jpeg', displayName: 'soMEImagEURl1.jpeg' }]; - const lmsEndpointUrl = 'sOmEvaLue.cOm'; - module.replaceStaticwithAsset({ editor, imageUrls, lmsEndpointUrl }); - expect(editor.getContent).toHaveBeenCalled(); - expect(editor.setContent).toHaveBeenCalled(); + describe('replaceStaticWithAsset', () => { + const initialContent = 'test'; + const learningContextId = 'course+test+run'; + const lmsEndpointUrl = 'sOmEvaLue.cOm'; + it('it returns updated src for text editor to update content', () => { + const expected = 'test'; + const actual = module.replaceStaticWithAsset({ initialContent, learningContextId }); + expect(actual).toEqual(expected); }); - test('it calls getContent and updateContent for expandable editor', () => { - const editor = { getContent: jest.fn(() => '') }; - const imageUrls = [{ staticFullUrl: '/assets/soMEImagEURl1.jpeg', displayName: 'soMEImagEURl1.jpeg' }]; - const lmsEndpointUrl = 'sOmEvaLue.cOm'; + it('it returs updated src with absolute url for expandable editor to update content', () => { const editorType = 'expandable'; - const updateContent = jest.fn(); - module.replaceStaticwithAsset({ - editor, - imageUrls, + const expected = `test`; + const actual = module.replaceStaticWithAsset({ + initialContent, editorType, lmsEndpointUrl, - updateContent, + learningContextId, }); - expect(editor.getContent).toHaveBeenCalled(); - expect(updateContent).toHaveBeenCalled(); + expect(actual).toEqual(expected); }); }); describe('setAssetToStaticUrl', () => { it('returns content with updated img links', () => { - const editorValue = ' testing link'; - const assets = [ - { portableUrl: '/static/soMEImagEURl', displayName: 'soMEImagEURl' }, - { portableUrl: '/static/soME_ImagE_URl1', displayName: 'soME ImagE URl1' }, - ]; + const editorValue = ' testing link'; const lmsEndpointUrl = 'sOmEvaLue.cOm'; - const content = module.setAssetToStaticUrl({ editorValue, assets, lmsEndpointUrl }); + const content = module.setAssetToStaticUrl({ editorValue, lmsEndpointUrl }); expect(content).toEqual(' testing link'); }); }); @@ -228,6 +219,7 @@ describe('TinyMceEditor hooks', () => { studioEndpointUrl: 'sOmEoThEruRl.cOm', images: mockImagesRef, isLibrary: false, + learningContextId: 'course+org+run', }; const evt = 'fakeEvent'; const editor = 'myEditor'; @@ -344,27 +336,14 @@ describe('TinyMceEditor hooks', () => { openImgModal: props.openImgModal, openSourceCodeModal: props.openSourceCodeModal, setImage: props.setSelection, - imageUrls: module.fetchImageUrls(props.images), images: mockImagesRef, lmsEndpointUrl: props.lmsEndpointUrl, + learningContextId: props.learningContextId, }), ); }); }); - describe('filterAssets', () => { - const emptyAssets = {}; - const assets = { sOmEaSsET: { contentType: 'image/' } }; - test('returns an empty array', () => { - const emptyFilterAssets = module.filterAssets({ assets: emptyAssets }); - expect(emptyFilterAssets).toEqual([]); - }); - test('returns filtered array of images', () => { - const FilteredAssets = module.filterAssets({ assets }); - expect(FilteredAssets).toEqual([{ contentType: 'image/' }]); - }); - }); - describe('imgModalToggle', () => { const hookKey = state.keys.isImageModalOpen; beforeEach(() => { @@ -522,11 +501,10 @@ describe('TinyMceEditor hooks', () => { describe('addImagesAndDimensionsToRef', () => { it('should add images to ref', () => { const imagesRef = { current: null }; - const assets = { ...mockAssets, height: undefined, width: undefined }; module.addImagesAndDimensionsToRef( { imagesRef, - assets, + images: mockImages, editorContentHtml: mockEditorContentHtml, }, ); diff --git a/src/editors/sharedComponents/TinyMceWidget/index.jsx b/src/editors/sharedComponents/TinyMceWidget/index.jsx index d2c8a3ff1..151d08cba 100644 --- a/src/editors/sharedComponents/TinyMceWidget/index.jsx +++ b/src/editors/sharedComponents/TinyMceWidget/index.jsx @@ -41,7 +41,8 @@ export const TinyMceWidget = ({ id, editorContentHtml, // editorContent in html form // redux - assets, + learningContextId, + images, isLibrary, lmsEndpointUrl, studioEndpointUrl, @@ -50,7 +51,7 @@ export const TinyMceWidget = ({ }) => { const { isImgOpen, openImgModal, closeImgModal } = hooks.imgModalToggle(); const { isSourceCodeOpen, openSourceCodeModal, closeSourceCodeModal } = hooks.sourceCodeModalToggle(editorRef); - const { imagesRef } = hooks.useImages({ assets, editorContentHtml }); + const { imagesRef } = hooks.useImages({ images, editorContentHtml }); const imageSelection = hooks.selectedImage(null); @@ -85,6 +86,7 @@ export const TinyMceWidget = ({ editorType, editorRef, isLibrary, + learningContextId, lmsEndpointUrl, studioEndpointUrl, images: imagesRef, @@ -103,7 +105,7 @@ TinyMceWidget.defaultProps = { editorRef: null, lmsEndpointUrl: null, studioEndpointUrl: null, - assets: null, + images: null, id: null, disabled: false, editorContentHtml: undefined, @@ -112,9 +114,10 @@ TinyMceWidget.defaultProps = { ...editorConfigDefaultProps, }; TinyMceWidget.propTypes = { + learningContextId: PropTypes.string, editorType: PropTypes.string, isLibrary: PropTypes.bool, - assets: PropTypes.shape({}), + images: PropTypes.shape({}), editorRef: PropTypes.shape({}), lmsEndpointUrl: PropTypes.string, studioEndpointUrl: PropTypes.string, @@ -127,10 +130,11 @@ TinyMceWidget.propTypes = { }; export const mapStateToProps = (state) => ({ - assets: selectors.app.assets(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 default (connect(mapStateToProps)(TinyMceWidget)); diff --git a/src/editors/sharedComponents/TinyMceWidget/index.test.jsx b/src/editors/sharedComponents/TinyMceWidget/index.test.jsx index 4bfb7a081..1b2a51b55 100644 --- a/src/editors/sharedComponents/TinyMceWidget/index.test.jsx +++ b/src/editors/sharedComponents/TinyMceWidget/index.test.jsx @@ -30,7 +30,8 @@ jest.mock('../../data/redux', () => ({ lmsEndpointUrl: jest.fn(state => ({ lmsEndpointUrl: state })), studioEndpointUrl: jest.fn(state => ({ studioEndpointUrl: state })), isLibrary: jest.fn(state => ({ isLibrary: state })), - assets: jest.fn(state => ({ assets: state })), + images: jest.fn(state => ({ images: state })), + learningContextId: jest.fn(state => ({ learningContextId: state })), }, }, })); @@ -52,7 +53,6 @@ jest.mock('./hooks', () => ({ setSelection: jest.fn().mockName('hooks.selectedImage.setSelection'), clearSelection: jest.fn().mockName('hooks.selectedImage.clearSelection'), })), - filterAssets: jest.fn(() => [{ staTICUrl: staticUrl }]), useImages: jest.fn(() => ({ imagesRef: { current: [{ externalUrl: staticUrl }] } })), })); @@ -70,12 +70,13 @@ describe('TinyMceWidget', () => { editorType: 'text', editorRef: { current: { value: 'something' } }, isLibrary: false, - assets: { sOmEaSsET: { staTICUrl: staticUrl } }, + images: { sOmEaSsET: { staTICUrl: staticUrl } }, lmsEndpointUrl: 'sOmEvaLue.cOm', studioEndpointUrl: 'sOmEoThERvaLue.cOm', disabled: false, id: 'sOMeiD', updateContent: () => ({}), + learningContextId: 'course+org+run', }; describe('snapshots', () => { imgModalToggle.mockReturnValue({ @@ -114,15 +115,20 @@ describe('TinyMceWidget', () => { mapStateToProps(testState).studioEndpointUrl, ).toEqual(selectors.app.studioEndpointUrl(testState)); }); - test('assets from app.assets', () => { + test('images from app.images', () => { expect( - mapStateToProps(testState).assets, - ).toEqual(selectors.app.assets(testState)); + 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/utils.js b/src/editors/sharedComponents/TinyMceWidget/utils.js new file mode 100644 index 000000000..b6e56c655 --- /dev/null +++ b/src/editors/sharedComponents/TinyMceWidget/utils.js @@ -0,0 +1,15 @@ +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 ''; +};