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

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