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
This commit is contained in:
Kristin Aoki
2024-06-17 09:52:49 -04:00
committed by GitHub
parent 252ad6a6b9
commit f3ae225d64
47 changed files with 635 additions and 404 deletions

View File

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

View File

@@ -23,7 +23,7 @@ exports[`SolutionWidget render snapshot: renders correct default 1`] = `
/>
</div>
<[object Object]
editorContentHtml="This is my question"
editorContentHtml="This is my solution"
editorType="solution"
id="solution"
minHeight={150}

View File

@@ -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 (
<div className="tinyMceWidget mt-4 text-primary-500">
@@ -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));

View File

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

View File

@@ -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 (
<div className="tinyMceWidget">
@@ -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));

View File

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

View File

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

View File

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

View File

@@ -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 (
<div className="text-center p-6">
<Spinner
@@ -55,18 +53,15 @@ export const ProblemEditor = ({
};
ProblemEditor.defaultProps = {
assetsFinished: null,
returnFunction: null,
};
ProblemEditor.propTypes = {
onClose: PropTypes.func.isRequired,
returnFunction: PropTypes.func,
// redux
assetsFinished: PropTypes.bool,
advancedSettingsFinished: PropTypes.bool.isRequired,
blockFinished: PropTypes.bool.isRequired,
blockFailed: PropTypes.bool.isRequired,
studioViewFinished: PropTypes.bool.isRequired,
problemType: PropTypes.string.isRequired,
initializeProblemEditor: PropTypes.func.isRequired,
blockValue: PropTypes.objectOf(PropTypes.shape({})).isRequired,
@@ -75,10 +70,8 @@ ProblemEditor.propTypes = {
export const mapStateToProps = (state) => ({
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 }),
});

View File

@@ -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(<ProblemEditor {...props} blockFinished />);
expect(wrapper.instance.findByType(Spinner)).toBeTruthy();
});
test('studio view loaded, block and assets not yet loaded, Spinner appears', () => {
const wrapper = shallow(<ProblemEditor {...props} studioViewFinished />);
expect(wrapper.instance.findByType(Spinner)).toBeTruthy();
});
test('assets loaded, block and studio view not yet loaded, Spinner appears', () => {
const wrapper = shallow(<ProblemEditor {...props} assetsFinished />);
expect(wrapper.instance.findByType(Spinner)).toBeTruthy();
});
test('advanceSettings loaded, block and studio view not yet loaded, Spinner appears', () => {
const wrapper = shallow(<ProblemEditor {...props} advancedSettingsFinished />);
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,

View File

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

View File

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

View File

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

View File

@@ -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 (
<RawEditor
editorRef={editorRef}
@@ -48,7 +52,7 @@ export const TextEditor = ({
<TinyMceWidget
editorType="text"
editorRef={editorRef}
editorContentHtml={blockValue ? blockValue.data.data : ''}
editorContentHtml={editorContent}
setEditorRef={setEditorRef}
minHeight={500}
height="100%"
@@ -59,7 +63,7 @@ export const TextEditor = ({
return (
<EditorContainer
getContent={hooks.getContent({ editorRef, isRaw, assets })}
getContent={hooks.getContent({ editorRef, showRawEditor })}
onClose={onClose}
returnFunction={returnFunction}
>
@@ -68,7 +72,7 @@ export const TextEditor = ({
<FormattedMessage {...messages.couldNotLoadTextContext} />
</Toast>
{(!assetsFinished)
{(!blockFinished)
? (
<div className="text-center p-6">
<Spinner
@@ -84,9 +88,7 @@ export const TextEditor = ({
};
TextEditor.defaultProps = {
blockValue: null,
isRaw: null,
assetsFinished: null,
assets: null,
blockFinished: null,
returnFunction: null,
};
TextEditor.propTypes = {
@@ -98,9 +100,9 @@ TextEditor.propTypes = {
}),
blockFailed: PropTypes.bool.isRequired,
initializeEditor: PropTypes.func.isRequired,
isRaw: PropTypes.bool,
assetsFinished: PropTypes.bool,
assets: PropTypes.shape({}),
showRawEditor: PropTypes.bool.isRequired,
blockFinished: PropTypes.bool,
learningContextId: PropTypes.string.isRequired,
// inject
intl: intlShape.isRequired,
};
@@ -108,9 +110,9 @@ TextEditor.propTypes = {
export const mapStateToProps = (state) => ({
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 = {

View File

@@ -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(<TextEditor {...props} />).snapshot).toMatchSnapshot();
});
test('not yet loaded, Spinner appears', () => {
expect(shallow(<TextEditor {...props} assetsFinished={false} />).snapshot).toMatchSnapshot();
expect(shallow(<TextEditor {...props} blockFinished={false} />).snapshot).toMatchSnapshot();
});
test('loaded, raw editor', () => {
expect(shallow(<TextEditor {...props} isRaw />).snapshot).toMatchSnapshot();
expect(shallow(<TextEditor {...props} showRawEditor />).snapshot).toMatchSnapshot();
});
test('block failed to load, Toast is shown', () => {
expect(shallow(<TextEditor {...props} blockFailed />).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).assetsFinished,
).toEqual(selectors.requests.isFinished(testState, { requestKey: RequestKeys.fetchAssets }));
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));
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
<div style={{
position: 'absolute',
@@ -65,7 +70,7 @@ export const Gallery = ({
type="radio"
value={highlighted}
>
{ displayList.map(asset => (
{displayList.map(asset => (
<GalleryCard
key={asset.id}
asset={asset}
@@ -74,6 +79,16 @@ export const Gallery = ({
/>
)) }
</SelectableBox.Set>
{allowLazyLoad && (
<GalleryLoadMoreButton
{...{
fetchNextPage,
assetCount,
displayListLength: displayList.length,
isLoaded,
}}
/>
)}
</div>
);
};
@@ -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;

View File

@@ -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(<IntlProvider locale="en">{component}</IntlProvider>);
test('snapshot: not loaded, show spinner', () => {

View File

@@ -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 = ({
)}
</div>
<div className="card-text px-3 py-2" style={{ marginTop: '10px' }}>
<h3 className="text-primary-500">{asset.displayName}</h3>
<h3 className="text-primary-500">
<Truncate>{asset.displayName}</Truncate>
</h3>
{ asset.transcripts && (
<div style={{ margin: '0 0 5px 0' }}>
<LanguageNamesWidget

View File

@@ -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: <Icon src={ExpandMore} />,
pending: <Icon src={SpinnerSimple} className="icon-spin" />,
},
disabledStates: ['pending'],
variant: 'primary',
};
return (
<div className="row justify-content-center py-3">
{displayListLength !== assetCount && (
<StatefulButton
{...buttonProps}
onClick={handlePageChange}
state={buttonState}
/>
)}
</div>
);
};
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;

View File

@@ -29,6 +29,7 @@ export const SearchSort = ({
onSwitchClick,
}) => {
const intl = useIntl();
return (
<ActionRow>
<Form.Group style={{ margin: 0 }}>

View File

@@ -43,6 +43,8 @@ exports[`TextEditor Image Gallery component component snapshot: loaded but no im
}
>
<Gallery
allowLazyLoad={false}
assetCount={0}
displayList={
[
{
@@ -62,6 +64,7 @@ exports[`TextEditor Image Gallery component component snapshot: loaded but no im
"id": "emptyGalleryMsg",
}
}
fetchNextPage={null}
galleryIsEmpty={true}
height="375px"
highlighted="props.highlighted"
@@ -117,6 +120,8 @@ exports[`TextEditor Image Gallery component component snapshot: loaded but searc
}
>
<Gallery
allowLazyLoad={false}
assetCount={0}
displayList={
[
{
@@ -136,6 +141,7 @@ exports[`TextEditor Image Gallery component component snapshot: loaded but searc
"id": "emptyGalleryMsg",
}
}
fetchNextPage={null}
galleryIsEmpty={false}
height="375px"
highlighted="props.highlighted"
@@ -191,6 +197,8 @@ exports[`TextEditor Image Gallery component component snapshot: loaded, show gal
}
>
<Gallery
allowLazyLoad={false}
assetCount={0}
displayList={
[
{
@@ -210,6 +218,7 @@ exports[`TextEditor Image Gallery component component snapshot: loaded, show gal
"id": "emptyGalleryMsg",
}
}
fetchNextPage={null}
galleryIsEmpty={false}
height="375px"
highlighted="props.highlighted"
@@ -265,6 +274,8 @@ exports[`TextEditor Image Gallery component component snapshot: not loaded, show
}
>
<Gallery
allowLazyLoad={false}
assetCount={0}
displayList={
[
{
@@ -284,6 +295,7 @@ exports[`TextEditor Image Gallery component component snapshot: not loaded, show
"id": "emptyGalleryMsg",
}
}
fetchNextPage={null}
galleryIsEmpty={false}
height="375px"
highlighted="props.highlighted"

View File

@@ -59,7 +59,14 @@ exports[`GalleryCard component snapshot with duration badge 1`] = `
<h3
className="text-primary-500"
>
props.img.displayName
<Truncate
elementType="div"
ellipsis="..."
lines={1}
whiteSpace={false}
>
props.img.displayName
</Truncate>
</h3>
<p
className="text-gray-500"
@@ -136,7 +143,14 @@ exports[`GalleryCard component snapshot with duration transcripts 1`] = `
<h3
className="text-primary-500"
>
props.img.displayName
<Truncate
elementType="div"
ellipsis="..."
lines={1}
whiteSpace={false}
>
props.img.displayName
</Truncate>
</h3>
<div
style={
@@ -228,7 +242,14 @@ exports[`GalleryCard component snapshot with status badge 1`] = `
<h3
className="text-primary-500"
>
props.img.displayName
<Truncate
elementType="div"
ellipsis="..."
lines={1}
whiteSpace={false}
>
props.img.displayName
</Truncate>
</h3>
<p
className="text-gray-500"
@@ -306,7 +327,14 @@ exports[`GalleryCard component snapshot with thumbnail fallback and load error 1
<h3
className="text-primary-500"
>
props.img.displayName
<Truncate
elementType="div"
ellipsis="..."
lines={1}
whiteSpace={false}
>
props.img.displayName
</Truncate>
</h3>
<p
className="text-gray-500"
@@ -384,7 +412,14 @@ exports[`GalleryCard component snapshot with thumbnail fallback and no error 1`]
<h3
className="text-primary-500"
>
props.img.displayName
<Truncate
elementType="div"
ellipsis="..."
lines={1}
whiteSpace={false}
>
props.img.displayName
</Truncate>
</h3>
<p
className="text-gray-500"
@@ -461,7 +496,14 @@ exports[`GalleryCard component snapshot: dateAdded=12345 1`] = `
<h3
className="text-primary-500"
>
props.img.displayName
<Truncate
elementType="div"
ellipsis="..."
lines={1}
whiteSpace={false}
>
props.img.displayName
</Truncate>
</h3>
<p
className="text-gray-500"

View File

@@ -56,6 +56,7 @@ export const SelectionModal = ({
isLoaded,
...galleryProps,
};
return (
<BaseModal
close={close}

View File

@@ -45,6 +45,7 @@ exports[`TinyMceWidget snapshots ImageUploadModal is not rendered 1`] = `
},
"initializeEditor": undefined,
"isLibrary": true,
"learningContextId": "course+org+run",
"lmsEndpointUrl": "sOmEvaLue.cOm",
"minHeight": undefined,
"openImgModal": [MockFunction modal.openModal],
@@ -122,6 +123,7 @@ exports[`TinyMceWidget snapshots SourcecodeModal is not rendered 1`] = `
},
"initializeEditor": undefined,
"isLibrary": false,
"learningContextId": "course+org+run",
"lmsEndpointUrl": "sOmEvaLue.cOm",
"minHeight": undefined,
"openImgModal": [MockFunction modal.openModal],
@@ -210,6 +212,7 @@ exports[`TinyMceWidget snapshots renders as expected with default behavior 1`] =
},
"initializeEditor": undefined,
"isLibrary": false,
"learningContextId": "course+org+run",
"lmsEndpointUrl": "sOmEvaLue.cOm",
"minHeight": undefined,
"openImgModal": [MockFunction modal.openModal],

View File

@@ -5,11 +5,13 @@ import {
useEffect,
} from 'react';
import { a11ycheckerCss } from 'frontend-components-tinymce-advanced-plugins';
import { isEmpty } from 'lodash-es';
import tinyMCEStyles from '../../data/constants/tinyMCEStyles';
import { StrictDict } from '../../utils';
import pluginConfig from './pluginConfig';
import * as module from './hooks';
import tinyMCE from '../../data/constants/tinyMCE';
import { getRelativeUrl, getStaticUrl } from './utils';
export const state = StrictDict({
// eslint-disable-next-line react-hooks/rules-of-hooks
@@ -22,21 +24,20 @@ export const state = StrictDict({
refReady: (val) => 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=&quot;|href="|href=&quot)/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/<asset hash>/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=&quot;|href="|href=&quot)/g) : [];
assetSrcs.forEach(src => {
if (src.startsWith('/asset') && assetUrls.length > 0) {
if (src.startsWith('/asset')) {
const assetBlockName = src.substring(src.indexOf('@') + 1, src.search(/("|&quot;)/));
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(/("|&quot;)/));
const updatedContent = content.replace(currentSrc, portableUrl);
content = updatedContent;
}
const portableUrl = getStaticUrl({ displayName: nameFromEditorSrc });
const currentSrc = src.substring(0, src.search(/("|&quot;)/));
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 {

View File

@@ -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(() => '<img src="/static/soMEImagEURl1.jpeg"/>'), 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 = '<img src="/static/soMEImagEURl1.jpeg"/><a href="/assets/v1/some-key/test.pdf">test</a>';
const learningContextId = 'course+test+run';
const lmsEndpointUrl = 'sOmEvaLue.cOm';
it('it returns updated src for text editor to update content', () => {
const expected = '<img src="/asset+test+run+type@asset+block@soMEImagEURl1.jpeg"/><a href="/asset+test+run+type@asset+block@test.pdf">test</a>';
const actual = module.replaceStaticWithAsset({ initialContent, learningContextId });
expect(actual).toEqual(expected);
});
test('it calls getContent and updateContent for expandable editor', () => {
const editor = { getContent: jest.fn(() => '<img src="/static/soMEImagEURl1.jpeg"/>') };
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 = `<img src="${lmsEndpointUrl}/asset+test+run+type@asset+block@soMEImagEURl1.jpeg"/><a href="${lmsEndpointUrl}/asset+test+run+type@asset+block@test.pdf">test</a>`;
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 = '<img src="/asset@asset-block/soME_ImagE_URl1"/> <a href="/asset@soMEImagEURl">testing link</a>';
const assets = [
{ portableUrl: '/static/soMEImagEURl', displayName: 'soMEImagEURl' },
{ portableUrl: '/static/soME_ImagE_URl1', displayName: 'soME ImagE URl1' },
];
const editorValue = '<img src="/asset@/soME_ImagE_URl1"/> <a href="/asset@soMEImagEURl">testing link</a>';
const lmsEndpointUrl = 'sOmEvaLue.cOm';
const content = module.setAssetToStaticUrl({ editorValue, assets, lmsEndpointUrl });
const content = module.setAssetToStaticUrl({ editorValue, lmsEndpointUrl });
expect(content).toEqual('<img src="/static/soME_ImagE_URl1"/> <a href="/static/soMEImagEURl">testing link</a>');
});
});
@@ -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,
},
);

View File

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

View File

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

View File

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