feat: upload images to v2 library components from the TinyMCE in library editor (#1458)

This commit is contained in:
Diana Olarte
2024-11-19 05:38:08 +11:00
committed by GitHub
parent ba48a273a1
commit f740f57454
27 changed files with 486 additions and 86 deletions

View File

@@ -11,7 +11,7 @@ import EditorPage from './EditorPage';
// Mock this plugins component:
jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' }));
// Always mock out the "fetch course images" endpoint:
jest.spyOn(editorCmsApi, 'fetchImages').mockImplementation(async () => ( // eslint-disable-next-line
jest.spyOn(editorCmsApi, 'fetchCourseImages').mockImplementation(async () => ( // eslint-disable-next-line
{ data: { assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0 } }
));
// Mock out the 'get ancestors' API:

View File

@@ -13,7 +13,7 @@ import EditorPage from '../../EditorPage';
// Mock this plugins component:
jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' }));
// Always mock out the "fetch course images" endpoint:
jest.spyOn(editorCmsApi, 'fetchImages').mockImplementation(async () => ( // eslint-disable-next-line
jest.spyOn(editorCmsApi, 'fetchCourseImages').mockImplementation(async () => ( // eslint-disable-next-line
{ data: { assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0 } }
));
// Mock out the 'get ancestors' API:

View File

@@ -53,6 +53,7 @@ const app = createSlice({
images: { ...state.images, ...payload.images },
imageCount: payload.imageCount,
}),
resetImages: (state) => ({ ...state, images: {}, imageCount: 0 }),
setVideos: (state, { payload }) => ({ ...state, videos: payload }),
setCourseDetails: (state, { payload }) => ({ ...state, courseDetails: payload }),
setShowRawEditor: (state, { payload }) => ({

View File

@@ -103,12 +103,8 @@ export const initialize = (data) => (dispatch) => {
dispatch(module.fetchCourseDetails());
break;
case 'html':
if (isLibraryKey(data.learningContextId)) {
// eslint-disable-next-line no-console
console.log('Not fetching image assets - not implemented yet for content libraries.');
} else {
dispatch(module.fetchImages({ pageNumber: 0 }));
}
if (isLibraryKey(data.learningContextId)) { dispatch(actions.app.resetImages()); }
dispatch(module.fetchImages({ pageNumber: 0 }));
break;
default:
break;

View File

@@ -1,4 +1,4 @@
import { StrictDict } from '../../../utils';
import { StrictDict, parseLibraryImageData, getLibraryImageAssets } from '../../../utils';
import { RequestKeys } from '../../constants/requests';
import api, { loadImages } from '../../services/cms/api';
@@ -10,6 +10,8 @@ import { selectors as appSelectors } from '../app';
// should be re-thought and cleaned up to avoid this pattern.
// eslint-disable-next-line import/no-self-import
import * as module from './requests';
import { isLibraryKey } from '../../../../generic/key-utils';
import { acceptedImgKeys } from '../../../sharedComponents/ImageUploadModal/SelectImageModal/utils';
// Similar to `import { actions, selectors } from '..';` but avoid circular imports:
const actions = { requests: requestsActions };
@@ -121,25 +123,55 @@ export const saveBlock = ({ content, ...rest }) => (dispatch, getState) => {
}));
};
export const uploadAsset = ({ asset, ...rest }) => (dispatch, getState) => {
const learningContextId = selectors.app.learningContextId(getState());
dispatch(module.networkRequest({
requestKey: RequestKeys.uploadAsset,
promise: api.uploadAsset({
learningContextId: selectors.app.learningContextId(getState()),
learningContextId,
asset,
studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
blockId: selectors.app.blockId(getState()),
}).then((resp) => {
if (isLibraryKey(learningContextId)) {
return ({
...resp,
data: { asset: parseLibraryImageData(resp.data) },
});
}
return resp;
}),
...rest,
}));
};
export const fetchImages = ({ pageNumber, ...rest }) => (dispatch, getState) => {
const learningContextId = selectors.app.learningContextId(getState());
if (isLibraryKey(learningContextId)) {
dispatch(module.networkRequest({
requestKey: RequestKeys.fetchImages,
promise: api
.fetchLibraryImages({
pageNumber,
blockId: selectors.app.blockId(getState()),
studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
learningContextId,
})
.then(({ data }) => {
const images = getLibraryImageAssets(data.files, Object.keys(acceptedImgKeys));
return { images, imageCount: Object.keys(images).length };
}),
...rest,
}));
return;
}
dispatch(module.networkRequest({
requestKey: RequestKeys.fetchImages,
promise: api
.fetchImages({
.fetchCourseImages({
pageNumber,
blockId: selectors.app.blockId(getState()),
studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
learningContextId: selectors.app.learningContextId(getState()),
learningContextId,
})
.then(({ data }) => ({ images: loadImages(data.assets), imageCount: data.totalCount })),
...rest,

View File

@@ -1,4 +1,4 @@
import { keyStore } from '../../../utils';
import { keyStore, parseLibraryImageData, getLibraryImageAssets } from '../../../utils';
import { RequestKeys } from '../../constants/requests';
import api from '../../services/cms/api';
import * as requests from './requests';
@@ -26,7 +26,8 @@ jest.mock('../../services/cms/api', () => ({
fetchByUnitId: ({ id, url }) => ({ id, url }),
fetchCourseDetails: (args) => args,
saveBlock: (args) => args,
fetchImages: ({ id, url }) => ({ id, url }),
fetchCourseImages: ({ id, url }) => ({ id, url }),
fetchLibraryImages: ({ id, url }) => ({ id, url }),
fetchVideos: ({ id, url }) => ({ id, url }),
uploadAsset: (args) => args,
loadImages: jest.fn(),
@@ -40,6 +41,12 @@ jest.mock('../../services/cms/api', () => ({
uploadVideo: (args) => args,
}));
jest.mock('../../../utils', () => ({
...jest.requireActual('../../../utils'),
parseLibraryImageData: jest.fn(),
getLibraryImageAssets: jest.fn(() => ({})),
}));
const apiKeys = keyStore(api);
let dispatch;
@@ -241,31 +248,59 @@ describe('requests thunkActions module', () => {
let fetchImages;
let loadImages;
let dispatchedAction;
const expectedArgs = {
studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
learningContextId: selectors.app.learningContextId(testState),
};
beforeEach(() => {
fetchImages = jest.fn((args) => new Promise((resolve) => {
resolve({ data: { assets: { fetchImages: args } } });
}));
jest.spyOn(api, apiKeys.fetchImages).mockImplementationOnce(fetchImages);
loadImages = jest.spyOn(api, apiKeys.loadImages).mockImplementationOnce(() => ({}));
requests.fetchImages({ ...fetchParams, onSuccess, onFailure })(dispatch, () => testState);
[[dispatchedAction]] = dispatch.mock.calls;
describe('courses', () => {
beforeEach(() => {
fetchImages = jest.fn((args) => new Promise((resolve) => {
resolve({ data: { assets: { fetchImages: args } } });
}));
jest.spyOn(api, apiKeys.fetchCourseImages).mockImplementationOnce(fetchImages);
loadImages = jest.spyOn(api, apiKeys.loadImages).mockImplementationOnce(() => ({}));
requests.fetchImages({ ...fetchParams, onSuccess, onFailure })(dispatch, () => testState);
[[dispatchedAction]] = dispatch.mock.calls;
});
const expectedArgs = {
blockId: selectors.app.blockId(testState),
studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
learningContextId: selectors.app.learningContextId(testState),
};
it('dispatches networkRequest', () => {
expect(dispatchedAction.networkRequest).not.toEqual(undefined);
});
test('forwards onSuccess and onFailure', () => {
expect(dispatchedAction.networkRequest.onSuccess).toEqual(onSuccess);
expect(dispatchedAction.networkRequest.onFailure).toEqual(onFailure);
});
test('api.fetchImages promise called with studioEndpointUrl and learningContextId', () => {
expect(fetchImages).toHaveBeenCalledWith(expectedArgs);
});
test('promise is chained with api.loadImages', () => {
expect(loadImages).toHaveBeenCalledWith({ fetchImages: expectedArgs });
});
test('promise is chained with api.loadImages', () => {
expect(loadImages).toHaveBeenCalledWith({ fetchImages: expectedArgs });
});
});
it('dispatches networkRequest', () => {
expect(dispatchedAction.networkRequest).not.toEqual(undefined);
});
test('forwards onSuccess and onFailure', () => {
expect(dispatchedAction.networkRequest.onSuccess).toEqual(onSuccess);
expect(dispatchedAction.networkRequest.onFailure).toEqual(onFailure);
});
test('api.fetchImages promise called with studioEndpointUrl and learningContextId', () => {
expect(fetchImages).toHaveBeenCalledWith(expectedArgs);
});
test('promise is chained with api.loadImages', () => {
expect(loadImages).toHaveBeenCalledWith({ fetchImages: expectedArgs });
describe('libraries', () => {
const expectedArgs = {
learningContextId: 'lib:demo',
studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
blockId: selectors.app.blockId(testState),
};
beforeEach(() => {
jest.spyOn(selectors.app, 'learningContextId').mockImplementationOnce(() => ('lib:demo'));
fetchImages = jest.fn((args) => new Promise((resolve) => {
resolve({ data: { files: { fetchImages: args } } });
}));
jest.spyOn(api, apiKeys.fetchLibraryImages).mockImplementationOnce(fetchImages);
requests.fetchImages({
...fetchParams, onSuccess, onFailure,
})(dispatch, () => testState);
[[dispatchedAction]] = dispatch.mock.calls;
});
test('api.fetchImages promise called with studioEndpointUrl and blockId', () => {
expect(fetchImages).toHaveBeenCalledWith(expectedArgs);
expect(getLibraryImageAssets).toHaveBeenCalled();
});
});
});
describe('fetchVideos', () => {
@@ -316,21 +351,62 @@ describe('requests thunkActions module', () => {
});
describe('uploadAsset', () => {
const asset = 'SoME iMage CoNtent As String';
testNetworkRequestAction({
action: requests.uploadAsset,
args: { asset, ...fetchParams },
expectedString: 'with uploadAsset promise',
expectedData: {
...fetchParams,
requestKey: RequestKeys.uploadAsset,
promise: api.uploadAsset({
learningContextId: selectors.app.learningContextId(testState),
asset,
studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
}),
},
let uploadAsset;
let dispatchedAction;
describe('courses', () => {
const expectedArgs = {
learningContextId: selectors.app.learningContextId(testState),
studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
blockId: selectors.app.blockId(testState),
asset,
};
beforeEach(() => {
uploadAsset = jest.fn((args) => new Promise((resolve) => {
resolve({ data: { asset: args } });
}));
jest.spyOn(api, apiKeys.uploadAsset).mockImplementationOnce(uploadAsset);
requests.uploadAsset({
asset, ...fetchParams, onSuccess, onFailure,
})(dispatch, () => testState);
[[dispatchedAction]] = dispatch.mock.calls;
});
it('dispatches networkRequest', () => {
expect(dispatchedAction.networkRequest).not.toEqual(undefined);
});
test('forwards onSuccess and onFailure', () => {
expect(dispatchedAction.networkRequest.onSuccess).toEqual(onSuccess);
expect(dispatchedAction.networkRequest.onFailure).toEqual(onFailure);
});
test('api.uploadAsset promise called with studioEndpointUrl, blockId and learningContextId', () => {
expect(uploadAsset).toHaveBeenCalledWith(expectedArgs);
});
});
describe('libraries', () => {
const expectedArgs = {
learningContextId: 'lib:demo',
studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
blockId: selectors.app.blockId(testState),
asset,
};
beforeEach(() => {
jest.spyOn(selectors.app, 'learningContextId').mockImplementationOnce(() => ('lib:demo'));
uploadAsset = jest.fn((args) => new Promise((resolve) => {
resolve({ data: { asset: args } });
}));
jest.spyOn(api, apiKeys.uploadAsset).mockImplementationOnce(uploadAsset);
requests.uploadAsset({
asset, ...fetchParams, onSuccess, onFailure,
})(dispatch, () => testState);
[[dispatchedAction]] = dispatch.mock.calls;
});
test('api.uploadAsset promise called with studioEndpointUrl and blockId', () => {
expect(uploadAsset).toHaveBeenCalledWith(expectedArgs);
expect(parseLibraryImageData).toHaveBeenCalled();
});
});
});
describe('uploadThumbnail', () => {
const thumbnail = 'SoME tHumbNAil CoNtent As String';
const videoId = 'SoME VidEOid CoNtent As String';

View File

@@ -1,12 +1,15 @@
import * as api from './api';
import * as urls from './urls';
import { get, post, deleteObject } from './utils';
import {
get, post, put, deleteObject,
} from './utils';
jest.mock('./urls', () => ({
block: jest.fn().mockReturnValue('urls.block'),
blockAncestor: jest.fn().mockReturnValue('urls.blockAncestor'),
blockStudioView: jest.fn().mockReturnValue('urls.StudioView'),
courseAssets: jest.fn().mockReturnValue('urls.courseAssets'),
libraryAssets: jest.fn().mockReturnValue('urls.libraryAssets'),
videoTranscripts: jest.fn().mockReturnValue('urls.videoTranscripts'),
allowThumbnailUpload: jest.fn().mockReturnValue('urls.allowThumbnailUpload'),
thumbnailUpload: jest.fn().mockReturnValue('urls.thumbnailUpload'),
@@ -25,19 +28,21 @@ jest.mock('./urls', () => ({
jest.mock('./utils', () => ({
get: jest.fn().mockName('get'),
post: jest.fn().mockName('post'),
put: jest.fn().mockName('put'),
deleteObject: jest.fn().mockName('deleteObject'),
}));
const { apiMethods } = api;
const blockId = 'block-v1-coursev1:2uX@4345432';
const learningContextId = 'demo2uX';
let learningContextId;
const studioEndpointUrl = 'hortus.coa';
const title = 'remember this needs to go into metadata to save';
describe('cms api', () => {
beforeEach(() => {
jest.clearAllMocks();
learningContextId = 'demo2uX';
});
describe('apiMethods', () => {
describe('fetchBlockId', () => {
@@ -100,9 +105,11 @@ describe('cms api', () => {
});
});
describe('fetchImages', () => {
describe('fetchCourseImages', () => {
it('should call get with url.courseAssets', () => {
apiMethods.fetchImages({ learningContextId, studioEndpointUrl, pageNumber: 0 });
apiMethods.fetchCourseImages({
learningContextId, studioEndpointUrl, pageNumber: 0,
});
const params = {
asset_type: 'Images',
page: 0,
@@ -113,6 +120,16 @@ describe('cms api', () => {
);
});
});
describe('fetchLibraryImages', () => {
it('should call get with urls.libraryAssets for library V2', () => {
apiMethods.fetchLibraryImages({
blockId,
});
expect(get).toHaveBeenCalledWith(
urls.libraryAssets({ blockId }),
);
});
});
describe('fetchCourseDetails', () => {
it('should call get with url.courseDetailsUrl', () => {
@@ -246,11 +263,14 @@ describe('cms api', () => {
});
describe('uploadAsset', () => {
const asset = new Blob(['data'], { type: 'image/jpeg' });
const img = new Blob(['data'], { type: 'image/jpeg' });
const filename = 'image.jpg';
const asset = new File([img], filename, { type: 'image/jpeg' });
const mockFormdata = new FormData();
mockFormdata.append('file', asset);
it('should call post with urls.courseAssets and imgdata', () => {
const mockFormdata = new FormData();
mockFormdata.append('file', asset);
apiMethods.uploadAsset({
blockId,
learningContextId,
studioEndpointUrl,
asset,
@@ -260,6 +280,20 @@ describe('cms api', () => {
mockFormdata,
);
});
it('should call post with urls.libraryAssets and imgdata', () => {
learningContextId = 'lib:demo2uX';
mockFormdata.append('content', asset);
apiMethods.uploadAsset({
blockId,
learningContextId,
studioEndpointUrl,
asset,
});
expect(put).toHaveBeenCalledWith(
`${urls.libraryAssets({ blockId, assetName: asset.name })}`,
mockFormdata,
);
});
});
describe('uploadVideo', () => {

View File

@@ -2,7 +2,9 @@ import type { AxiosRequestConfig } from 'axios';
import { camelizeKeys } from '../../../utils';
import { isLibraryKey } from '../../../../generic/key-utils';
import * as urls from './urls';
import { get, post, deleteObject } from './utils';
import {
get, post, put, deleteObject,
} from './utils';
import { durationStringFromValue } from '../../../containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks';
const fetchByUnitIdOptions: AxiosRequestConfig = {};
@@ -115,19 +117,11 @@ export const apiMethods = {
fetchStudioView: ({ blockId, studioEndpointUrl }) => get(
urls.blockStudioView({ studioEndpointUrl, blockId }),
),
fetchImages: ({
fetchCourseImages: ({
learningContextId,
studioEndpointUrl,
pageNumber,
}): Promise<{ data: AssetResponse & Pagination }> => {
if (isLibraryKey(learningContextId)) {
// V2 content libraries don't support static assets yet:
return Promise.resolve({
data: {
assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0,
},
});
}
const params = {
asset_type: 'Images',
page: pageNumber,
@@ -137,6 +131,9 @@ export const apiMethods = {
{ params },
);
},
fetchLibraryImages: ({ blockId }) => get(
`${urls.libraryAssets({ blockId })}`,
),
fetchVideos: ({ studioEndpointUrl, learningContextId }) => get(
urls.courseVideos({ studioEndpointUrl, learningContextId }),
),
@@ -147,12 +144,20 @@ export const apiMethods = {
urls.courseAdvanceSettings({ studioEndpointUrl, learningContextId }),
),
uploadAsset: ({
blockId,
learningContextId,
studioEndpointUrl,
asset,
}) => {
const data = new FormData();
data.append('file', asset);
if (isLibraryKey(learningContextId)) {
data.set('content', asset);
return put(
`${urls.libraryAssets({ blockId, assetName: asset.name })}`,
data,
);
}
return post(
urls.courseAssets({ studioEndpointUrl, learningContextId }),
data,

View File

@@ -1,4 +1,5 @@
import { isLibraryKey, isLibraryV1Key } from '../../../../generic/key-utils';
import { getXBlockAssetsApiUrl } from '../../../../library-authoring/data/api';
/**
* A little helper so we can write the types of these functions more compactly
@@ -61,6 +62,12 @@ export const courseAssets = (({ studioEndpointUrl, learningContextId }) => (
`${studioEndpointUrl}/assets/${learningContextId}/`
)) satisfies UrlFunction;
export const libraryAssets = (({ blockId, assetName }) => (
assetName
? `${getXBlockAssetsApiUrl(blockId)}static/${encodeURI(assetName)}`
: `${getXBlockAssetsApiUrl(blockId)}`
)) satisfies UrlFunction;
export const thumbnailUpload = (({ studioEndpointUrl, learningContextId, videoId }) => (
`${studioEndpointUrl}/video_images/${learningContextId}/${videoId}`
)) satisfies UrlFunction;

View File

@@ -16,6 +16,13 @@ export const get: Axios['get'] = (...args) => client().get(...args);
* @param {object|string} data - post payload
*/
export const post: Axios['post'] = (...args) => client().post(...args);
/**
* put(url, data)
* simple wrapper providing an authenticated Http client put action
*/
export const put: Axios['put'] = (...args) => client().put(...args);
/**
* delete(url, data)
* simple wrapper providing an authenticated Http client delete action

View File

@@ -9,8 +9,10 @@ exports[`SelectImageModal component snapshot 1`] = `
"jpeg": ".jpeg",
"jpg": ".jpg",
"png": ".png",
"svg": ".svg",
"tif": ".tif",
"tiff": ".tiff",
"webp": ".webp",
}
}
close={[MockFunction props.close]}
@@ -59,7 +61,7 @@ exports[`SelectImageModal component snapshot 1`] = `
"id": "authoring.texteditor.selectimagemodal.next.label",
},
"fetchError": {
"defaultMessage": "Failed to obtain course images. Please try again.",
"defaultMessage": "Failed to obtain images. Please try again.",
"description": "Message presented to user when images are not found",
"id": "authoring.texteditor.selectimagemodal.error.fetchImagesError",
},

View File

@@ -17,6 +17,7 @@ const SelectImageModal = ({
isLoaded,
isFetchError,
isUploadError,
isLibrary,
imageCount,
}) => {
const {
@@ -57,6 +58,7 @@ const SelectImageModal = ({
isLoaded,
isFetchError,
isUploadError,
isLibrary,
}}
/>
);
@@ -73,12 +75,14 @@ SelectImageModal.propTypes = {
isFetchError: PropTypes.bool.isRequired,
isUploadError: PropTypes.bool.isRequired,
imageCount: PropTypes.number.isRequired,
isLibrary: PropTypes.bool,
};
export const mapStateToProps = (state) => ({
isLoaded: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchImages }),
isFetchError: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchImages }),
isUploadError: selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadAsset }),
isLibrary: selectors.app.isLibrary(state),
imageCount: state.app.imageCount,
});

View File

@@ -56,7 +56,7 @@ const messages = defineMessages({
},
fetchImagesError: {
id: 'authoring.texteditor.selectimagemodal.error.fetchImagesError',
defaultMessage: 'Failed to obtain course images. Please try again.',
defaultMessage: 'Failed to obtain images. Please try again.',
description: 'Message presented to user when images are not found',
},
fileSizeError: {

View File

@@ -44,4 +44,6 @@ export const acceptedImgKeys = StrictDict({
tif: '.tif',
tiff: '.tiff',
ico: '.ico',
svg: '.svg',
webp: '.webp',
});

View File

@@ -21,12 +21,17 @@ export const imgProps = ({
selection,
lmsEndpointUrl,
editorType,
isLibrary,
}) => {
let url = selection?.externalUrl;
if (url?.startsWith(lmsEndpointUrl) && editorType !== 'expandable') {
const sourceEndIndex = lmsEndpointUrl.length;
url = url.substring(sourceEndIndex);
}
if (isLibrary) {
const index = url.indexOf('static/');
url = url.substring(index);
}
return {
src: url,
alt: settings.isDecorative ? '' : settings.altText,
@@ -36,13 +41,14 @@ export const imgProps = ({
};
export const saveToEditor = ({
settings, selection, lmsEndpointUrl, editorType, editorRef,
settings, selection, lmsEndpointUrl, editorType, editorRef, isLibrary,
}) => {
const newImgTag = module.hooks.imgTag({
settings,
selection,
lmsEndpointUrl,
editorType,
isLibrary,
});
editorRef.current.execCommand(
@@ -103,12 +109,14 @@ export const hooks = {
selection,
lmsEndpointUrl,
editorType,
isLibrary,
}) => {
const props = module.imgProps({
settings,
selection,
lmsEndpointUrl,
editorType,
isLibrary,
});
return `<img ${propsString(props)} />`;
},
@@ -130,6 +138,7 @@ const ImageUploadModal = ({
images,
editorType,
lmsEndpointUrl,
isLibrary,
}) => {
if (selection && selection.externalUrl) {
return (
@@ -148,6 +157,7 @@ const ImageUploadModal = ({
setSelection,
lmsEndpointUrl,
clearSelection,
isLibrary,
}),
returnToSelection: clearSelection,
}}
@@ -190,6 +200,7 @@ ImageUploadModal.propTypes = {
images: PropTypes.shape({}).isRequired,
lmsEndpointUrl: PropTypes.string.isRequired,
editorType: PropTypes.string,
isLibrary: PropTypes.string,
};
export const ImageUploadModalInternal = ImageUploadModal; // For testing only

View File

@@ -23,6 +23,7 @@ const Gallery = ({
showIdsOnCards,
height,
isLoaded,
isLibrary,
thumbnailFallback,
allowLazyLoad,
fetchNextPage,
@@ -79,7 +80,7 @@ const Gallery = ({
/>
)) }
</SelectableBox.Set>
{allowLazyLoad && (
{(allowLazyLoad && !isLibrary) && (
<GalleryLoadMoreButton
{...{
fetchNextPage,
@@ -112,6 +113,7 @@ Gallery.propTypes = {
highlighted: PropTypes.string,
onHighlightChange: PropTypes.func.isRequired,
emptyGalleryLabel: PropTypes.shape({}).isRequired,
isLibrary: PropTypes.bool,
showIdsOnCards: PropTypes.bool,
height: PropTypes.string,
thumbnailFallback: PropTypes.element,

View File

@@ -75,6 +75,7 @@ const GalleryCard = ({
/>
</div>
)}
{asset.dateAdded && (
<p className="text-gray-500" style={{ fontSize: '11px' }}>
<FormattedMessage
{...messages.addedDate}
@@ -84,6 +85,7 @@ const GalleryCard = ({
}}
/>
</p>
)}
</div>
</div>
</SelectableBox>

View File

@@ -34,6 +34,7 @@ const SelectionModal = ({
isLoaded,
isFetchError,
isUploadError,
isLibrary,
}) => {
const intl = useIntl();
const {
@@ -54,6 +55,7 @@ const SelectionModal = ({
const galleryPropsValues = {
isLoaded,
isLibrary,
...galleryProps,
};
@@ -83,7 +85,7 @@ const SelectionModal = ({
)}
title={intl.formatMessage(titleMsg)}
bodyStyle={{ background }}
headerComponent={(
headerComponent={!isLibrary && (
<div style={{ margin: '18px 0' }}>
<SearchSort {...searchSortProps} />
</div>
@@ -160,6 +162,7 @@ SelectionModal.propTypes = {
isLoaded: PropTypes.bool.isRequired,
isFetchError: PropTypes.bool.isRequired,
isUploadError: PropTypes.bool.isRequired,
isLibrary: PropTypes.bool,
};
export default SelectionModal;

View File

@@ -34,8 +34,8 @@ exports[`TinyMceWidget snapshots ImageUploadModal is not rendered 1`] = `
],
},
"initializeEditor": undefined,
"isLibrary": true,
"learningContextId": "course+org+run",
"isLibrary": false,
"learningContextId": "library-v1:org+t01",
"lmsEndpointUrl": "sOmEvaLue.cOm",
"minHeight": undefined,
"openImgModal": [MockFunction modal.openModal],
@@ -77,6 +77,7 @@ exports[`TinyMceWidget snapshots SourcecodeModal is not rendered 1`] = `
],
}
}
isLibrary={true}
isOpen={false}
lmsEndpointUrl="http://localhost:18000"
selection="hooks.selectedImage.selection"
@@ -146,6 +147,7 @@ exports[`TinyMceWidget snapshots renders as expected with default behavior 1`] =
],
}
}
isLibrary={true}
isOpen={false}
lmsEndpointUrl="http://localhost:18000"
selection="hooks.selectedImage.selection"

View File

@@ -245,7 +245,6 @@ export const editorConfig = ({
setEditorRef,
editorContentHtml,
images,
isLibrary,
placeholder,
initializeEditor,
openImgModal,
@@ -268,9 +267,8 @@ export const editorConfig = ({
imageToolbar,
quickbarsInsertToolbar,
quickbarsSelectionToolbar,
} = pluginConfig({ isLibrary, placeholder, editorType });
} = pluginConfig({ learningContextId, placeholder, editorType });
const isLocaleRtl = isRtl(getLocale());
return {
onInit: (evt, editor) => {
setEditorRef(editor);

View File

@@ -13,6 +13,7 @@ import ImageUploadModal from '../ImageUploadModal';
import SourceCodeModal from '../SourceCodeModal';
import * as hooks from './hooks';
import './customTinyMcePlugins/embedIframePlugin';
import { isLibraryV1Key } from '../../../generic/key-utils';
export { prepareEditorRef } from './hooks';
@@ -54,7 +55,7 @@ const TinyMceWidget = ({
return (
<>
{!isLibrary && (
{!isLibraryV1Key(learningContextId) && (
<ImageUploadModal
isOpen={isImgOpen}
close={closeImgModal}
@@ -62,6 +63,7 @@ const TinyMceWidget = ({
images={imagesRef}
editorType={editorType}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
isLibrary
{...imageSelection}
/>
)}

View File

@@ -74,7 +74,7 @@ describe('TinyMceWidget', () => {
expect(wrapper.instance.findByType(SourceCodeModal).length).toBe(0);
});
test('ImageUploadModal is not rendered', () => {
const wrapper = shallow(<TinyMceWidget {...props} isLibrary />);
const wrapper = shallow(<TinyMceWidget {...props} learningContextId="library-v1:org+t01" />);
expect(wrapper.snapshot).toMatchSnapshot();
expect(wrapper.instance.findByType(ImageUploadModal).length).toBe(0);
});

View File

@@ -1,13 +1,14 @@
import { isLibraryV1Key } from '../../../generic/key-utils';
import { StrictDict } from '../../utils';
import { buttons, plugins } from '../../data/constants/tinyMCE';
const mapToolbars = toolbars => toolbars.map(toolbar => toolbar.join(' ')).join(' | ');
const pluginConfig = ({ isLibrary, placeholder, editorType }) => {
const image = isLibrary ? '' : plugins.image;
const imageTools = isLibrary ? '' : plugins.imagetools;
const imageUploadButton = isLibrary ? '' : buttons.imageUploadButton;
const editImageSettings = isLibrary ? '' : buttons.editImageSettings;
const pluginConfig = ({ learningContextId, placeholder, editorType }) => {
const image = isLibraryV1Key(learningContextId) ? '' : plugins.image;
const imageTools = isLibraryV1Key(learningContextId) ? '' : plugins.imagetools;
const imageUploadButton = isLibraryV1Key(learningContextId) ? '' : buttons.imageUploadButton;
const editImageSettings = isLibraryV1Key(learningContextId) ? '' : buttons.editImageSettings;
const codePlugin = editorType === 'text' ? plugins.code : '';
const codeButton = editorType === 'text' ? buttons.code : '';
const labelButton = editorType === 'question' ? buttons.customLabelButton : '';

View File

@@ -0,0 +1,110 @@
import { LibraryAssetResponse } from '../../library-authoring/data/api';
type GalleryImageData = {
displayName: string,
url: string,
externalUrl: string,
portableUrl: string,
thumbnail: string,
id: string,
locked: boolean,
};
/**
* Extracts the file name from a file path.
* This function strips the directory structure and returns the base file name.
*
* @param data - The asset data containing the file path.
* @returns The file name extracted from the path.
*
* @example
* const data = { path: '/static/example.jpg', size: 12345, url: 'http://example.com/static/example.jpg' };
* const fileName = getFileName(data); // "example.jpg"
*/
export const getFileName = (data: LibraryAssetResponse): string => data.path.replace(/^.*[\\/]/, '');
/**
* Checks if the provided asset data corresponds to an accepted image file type based on its extension.
*
* @param data - The asset data containing the file path.
* @param acceptedImgExt - The array of accepted image extensions.
* @returns `true` if the file has an accepted image extension, otherwise `false`.
*
* @example
* const data = { path: '/static/example.jpg', size: 12345, url: 'http://example.com/static/example.jpg' };
* const isImg = isImage(data); // Returns true
*/
export const isImage = (data: LibraryAssetResponse, acceptedImgExt:string[]): boolean => {
const ext = data.path.split('.').pop()?.toLowerCase() ?? ''; // Extract and lowercase the file extension
return ext !== '' && acceptedImgExt.includes(ext);
};
/**
* Parses a `LibraryAssetResponse` into a `GalleryImageData` object.
* This includes extracting the file name and constructing other image-related metadata.
*
* @param data - The asset data to parse.
* @returns The parsed image data with properties like `displayName`, `externalUrl`, etc.
*
* @example
* const data = { path: '/static/example.jpg', size: 12345, url: 'http://example.com/static/example.jpg' };
* const imageData = parseLibraryImageData(data);
* // {
* // displayName: 'example.jpg',
* // url: 'http://example.com/static/example.jpg',
* // externalUrl: 'http://example.com/static/example.jpg',
* // portableUrl: '/static/example.jpg',
* // thumbnail: 'http://example.com/static/example.jpg',
* // id: '/static/example.jpg',
* // locked: false
* // }
*/
export const parseLibraryImageData = (data: LibraryAssetResponse): GalleryImageData => ({
displayName: getFileName(data),
url: data.url,
externalUrl: data.url,
portableUrl: data.path,
thumbnail: data.url,
id: data.path,
locked: false,
});
/**
* Filters and transforms an array of `LibrariesAssetResponse` objects into a dictionary of `GalleryImageData`.
* Only assets with recognized extension (i.e., valid image files) are included in the result.
*
* @param librariesAssets - The array of asset data to process.
* @param acceptedImgExt - The array of accepted image extensions.
* @returns A dictionary where each key is the file name and the value is the corresponding `GalleryImageData`.
*
* @example
* const assets = [
* { path: '/static/example.jpg', size: 12345, url: 'http://example.com/static/example.jpg' },
* { path: '/assets/files/unsupported.xyz', size: 67890, url: 'http://example.com/assets/files/unsupported.xyz' }
* ];
* const imageAssets = getLibraryImageAssets(assets);
* // {
* // 'example.jpg': {
* // displayName: 'example.jpg',
* // url: 'http://example.com/static/example.jpg',
* // externalUrl: 'http://example.com/static/example.jpg',
* // portableUrl: '/static/example.jpg',
* // thumbnail: 'http://example.com/static/example.jpg',
* // id: '/static/example.jpg',
* // locked: false
* // }
* // }
*/
export const getLibraryImageAssets = (
librariesAssets: Array<LibraryAssetResponse>,
acceptedImgExt:string[],
): Record<string, GalleryImageData> => librariesAssets.reduce((obj, file) => {
if (isImage(file, acceptedImgExt)) {
const imageData = parseLibraryImageData(file);
return { ...obj, [imageData.displayName]: imageData };
}
return obj;
}, {} as Record<string, GalleryImageData>);

View File

@@ -0,0 +1,96 @@
import {
parseLibraryImageData, getLibraryImageAssets, isImage, getFileName,
} from './formatLibraryImgRequest';
import { LibraryAssetResponse } from '../../library-authoring/data/api';
const acceptedImgExt = ['jpg'];
describe('parseLibraryImageData', () => {
describe('getFileName', () => {
it('should return the file name from the path', () => {
const data: LibraryAssetResponse = {
path: 'static/example.jpg',
size: 12345,
url: 'http://example.com/static/example.jpg',
};
const result = getFileName(data);
expect(result).toBe('example.jpg');
});
});
describe('isImage', () => {
it('should return true for supported file extensions', () => {
const data: LibraryAssetResponse = {
path: 'static/example.jpg',
size: 12345,
url: 'http://example.com/static/example.jpg',
};
const result = isImage(data, acceptedImgExt);
expect(result).toBe(true);
});
it('should return false for unsupported file extensions', () => {
const data: LibraryAssetResponse = {
path: '/assets/files/unknown.xyz',
size: 12345,
url: 'http://example.com/assets/files/unknown.xyz',
};
const result = isImage(data, acceptedImgExt);
expect(result).toBe(false);
});
});
describe('parseLibraryImageData', () => {
it('should correctly parse a valid LibraryAssetResponse into TinyMCEImageData', () => {
const data: LibraryAssetResponse = {
path: 'static/example.jpg',
size: 12345,
url: 'http://example.com/static/example.jpg',
};
const result = parseLibraryImageData(data);
expect(result).toEqual({
displayName: 'example.jpg',
url: 'http://example.com/static/example.jpg',
externalUrl: 'http://example.com/static/example.jpg',
portableUrl: 'static/example.jpg',
thumbnail: 'http://example.com/static/example.jpg',
id: 'static/example.jpg',
locked: false,
});
});
});
describe('getLibraryImageAssets', () => {
it('should filter out assets and return a dictionary of valid images', () => {
const assets: LibraryAssetResponse[] = [
{ path: 'static/example.jpg', size: 12345, url: 'http://example.com/static/example.jpg' },
{ path: '/assets/files/unsupported.xyz', size: 67890, url: 'http://example.com/assets/files/unsupported.xyz' },
];
const result = getLibraryImageAssets(assets, acceptedImgExt);
expect(result).toEqual({
'example.jpg': {
displayName: 'example.jpg',
url: 'http://example.com/static/example.jpg',
externalUrl: 'http://example.com/static/example.jpg',
portableUrl: 'static/example.jpg',
thumbnail: 'http://example.com/static/example.jpg',
id: 'static/example.jpg',
locked: false,
},
});
});
it('should return an empty object if no valid images are found', () => {
const assets: LibraryAssetResponse[] = [
{ path: '/assets/files/unsupported.xyz', size: 67890, url: 'http://example.com/assets/files/unsupported.xyz' },
];
const result = getLibraryImageAssets(assets, acceptedImgExt);
expect(result).toEqual({});
});
});
});

View File

@@ -5,3 +5,4 @@ export { default as camelizeKeys } from './camelizeKeys';
export { default as removeItemOnce } from './removeOnce';
export { default as formatDuration } from './formatDuration';
export { default as snakeCaseKeys } from './snakeCaseKeys';
export * from './formatLibraryImgRequest';

View File

@@ -183,6 +183,12 @@ export interface GetLibrariesV2CustomParams {
search?: string,
}
export type LibraryAssetResponse = {
path: string,
size: number,
url: string,
};
export interface CreateBlockDataRequest {
libraryId: string;
blockType: string;
@@ -439,7 +445,7 @@ export async function publishXBlock(usageKey: string) {
* Fetch the asset (static file) list for the given XBlock.
*/
// istanbul ignore next
export async function getXBlockAssets(usageKey: string): Promise<{ path: string; url: string; size: number }[]> {
export async function getXBlockAssets(usageKey: string): Promise<LibraryAssetResponse[]> {
const { data } = await getAuthenticatedHttpClient().get(getXBlockAssetsApiUrl(usageKey));
return data.files;
}