diff --git a/src/editors/EditorPage.test.tsx b/src/editors/EditorPage.test.tsx index e4d0de409..4ae9817d3 100644 --- a/src/editors/EditorPage.test.tsx +++ b/src/editors/EditorPage.test.tsx @@ -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: diff --git a/src/editors/containers/EditorContainer/index.test.tsx b/src/editors/containers/EditorContainer/index.test.tsx index 3f427bbeb..a35e4d74b 100644 --- a/src/editors/containers/EditorContainer/index.test.tsx +++ b/src/editors/containers/EditorContainer/index.test.tsx @@ -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: diff --git a/src/editors/data/redux/app/reducer.js b/src/editors/data/redux/app/reducer.js index 043de8bf6..3e019c8a5 100644 --- a/src/editors/data/redux/app/reducer.js +++ b/src/editors/data/redux/app/reducer.js @@ -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 }) => ({ diff --git a/src/editors/data/redux/thunkActions/app.js b/src/editors/data/redux/thunkActions/app.js index 478ef4240..09ab9eb61 100644 --- a/src/editors/data/redux/thunkActions/app.js +++ b/src/editors/data/redux/thunkActions/app.js @@ -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; diff --git a/src/editors/data/redux/thunkActions/requests.js b/src/editors/data/redux/thunkActions/requests.js index 46f9d1a03..edff3bf87 100644 --- a/src/editors/data/redux/thunkActions/requests.js +++ b/src/editors/data/redux/thunkActions/requests.js @@ -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, diff --git a/src/editors/data/redux/thunkActions/requests.test.js b/src/editors/data/redux/thunkActions/requests.test.js index 4b5961b9e..ece541178 100644 --- a/src/editors/data/redux/thunkActions/requests.test.js +++ b/src/editors/data/redux/thunkActions/requests.test.js @@ -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'; diff --git a/src/editors/data/services/cms/api.test.ts b/src/editors/data/services/cms/api.test.ts index d7b553fb9..c3df32ccb 100644 --- a/src/editors/data/services/cms/api.test.ts +++ b/src/editors/data/services/cms/api.test.ts @@ -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', () => { diff --git a/src/editors/data/services/cms/api.ts b/src/editors/data/services/cms/api.ts index d40c9d5f3..e979eb445 100644 --- a/src/editors/data/services/cms/api.ts +++ b/src/editors/data/services/cms/api.ts @@ -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, diff --git a/src/editors/data/services/cms/urls.ts b/src/editors/data/services/cms/urls.ts index 361849991..a137ffb9f 100644 --- a/src/editors/data/services/cms/urls.ts +++ b/src/editors/data/services/cms/urls.ts @@ -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; diff --git a/src/editors/data/services/cms/utils.ts b/src/editors/data/services/cms/utils.ts index b7d6276fe..2e77435cd 100644 --- a/src/editors/data/services/cms/utils.ts +++ b/src/editors/data/services/cms/utils.ts @@ -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 diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/index.test.jsx.snap index fd2b660d1..f94b18b18 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/index.test.jsx.snap +++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/index.test.jsx.snap @@ -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", }, diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.jsx b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.jsx index 81e4802cb..0e0439a58 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.jsx +++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.jsx @@ -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, }); diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/messages.js b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/messages.js index f39c30779..14794b641 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/messages.js +++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/messages.js @@ -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: { diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/utils.js b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/utils.js index a94a6ebf2..a1af9fa61 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/utils.js +++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/utils.js @@ -44,4 +44,6 @@ export const acceptedImgKeys = StrictDict({ tif: '.tif', tiff: '.tiff', ico: '.ico', + svg: '.svg', + webp: '.webp', }); diff --git a/src/editors/sharedComponents/ImageUploadModal/index.jsx b/src/editors/sharedComponents/ImageUploadModal/index.jsx index e5a705ce3..fea13225c 100644 --- a/src/editors/sharedComponents/ImageUploadModal/index.jsx +++ b/src/editors/sharedComponents/ImageUploadModal/index.jsx @@ -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 ``; }, @@ -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 diff --git a/src/editors/sharedComponents/SelectionModal/Gallery.jsx b/src/editors/sharedComponents/SelectionModal/Gallery.jsx index 1e01a109f..fc71e81cb 100644 --- a/src/editors/sharedComponents/SelectionModal/Gallery.jsx +++ b/src/editors/sharedComponents/SelectionModal/Gallery.jsx @@ -23,6 +23,7 @@ const Gallery = ({ showIdsOnCards, height, isLoaded, + isLibrary, thumbnailFallback, allowLazyLoad, fetchNextPage, @@ -79,7 +80,7 @@ const Gallery = ({ /> )) } - {allowLazyLoad && ( + {(allowLazyLoad && !isLibrary) && ( )} + {asset.dateAdded && (

+ )} diff --git a/src/editors/sharedComponents/SelectionModal/index.jsx b/src/editors/sharedComponents/SelectionModal/index.jsx index f96a29c83..c345f8232 100644 --- a/src/editors/sharedComponents/SelectionModal/index.jsx +++ b/src/editors/sharedComponents/SelectionModal/index.jsx @@ -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 && (
@@ -160,6 +162,7 @@ SelectionModal.propTypes = { isLoaded: PropTypes.bool.isRequired, isFetchError: PropTypes.bool.isRequired, isUploadError: PropTypes.bool.isRequired, + isLibrary: PropTypes.bool, }; export default SelectionModal; diff --git a/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap index 01e328f8c..46672bee9 100644 --- a/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap +++ b/src/editors/sharedComponents/TinyMceWidget/__snapshots__/index.test.jsx.snap @@ -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" diff --git a/src/editors/sharedComponents/TinyMceWidget/hooks.js b/src/editors/sharedComponents/TinyMceWidget/hooks.js index 9f959f163..762f432c9 100644 --- a/src/editors/sharedComponents/TinyMceWidget/hooks.js +++ b/src/editors/sharedComponents/TinyMceWidget/hooks.js @@ -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); diff --git a/src/editors/sharedComponents/TinyMceWidget/index.jsx b/src/editors/sharedComponents/TinyMceWidget/index.jsx index 9294cf0bd..3306f28d6 100644 --- a/src/editors/sharedComponents/TinyMceWidget/index.jsx +++ b/src/editors/sharedComponents/TinyMceWidget/index.jsx @@ -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) && ( )} diff --git a/src/editors/sharedComponents/TinyMceWidget/index.test.jsx b/src/editors/sharedComponents/TinyMceWidget/index.test.jsx index 741e196ad..bce15315d 100644 --- a/src/editors/sharedComponents/TinyMceWidget/index.test.jsx +++ b/src/editors/sharedComponents/TinyMceWidget/index.test.jsx @@ -74,7 +74,7 @@ describe('TinyMceWidget', () => { expect(wrapper.instance.findByType(SourceCodeModal).length).toBe(0); }); test('ImageUploadModal is not rendered', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.snapshot).toMatchSnapshot(); expect(wrapper.instance.findByType(ImageUploadModal).length).toBe(0); }); diff --git a/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js index 7d7120dc2..c33ab2977 100644 --- a/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js +++ b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js @@ -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 : ''; diff --git a/src/editors/utils/formatLibraryImgRequest.ts b/src/editors/utils/formatLibraryImgRequest.ts new file mode 100644 index 000000000..e5f1780da --- /dev/null +++ b/src/editors/utils/formatLibraryImgRequest.ts @@ -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, + acceptedImgExt:string[], +): Record => librariesAssets.reduce((obj, file) => { + if (isImage(file, acceptedImgExt)) { + const imageData = parseLibraryImageData(file); + return { ...obj, [imageData.displayName]: imageData }; + } + return obj; +}, {} as Record); diff --git a/src/editors/utils/formatLibreryImgRequest.test.ts b/src/editors/utils/formatLibreryImgRequest.test.ts new file mode 100644 index 000000000..3e4a05d55 --- /dev/null +++ b/src/editors/utils/formatLibreryImgRequest.test.ts @@ -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({}); + }); + }); +}); diff --git a/src/editors/utils/index.ts b/src/editors/utils/index.ts index e31466915..34e2a3c35 100644 --- a/src/editors/utils/index.ts +++ b/src/editors/utils/index.ts @@ -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'; diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 06f8c50f0..2efdf9175 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -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 { const { data } = await getAuthenticatedHttpClient().get(getXBlockAssetsApiUrl(usageKey)); return data.files; }