feat: Select img api (#41)
* feat: add capabilities * chore: more tests * fix: use correct hook source for getDispatch * fix: fix fetchImages data contract * fix: make onClose method a callback for ImageUploadModal * chore: lint and test updates * chore: clean up propType warning Co-authored-by: connorhaugh <chaugh21@amherst.edu>
This commit is contained in:
@@ -11,4 +11,7 @@ module.exports = createConfig('jest', {
|
||||
snapshotSerializers: [
|
||||
'enzyme-to-json/serializer',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^lodash-es$': 'lodash',
|
||||
},
|
||||
});
|
||||
|
||||
23611
package-lock.json
generated
23611
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,6 @@
|
||||
},
|
||||
"homepage": "https://github.com/edx/frontend-lib-content-components#readme",
|
||||
"devDependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-build": "9.1.1",
|
||||
"@edx/frontend-platform": "1.15.2",
|
||||
"@edx/paragon": "19.6.0",
|
||||
@@ -57,7 +56,6 @@
|
||||
"redux-saga": "1.1.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-edx@2.0.3",
|
||||
"@reduxjs/toolkit": "^1.7.2",
|
||||
"@tinymce/tinymce-react": "^3.13.0",
|
||||
"babel-polyfill": "6.26.0",
|
||||
@@ -70,7 +68,8 @@
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"redux-thunk": "^2.4.1",
|
||||
"reselect": "^4.1.5",
|
||||
"tinymce": "^5.10.2"
|
||||
"tinymce": "^5.10.2",
|
||||
"lodash-es": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edx/paragon": ">= 7.0.0 < 20.0.0",
|
||||
|
||||
@@ -29,7 +29,7 @@ export const hooks = {
|
||||
setSelection(null);
|
||||
close();
|
||||
},
|
||||
onClose: ({ clearSelection, close }) => {
|
||||
onClose: ({ clearSelection, close }) => () => {
|
||||
clearSelection();
|
||||
close();
|
||||
},
|
||||
@@ -66,7 +66,9 @@ export const ImageUploadModal = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (<SelectImageModal {...{ isOpen, close, setSelection }} />);
|
||||
return (
|
||||
<SelectImageModal {...{ isOpen, close, setSelection }} />
|
||||
);
|
||||
};
|
||||
|
||||
ImageUploadModal.defaultProps = {
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('ImageUploadModal', () => {
|
||||
it('takes and calls clearSelection and close callbacks', () => {
|
||||
const clearSelection = jest.fn();
|
||||
const close = jest.fn();
|
||||
module.hooks.onClose({ clearSelection, close });
|
||||
module.hooks.onClose({ clearSelection, close })();
|
||||
expect(clearSelection).toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { acceptedImgKeys } from './utils';
|
||||
|
||||
export const FileInput = ({ fileInput }) => (
|
||||
<input
|
||||
accept={Object.values(acceptedImgKeys).join()}
|
||||
className="upload d-none"
|
||||
onChange={fileInput.addFile}
|
||||
ref={fileInput.ref}
|
||||
type="file"
|
||||
/>
|
||||
);
|
||||
|
||||
FileInput.propTypes = {
|
||||
fileInput: PropTypes.shape({
|
||||
addFile: PropTypes.func,
|
||||
ref: PropTypes.oneOfType([
|
||||
// Either a function
|
||||
PropTypes.func,
|
||||
// Or the instance of a DOM native element (see the note about SSR)
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]),
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default FileInput;
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import { acceptedImgKeys } from './utils';
|
||||
|
||||
import { FileInput } from './FileInput';
|
||||
|
||||
describe('FileInput component', () => {
|
||||
let el;
|
||||
let container;
|
||||
let props;
|
||||
beforeEach(() => {
|
||||
container = {};
|
||||
props = {
|
||||
fileInput: {
|
||||
addFile: jest.fn().mockName('props.fileInput.addFile'),
|
||||
ref: (input) => { container.ref = input; },
|
||||
},
|
||||
};
|
||||
el = mount(<FileInput {...props} />);
|
||||
});
|
||||
test('snapshot', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('only accepts allowed file types', () => {
|
||||
expect(el.find('input').props().accept).toEqual(
|
||||
Object.values(acceptedImgKeys).join(),
|
||||
);
|
||||
});
|
||||
test('calls fileInput.addFile onChange', () => {
|
||||
expect(el.find('input').props().onChange).toEqual(props.fileInput.addFile);
|
||||
});
|
||||
test('loads ref from fileInput.ref', () => {
|
||||
expect(container.ref).toEqual(el.find('input').getDOMNode());
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,36 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
Scrollable, SelectableBox, Spinner,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { selectors } from '../../../../data/redux';
|
||||
import { RequestKeys } from '../../../../data/constants/requests';
|
||||
|
||||
import messages from './messages';
|
||||
import GalleryCard from './GalleryCard';
|
||||
|
||||
export const Gallery = ({
|
||||
loading,
|
||||
displayList,
|
||||
highlighted,
|
||||
onHighlightChange,
|
||||
// injected
|
||||
intl,
|
||||
// redux
|
||||
isLoaded,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return <Spinner animation="border" className="mie-3" screenReaderText="loading" />;
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<Spinner
|
||||
animation="border"
|
||||
className="mie-3"
|
||||
screenReaderText={intl.formatMessage(messages.loading)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Scrollable className="gallery bg-gray-100" style={{ height: '375px' }}>
|
||||
@@ -26,7 +42,7 @@ export const Gallery = ({
|
||||
type="radio"
|
||||
value={highlighted}
|
||||
>
|
||||
{displayList.map(img => <GalleryCard img={img} />)}
|
||||
{displayList.map(img => <GalleryCard key={img.id} img={img} />)}
|
||||
</SelectableBox.Set>
|
||||
</div>
|
||||
</Scrollable>
|
||||
@@ -34,13 +50,23 @@ export const Gallery = ({
|
||||
};
|
||||
|
||||
Gallery.defaultProps = {
|
||||
loading: false,
|
||||
highlighted: '',
|
||||
};
|
||||
Gallery.propTypes = {
|
||||
loading: PropTypes.bool,
|
||||
displayList: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
highlighted: PropTypes.string.isRequired,
|
||||
highlighted: PropTypes.string,
|
||||
onHighlightChange: PropTypes.func.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
// redux
|
||||
isLoaded: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default Gallery;
|
||||
const requestKey = RequestKeys.fetchImages;
|
||||
export const mapStateToProps = (state) => ({
|
||||
isLoaded: selectors.requests.isFinished(state, { requestKey }),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {};
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Gallery));
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { formatMessage } from '../../../../../testUtils';
|
||||
import { RequestKeys } from '../../../../data/constants/requests';
|
||||
import { selectors } from '../../../../data/redux';
|
||||
import { Gallery, mapStateToProps, mapDispatchToProps } from './Gallery';
|
||||
|
||||
jest.mock('../../../../data/redux', () => ({
|
||||
selectors: {
|
||||
requests: {
|
||||
isFinished: (state, { requestKey }) => ({ isFinished: { state, requestKey } }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./GalleryCard', () => 'GalleryCard');
|
||||
|
||||
describe('TextEditor Image Gallery component', () => {
|
||||
describe('component', () => {
|
||||
const props = {
|
||||
displayList: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
highlighted: 'props.highlighted',
|
||||
onHighlightChange: jest.fn().mockName('props.onHighlightChange'),
|
||||
intl: { formatMessage },
|
||||
isLoaded: true,
|
||||
};
|
||||
test('snapshot: not loaded, show spinner', () => {
|
||||
expect(shallow(<Gallery {...props} isLoaded={false} />)).toMatchSnapshot();
|
||||
});
|
||||
test('snapshot: loaded, show gallery', () => {
|
||||
expect(shallow(<Gallery {...props} />)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
const testState = { some: 'testState' };
|
||||
test('loads isLoaded from requests.isFinished selector for fetchImages request', () => {
|
||||
expect(mapStateToProps(testState).isLoaded).toEqual(
|
||||
selectors.requests.isFinished(testState, { requestKey: RequestKeys.fetchImages }),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
test('is empty', () => {
|
||||
expect(mapDispatchToProps).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`FileInput component snapshot 1`] = `
|
||||
<FileInput
|
||||
fileInput={
|
||||
Object {
|
||||
"addFile": [MockFunction props.fileInput.addFile],
|
||||
"ref": [Function],
|
||||
}
|
||||
}
|
||||
>
|
||||
<input
|
||||
accept=".gif,.jpg,.jpeg,.png,.tif,.tiff"
|
||||
className="upload d-none"
|
||||
onChange={[MockFunction props.fileInput.addFile]}
|
||||
type="file"
|
||||
/>
|
||||
</FileInput>
|
||||
`;
|
||||
@@ -0,0 +1,57 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`TextEditor Image Gallery component component snapshot: loaded, show gallery 1`] = `
|
||||
<Scrollable
|
||||
className="gallery bg-gray-100"
|
||||
style={
|
||||
Object {
|
||||
"height": "375px",
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="p-4"
|
||||
>
|
||||
<SelectableBox.Set
|
||||
columns={1}
|
||||
name="images"
|
||||
onChange={[MockFunction props.onHighlightChange]}
|
||||
type="radio"
|
||||
value="props.highlighted"
|
||||
>
|
||||
<GalleryCard
|
||||
img={
|
||||
Object {
|
||||
"id": 1,
|
||||
}
|
||||
}
|
||||
key="1"
|
||||
/>
|
||||
<GalleryCard
|
||||
img={
|
||||
Object {
|
||||
"id": 2,
|
||||
}
|
||||
}
|
||||
key="2"
|
||||
/>
|
||||
<GalleryCard
|
||||
img={
|
||||
Object {
|
||||
"id": 3,
|
||||
}
|
||||
}
|
||||
key="3"
|
||||
/>
|
||||
</SelectableBox.Set>
|
||||
</div>
|
||||
</Scrollable>
|
||||
`;
|
||||
|
||||
exports[`TextEditor Image Gallery component component snapshot: not loaded, show spinner 1`] = `
|
||||
<Spinner
|
||||
animation="border"
|
||||
className="mie-3"
|
||||
screenReaderText="loading..."
|
||||
/>
|
||||
`;
|
||||
@@ -28,7 +28,7 @@ exports[`GalleryCard component snapshot: dateAdded=12345 1`] = `
|
||||
<FormattedMessage
|
||||
defaultMessage="Added {date} at {time}"
|
||||
description="File date-added string"
|
||||
id="authoring.texteditor.selectimagemodal.addedDate.part1.label"
|
||||
id="authoring.texteditor.selectimagemodal.addedDate.label"
|
||||
values={
|
||||
Object {
|
||||
"date": <FormattedDate
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SelectImageModal component snapshot 1`] = `
|
||||
<BaseModal
|
||||
close={[MockFunction props.close]}
|
||||
confirmAction={
|
||||
<Button
|
||||
select="btnProps"
|
||||
variant="primary"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Next"
|
||||
description="Label for Next button"
|
||||
id="authoring.texteditor.selectimagemodal.next.label"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
footerAction={
|
||||
<Button
|
||||
onClick="imgHooks.fileInput.click"
|
||||
variant="link"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Upload a new image"
|
||||
description="Label for upload button"
|
||||
id="authoring.texteditor.selectimagemodal.upload.label"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
isOpen={true}
|
||||
title="Add an image"
|
||||
>
|
||||
<Stack
|
||||
gap={3}
|
||||
>
|
||||
<SearchSort
|
||||
search="sortProps"
|
||||
/>
|
||||
<Gallery
|
||||
gallery="props"
|
||||
/>
|
||||
<FileInput
|
||||
fileInput={
|
||||
Object {
|
||||
"addFile": "imgHooks.fileInput.addFile",
|
||||
"click": "imgHooks.fileInput.click",
|
||||
"ref": "imgHooks.fileInput.ref",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</BaseModal>
|
||||
`;
|
||||
@@ -1,4 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { thunkActions } from '../../../../data/redux';
|
||||
import * as module from './hooks';
|
||||
import { sortFunctions, sortKeys } from './utils';
|
||||
|
||||
@@ -31,15 +34,15 @@ export const displayList = ({ sortBy, searchString, images }) => module.filtered
|
||||
}).sort(sortFunctions[sortBy in sortKeys ? sortKeys[sortBy] : sortKeys.dateNewest]);
|
||||
|
||||
export const imgListHooks = ({
|
||||
fetchImages,
|
||||
setSelection,
|
||||
searchSortProps,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const [images, setImages] = module.state.images({});
|
||||
const [highlighted, setHighlighted] = module.state.highlighted(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchImages({ onSuccess: setImages });
|
||||
dispatch(thunkActions.app.fetchImages({ setImages }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
@@ -57,14 +60,17 @@ export const imgListHooks = ({
|
||||
};
|
||||
};
|
||||
|
||||
export const fileInputHooks = ({ uploadImage }) => {
|
||||
export const fileInputHooks = ({ setSelection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const ref = React.useRef();
|
||||
const click = () => ref.current.click();
|
||||
const resetFile = () => { ref.current.value = ''; };
|
||||
const addFile = (e) => uploadImage({
|
||||
file: e.target.files[0],
|
||||
resetFile,
|
||||
});
|
||||
const addFile = (e) => {
|
||||
dispatch(thunkActions.app.uploadImage({
|
||||
file: e.target.files[0],
|
||||
setSelection,
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
click,
|
||||
addFile,
|
||||
@@ -72,10 +78,10 @@ export const fileInputHooks = ({ uploadImage }) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const imgHooks = ({ fetchImages, uploadImage, setSelection }) => {
|
||||
export const imgHooks = ({ setSelection }) => {
|
||||
const searchSortProps = module.searchAndSortHooks();
|
||||
const imgList = module.imgListHooks({ fetchImages, setSelection, searchSortProps });
|
||||
const fileInput = module.fileInputHooks({ uploadImage });
|
||||
const imgList = module.imgListHooks({ setSelection, searchSortProps });
|
||||
const fileInput = module.fileInputHooks({ setSelection });
|
||||
const { selectBtnProps, galleryProps } = imgList;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React from 'react';
|
||||
import { dispatch } from 'react-redux';
|
||||
|
||||
import { MockUseState } from '../../../../../testUtils';
|
||||
import { keyStore } from '../../../../utils';
|
||||
import { thunkActions } from '../../../../data/redux';
|
||||
|
||||
import * as hooks from './hooks';
|
||||
import { sortFunctions, sortKeys } from './utils';
|
||||
|
||||
@@ -11,6 +15,24 @@ jest.mock('react', () => ({
|
||||
useCallback: (cb, prereqs) => ({ cb, prereqs }),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => {
|
||||
const dispatchFn = jest.fn();
|
||||
return {
|
||||
...jest.requireActual('react-redux'),
|
||||
dispatch: dispatchFn,
|
||||
useDispatch: jest.fn(() => dispatchFn),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../../../data/redux', () => ({
|
||||
thunkActions: {
|
||||
app: {
|
||||
fetchImages: jest.fn(),
|
||||
uploadImage: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const state = new MockUseState(hooks);
|
||||
const hookKeys = keyStore(hooks);
|
||||
let hook;
|
||||
@@ -110,7 +132,6 @@ describe('SelectImageModal hooks', () => {
|
||||
});
|
||||
describe('imgListHooks outputs', () => {
|
||||
const props = {
|
||||
fetchImages: jest.fn(),
|
||||
setSelection: jest.fn(),
|
||||
searchSortProps: { searchString: 'Es', sortBy: sortKeys.dateNewest },
|
||||
};
|
||||
@@ -126,13 +147,14 @@ describe('SelectImageModal hooks', () => {
|
||||
expect(state.stateVals.images).toEqual(hook.images);
|
||||
expect(state.stateVals.images).toEqual({});
|
||||
});
|
||||
it('calls fetchImages once, with setImages as onSuccess param', () => {
|
||||
it('dispatches fetchImages thunkAction once, with setImages as onSuccess param', () => {
|
||||
expect(React.useEffect.mock.calls.length).toEqual(1);
|
||||
const [cb, prereqs] = React.useEffect.mock.calls[0];
|
||||
expect(prereqs).toEqual([]);
|
||||
expect(props.fetchImages).not.toHaveBeenCalled();
|
||||
cb();
|
||||
expect(props.fetchImages).toHaveBeenCalledWith({ onSuccess: state.setState.images });
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
thunkActions.app.fetchImages({ setImages: state.setState.images }),
|
||||
);
|
||||
});
|
||||
describe('selectBtnProps', () => {
|
||||
it('is disabled if nothing is highlighted', () => {
|
||||
@@ -170,9 +192,9 @@ describe('SelectImageModal hooks', () => {
|
||||
});
|
||||
});
|
||||
describe('fileInputHooks', () => {
|
||||
const uploadImage = jest.fn();
|
||||
const setSelection = jest.fn();
|
||||
beforeEach(() => {
|
||||
hook = hooks.fileInputHooks({ uploadImage });
|
||||
hook = hooks.fileInputHooks({ setSelection });
|
||||
});
|
||||
it('returns a ref for the file input', () => {
|
||||
expect(hook.ref).toEqual({ current: undefined });
|
||||
@@ -180,24 +202,18 @@ describe('SelectImageModal hooks', () => {
|
||||
test('click calls current.click on the ref', () => {
|
||||
const click = jest.fn();
|
||||
React.useRef.mockReturnValueOnce({ current: { click } });
|
||||
hook = hooks.fileInputHooks({ uploadImage });
|
||||
hook = hooks.fileInputHooks({ setSelection });
|
||||
hook.click();
|
||||
expect(click).toHaveBeenCalled();
|
||||
});
|
||||
describe('addFile (uploadImage args)', () => {
|
||||
const event = { target: { files: [testValue] } };
|
||||
it('calls uploadImage with the first target file', () => {
|
||||
it('dispatches uploadImage thunkAction with the first target file and setSelection', () => {
|
||||
hook.addFile(event);
|
||||
expect(uploadImage).toHaveBeenCalled();
|
||||
expect(uploadImage.mock.calls[0][0].file).toEqual(testValue);
|
||||
});
|
||||
it('passes a resetFile callback that sets ref.current.value to empty string', () => {
|
||||
React.useRef.mockReturnValueOnce({ current: { value: 'not empty' } });
|
||||
hook = hooks.fileInputHooks({ uploadImage });
|
||||
hook.addFile(event);
|
||||
expect(uploadImage).toHaveBeenCalled();
|
||||
uploadImage.mock.calls[0][0].resetFile();
|
||||
expect(hook.ref.current.value).toEqual('');
|
||||
expect(dispatch).toHaveBeenCalledWith(thunkActions.app.uploadImage({
|
||||
file: testValue,
|
||||
setSelection,
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -209,8 +225,6 @@ describe('SelectImageModal hooks', () => {
|
||||
const searchAndSortHooks = { search: 'props' };
|
||||
const fileInputHooks = { file: 'input hooks' };
|
||||
|
||||
const fetchImages = jest.fn();
|
||||
const uploadImage = jest.fn();
|
||||
const setSelection = jest.fn();
|
||||
const spies = {};
|
||||
beforeEach(() => {
|
||||
@@ -220,19 +234,18 @@ describe('SelectImageModal hooks', () => {
|
||||
.mockReturnValueOnce(searchAndSortHooks);
|
||||
spies.file = jest.spyOn(hooks, hookKeys.fileInputHooks)
|
||||
.mockReturnValueOnce(fileInputHooks);
|
||||
hook = hooks.imgHooks({ fetchImages, uploadImage, setSelection });
|
||||
hook = hooks.imgHooks({ setSelection });
|
||||
});
|
||||
it('forwards fileInputHooks as fileInput, called with uploadImage prop', () => {
|
||||
expect(hook.fileInput).toEqual(fileInputHooks);
|
||||
expect(spies.file.mock.calls.length).toEqual(1);
|
||||
expect(spies.file).toHaveBeenCalledWith({
|
||||
uploadImage,
|
||||
setSelection,
|
||||
});
|
||||
});
|
||||
it('initializes imgListHooks with fetchImages, setSelection and searchAndSortHooks', () => {
|
||||
it('initializes imgListHooks with setSelection and searchAndSortHooks', () => {
|
||||
expect(spies.imgList.mock.calls.length).toEqual(1);
|
||||
expect(spies.imgList).toHaveBeenCalledWith({
|
||||
fetchImages,
|
||||
setSelection,
|
||||
searchSortProps: searchAndSortHooks,
|
||||
});
|
||||
@@ -240,7 +253,7 @@ describe('SelectImageModal hooks', () => {
|
||||
it('forwards searchAndSortHooks as searchSortProps', () => {
|
||||
expect(hook.searchSortProps).toEqual(searchAndSortHooks);
|
||||
expect(spies.file.mock.calls.length).toEqual(1);
|
||||
expect(spies.file).toHaveBeenCalledWith({ uploadImage });
|
||||
expect(spies.file).toHaveBeenCalledWith({ setSelection });
|
||||
});
|
||||
it('forwards galleryProps and selectBtnProps from the image list hooks', () => {
|
||||
expect(hook.galleryProps).toEqual(imgListHooks.galleryProps);
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, Stack } from '@edx/paragon';
|
||||
import { Add } from '@edx/paragon/icons';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { thunkActions } from '../../../../data/redux';
|
||||
import hooks from './hooks';
|
||||
import { acceptedImgKeys } from './utils';
|
||||
import messages from './messages';
|
||||
import BaseModal from '../BaseModal';
|
||||
import ErrorAlert from './ErrorAlert';
|
||||
import SearchSort from './SearchSort';
|
||||
import Gallery from './Gallery';
|
||||
|
||||
// internationalization
|
||||
// intel
|
||||
// inject intel
|
||||
// some kind of date thing (FormattedMessage and FormattedDate)
|
||||
|
||||
// TODO testing (testUtils has formatted message)
|
||||
import FileInput from './FileInput';
|
||||
|
||||
export const SelectImageModal = ({
|
||||
isOpen,
|
||||
@@ -28,16 +18,13 @@ export const SelectImageModal = ({
|
||||
setSelection,
|
||||
// injected
|
||||
intl,
|
||||
// redux
|
||||
fetchImages,
|
||||
uploadImage,
|
||||
}) => {
|
||||
const {
|
||||
searchSortProps,
|
||||
galleryProps,
|
||||
selectBtnProps,
|
||||
fileInput,
|
||||
} = hooks.imgHooks({ fetchImages, uploadImage, setSelection });
|
||||
} = hooks.imgHooks({ setSelection });
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
@@ -58,15 +45,10 @@ export const SelectImageModal = ({
|
||||
<Stack gap={3}>
|
||||
<SearchSort {...searchSortProps} />
|
||||
<Gallery {...galleryProps} />
|
||||
<input
|
||||
accept={Object.values(acceptedImgKeys).join()}
|
||||
className="upload d-none"
|
||||
onChange={fileInput.addFile}
|
||||
ref={fileInput.ref}
|
||||
type="file"
|
||||
/>
|
||||
<FileInput fileInput={fileInput} />
|
||||
</Stack>
|
||||
</BaseModal>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
@@ -76,15 +58,6 @@ SelectImageModal.propTypes = {
|
||||
setSelection: PropTypes.func.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
// redux
|
||||
fetchImages: PropTypes.func.isRequired,
|
||||
uploadImage: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = () => ({});
|
||||
export const mapDispatchToProps = {
|
||||
fetchImages: thunkActions.app.fetchImages,
|
||||
uploadImage: thunkActions.app.uploadImage,
|
||||
};
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(SelectImageModal));
|
||||
export default injectIntl(SelectImageModal);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { formatMessage } from '../../../../../testUtils';
|
||||
import BaseModal from '../BaseModal';
|
||||
import FileInput from './FileInput';
|
||||
import Gallery from './Gallery';
|
||||
import SearchSort from './SearchSort';
|
||||
import hooks from './hooks';
|
||||
import { SelectImageModal } from '.';
|
||||
|
||||
jest.mock('../BaseModal', () => 'BaseModal');
|
||||
jest.mock('./FileInput', () => 'FileInput');
|
||||
jest.mock('./Gallery', () => 'Gallery');
|
||||
jest.mock('./SearchSort', () => 'SearchSort');
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
imgHooks: jest.fn(() => ({
|
||||
searchSortProps: { search: 'sortProps' },
|
||||
galleryProps: { gallery: 'props' },
|
||||
selectBtnProps: { select: 'btnProps' },
|
||||
fileInput: {
|
||||
addFile: 'imgHooks.fileInput.addFile',
|
||||
click: 'imgHooks.fileInput.click',
|
||||
ref: 'imgHooks.fileInput.ref',
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('SelectImageModal', () => {
|
||||
describe('component', () => {
|
||||
const props = {
|
||||
isOpen: true,
|
||||
close: jest.fn().mockName('props.close'),
|
||||
setSelection: jest.fn().mockName('props.setSelection'),
|
||||
intl: { formatMessage },
|
||||
};
|
||||
let el;
|
||||
const imgHooks = hooks.imgHooks();
|
||||
beforeEach(() => {
|
||||
el = shallow(<SelectImageModal {...props} />);
|
||||
});
|
||||
test('snapshot', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
it('provides confirm action, forwarding selectBtnProps from imgHooks', () => {
|
||||
expect(el.find(BaseModal).props().confirmAction.props).toEqual(
|
||||
expect.objectContaining({ ...hooks.imgHooks().selectBtnProps, variant: 'primary' }),
|
||||
);
|
||||
});
|
||||
it('provides file upload button linked to fileInput.click', () => {
|
||||
expect(el.find(BaseModal).props().footerAction.props.onClick).toEqual(
|
||||
imgHooks.fileInput.click,
|
||||
);
|
||||
});
|
||||
it('provides a SearchSort component with searchSortProps from imgHooks', () => {
|
||||
expect(el.find(SearchSort).props()).toEqual(imgHooks.searchSortProps);
|
||||
});
|
||||
it('provides a Gallery component with galleryProps from imgHooks', () => {
|
||||
expect(el.find(Gallery).props()).toEqual(imgHooks.galleryProps);
|
||||
});
|
||||
it('provides a FileInput component with fileInput props from imgHooks', () => {
|
||||
expect(el.find(FileInput).props()).toMatchObject({ fileInput: imgHooks.fileInput });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -39,12 +39,16 @@ export const messages = {
|
||||
defaultMessage: 'By name (descending)',
|
||||
description: 'Dropdown label for sorting by name (descending)',
|
||||
},
|
||||
// Date added messages
|
||||
addedDate: {
|
||||
id: 'authoring.texteditor.selectimagemodal.addedDate.part1.label',
|
||||
id: 'authoring.texteditor.selectimagemodal.addedDate.label',
|
||||
defaultMessage: 'Added {date} at {time}',
|
||||
description: 'File date-added string',
|
||||
},
|
||||
loading: {
|
||||
id: 'authoring.texteditor.selectimagemodal.spinner.readertext',
|
||||
defaultMessage: 'loading...',
|
||||
description: 'Gallery loading spinner screen-reader text',
|
||||
},
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* istanbul ignore file */
|
||||
export const mockImageData = [
|
||||
{
|
||||
displayName: 'shahrukh.jpg',
|
||||
|
||||
@@ -11,4 +11,6 @@ export const RequestKeys = StrictDict({
|
||||
fetchBlock: 'fetchBlock',
|
||||
fetchUnit: 'fetchUnit',
|
||||
saveBlock: 'saveBlock',
|
||||
fetchImages: 'fetchImages',
|
||||
uploadImage: 'uploadImage',
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ const initialState = {
|
||||
[RequestKeys.fetchUnit]: { status: RequestStates.inactive },
|
||||
[RequestKeys.fetchBlock]: { status: RequestStates.inactive },
|
||||
[RequestKeys.saveBlock]: { status: RequestStates.inactive },
|
||||
[RequestKeys.fetchImages]: { status: RequestStates.inactive },
|
||||
[RequestKeys.uploadImage]: { status: RequestStates.inactive },
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { StrictDict } from '../../../utils';
|
||||
import * as mockData from '../../constants/mockData';
|
||||
import { StrictDict, camelizeKeys } from '../../../utils';
|
||||
import { actions } from '..';
|
||||
import * as requests from './requests';
|
||||
import * as module from './app';
|
||||
@@ -44,52 +43,15 @@ export const saveBlock = ({ content, returnToUnit }) => (dispatch) => {
|
||||
}));
|
||||
};
|
||||
|
||||
export const fetchImages = ({ onSuccess }) => () => {
|
||||
// get images
|
||||
const processedData = mockData.mockImageData.reduce(
|
||||
(obj, el) => {
|
||||
const dateAdded = new Date(el.dateAdded.replace(' at', '')).getTime();
|
||||
return { ...obj, [el.id]: { ...el, dateAdded } };
|
||||
},
|
||||
{},
|
||||
);
|
||||
return onSuccess(processedData);
|
||||
export const fetchImages = ({ setImages }) => (dispatch) => {
|
||||
dispatch(requests.fetchImages({ onSuccess: setImages }));
|
||||
};
|
||||
|
||||
export const uploadImage = ({
|
||||
file, startLoading, stopLoading, resetFile, setError,
|
||||
}) => () => {
|
||||
// input file
|
||||
// lastModified: 1643131112097
|
||||
// lastModifiedDate: Tue Jan 25 2022 12:18:32 GMT-0500 (Eastern Standard Time) {}
|
||||
// name: "Profile.jpg"
|
||||
// size: 21015
|
||||
// type: "image/jpeg"
|
||||
|
||||
// api will respond with the following JSON
|
||||
// {
|
||||
// "asset": {
|
||||
// "display_name": "journey_escape.jpg",
|
||||
// "content_type": "image/jpeg",
|
||||
// "date_added": "Jan 05, 2022 at 21:26 UTC",
|
||||
// "url": "/asset-v1:edX+test101+2021_T1+type@asset+block@journey_escape.jpg",
|
||||
// "external_url": "https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@journey_escape.jpg",
|
||||
// "portable_url": "/static/journey_escape.jpg",
|
||||
// "thumbnail": "/asset-v1:edX+test101+2021_T1+type@thumbnail+block@journey_escape.jpg",
|
||||
// "locked": false,
|
||||
// "id": "asset-v1:edX+test101+2021_T1+type@asset+block@journey_escape.jpg"
|
||||
// },
|
||||
// "msg": "Upload completed"
|
||||
// }
|
||||
|
||||
console.log(file);
|
||||
startLoading();
|
||||
setTimeout(() => {
|
||||
stopLoading();
|
||||
resetFile();
|
||||
setError('test error');
|
||||
}, 5000);
|
||||
return null;
|
||||
export const uploadImage = ({ file, setSelection }) => (dispatch) => {
|
||||
dispatch(requests.uploadImage({
|
||||
image: file,
|
||||
onSuccess: (response) => setSelection(camelizeKeys(response.data.asset)),
|
||||
}));
|
||||
};
|
||||
|
||||
export default StrictDict({
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { actions } from '..';
|
||||
import { camelizeKeys } from '../../../utils';
|
||||
import * as thunkActions from './app';
|
||||
|
||||
jest.mock('./requests', () => ({
|
||||
fetchBlock: (args) => ({ fetchBlock: args }),
|
||||
fetchUnit: (args) => ({ fetchUnit: args }),
|
||||
saveBlock: (args) => ({ saveBlock: args }),
|
||||
fetchImages: (args) => ({ fetchImages: args }),
|
||||
uploadImage: (args) => ({ uploadImage: args }),
|
||||
}));
|
||||
|
||||
const testValue = 'test VALUE';
|
||||
jest.mock('../../../utils', () => ({
|
||||
camelizeKeys: (args) => ([{ camelizeKeys: args }]),
|
||||
...jest.requireActual('../../../utils'),
|
||||
}));
|
||||
|
||||
const testValue = { data: { assets: 'test VALUE' } };
|
||||
|
||||
describe('app thunkActions', () => {
|
||||
let dispatch;
|
||||
@@ -92,4 +100,29 @@ describe('app thunkActions', () => {
|
||||
expect(returnToUnit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('fetchImages', () => {
|
||||
it('dispatches fetchUnit action with setImages for onSuccess param', () => {
|
||||
const setImages = jest.fn();
|
||||
thunkActions.fetchImages({ setImages })(dispatch);
|
||||
[[dispatchedAction]] = dispatch.mock.calls;
|
||||
expect(dispatchedAction.fetchImages).toEqual({ onSuccess: setImages });
|
||||
});
|
||||
});
|
||||
describe('uploadImage', () => {
|
||||
const setSelection = jest.fn();
|
||||
beforeEach(() => {
|
||||
thunkActions.uploadImage({ file: testValue, setSelection })(dispatch);
|
||||
[[dispatchedAction]] = dispatch.mock.calls;
|
||||
});
|
||||
it('dispatches uploadImage action', () => {
|
||||
expect(dispatchedAction.uploadImage).not.toBe(undefined);
|
||||
});
|
||||
test('passes file as image prop', () => {
|
||||
expect(dispatchedAction.uploadImage.image).toEqual(testValue);
|
||||
});
|
||||
test('onSuccess: calls setSelection with camelized response.data.asset', () => {
|
||||
dispatchedAction.uploadImage.onSuccess({ data: { asset: testValue } });
|
||||
expect(setSelection).toHaveBeenCalledWith(camelizeKeys(testValue));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { StrictDict } from '../../../utils';
|
||||
|
||||
import { RequestKeys } from '../../constants/requests';
|
||||
import { actions, selectors } from '..';
|
||||
import api from '../../services/cms/api';
|
||||
import api, { loadImages } from '../../services/cms/api';
|
||||
|
||||
import * as module from './requests';
|
||||
|
||||
@@ -87,8 +87,32 @@ export const saveBlock = ({ content, ...rest }) => (dispatch, getState) => {
|
||||
...rest,
|
||||
}));
|
||||
};
|
||||
export const uploadImage = ({ image, ...rest }) => (dispatch, getState) => {
|
||||
dispatch(module.networkRequest({
|
||||
requestKey: RequestKeys.uploadImage,
|
||||
promise: api.uploadImage({
|
||||
courseId: selectors.app.courseId(getState()),
|
||||
image,
|
||||
studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
|
||||
}),
|
||||
...rest,
|
||||
}));
|
||||
};
|
||||
|
||||
export const fetchImages = ({ ...rest }) => (dispatch, getState) => {
|
||||
dispatch(module.networkRequest({
|
||||
requestKey: RequestKeys.fetchImages,
|
||||
promise: api.fetchImages({
|
||||
studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
|
||||
courseId: selectors.app.courseId(getState()),
|
||||
}).then((response) => loadImages(response.data.assets)),
|
||||
...rest,
|
||||
}));
|
||||
};
|
||||
|
||||
export default StrictDict({
|
||||
uploadImage,
|
||||
fetchImages,
|
||||
fetchUnit,
|
||||
fetchBlock,
|
||||
saveBlock,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { keyStore } from '../../../utils';
|
||||
import { RequestKeys } from '../../constants/requests';
|
||||
import api from '../../services/cms/api';
|
||||
import * as requests from './requests';
|
||||
@@ -19,11 +20,19 @@ jest.mock('../../services/cms/api', () => ({
|
||||
fetchBlockById: ({ id, url }) => ({ id, url }),
|
||||
fetchByUnitId: ({ id, url }) => ({ id, url }),
|
||||
saveBlock: (args) => args,
|
||||
fetchImages: ({ id, url }) => ({ id, url }),
|
||||
uploadImage: (args) => args,
|
||||
loadImages: jest.fn(),
|
||||
}));
|
||||
|
||||
const apiKeys = keyStore(api);
|
||||
|
||||
let dispatch;
|
||||
let onSuccess;
|
||||
let onFailure;
|
||||
|
||||
const fetchParams = { fetchParam1: 'param1', fetchParam2: 'param2' };
|
||||
|
||||
describe('requests thunkActions module', () => {
|
||||
beforeEach(() => {
|
||||
dispatch = jest.fn();
|
||||
@@ -149,7 +158,6 @@ describe('requests thunkActions module', () => {
|
||||
});
|
||||
};
|
||||
describe('network request actions', () => {
|
||||
const fetchParams = { fetchParam1: 'param1', fetchParam2: 'param2' };
|
||||
beforeEach(() => {
|
||||
requests.networkRequest = jest.fn(args => ({ networkRequest: args }));
|
||||
});
|
||||
@@ -183,14 +191,47 @@ describe('requests thunkActions module', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchImages', () => {
|
||||
let fetchImages;
|
||||
let loadImages;
|
||||
let dispatchedAction;
|
||||
const expectedArgs = {
|
||||
studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
|
||||
courseId: selectors.app.courseId(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;
|
||||
});
|
||||
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 courseId', () => {
|
||||
expect(fetchImages).toHaveBeenCalledWith(expectedArgs);
|
||||
});
|
||||
test('promise is chained with api.loadImages', () => {
|
||||
expect(loadImages).toHaveBeenCalledWith({ fetchImages: expectedArgs });
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveBlock', () => {
|
||||
const content = 'SoME HtMl CoNtent As String';
|
||||
testNetworkRequestAction({
|
||||
action: requests.saveBlock,
|
||||
args: { content, some: 'data' },
|
||||
args: { content, ...fetchParams },
|
||||
expectedString: 'with saveBlock promise',
|
||||
expectedData: {
|
||||
...testState,
|
||||
...fetchParams,
|
||||
requestKey: RequestKeys.saveBlock,
|
||||
promise: api.saveBlock({
|
||||
blockId: selectors.app.blockId(testState),
|
||||
@@ -203,5 +244,23 @@ describe('requests thunkActions module', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadImage', () => {
|
||||
const image = 'SoME iMage CoNtent As String';
|
||||
testNetworkRequestAction({
|
||||
action: requests.uploadImage,
|
||||
args: { image, ...fetchParams },
|
||||
expectedString: 'with uploadImage promise',
|
||||
expectedData: {
|
||||
...fetchParams,
|
||||
requestKey: RequestKeys.uploadImage,
|
||||
promise: api.uploadImage({
|
||||
courseId: selectors.app.courseId(testState),
|
||||
image,
|
||||
studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { camelizeKeys } from '../../../utils';
|
||||
import * as urls from './urls';
|
||||
import { get, post } from './utils';
|
||||
import * as module from './api';
|
||||
@@ -10,6 +11,17 @@ export const apiMethods = {
|
||||
fetchByUnitId: ({ blockId, studioEndpointUrl }) => get(
|
||||
urls.blockAncestor({ studioEndpointUrl, blockId }),
|
||||
),
|
||||
fetchImages: ({ courseId, studioEndpointUrl }) => get(
|
||||
urls.courseImages({ studioEndpointUrl, courseId }),
|
||||
),
|
||||
uploadImage: ({
|
||||
courseId,
|
||||
studioEndpointUrl,
|
||||
image,
|
||||
}) => post(
|
||||
urls.courseAssets({ studioEndpointUrl, courseId }),
|
||||
image,
|
||||
),
|
||||
normalizeContent: ({
|
||||
blockId,
|
||||
blockType,
|
||||
@@ -48,6 +60,16 @@ export const apiMethods = {
|
||||
),
|
||||
};
|
||||
|
||||
export const loadImage = (imageData) => ({
|
||||
...imageData,
|
||||
dateAdded: new Date(imageData.dateAdded.replace(' at', '')).getTime(),
|
||||
});
|
||||
|
||||
export const loadImages = (rawImages) => camelizeKeys(rawImages).reduce(
|
||||
(obj, image) => ({ ...obj, [image.id]: module.loadImage(image) }),
|
||||
{},
|
||||
);
|
||||
|
||||
export const checkMockApi = (key) => {
|
||||
if (process.env.REACT_APP_DEVGALLERY) {
|
||||
return mockApi[key];
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { apiMethods } from './api';
|
||||
import * as utils from '../../../utils';
|
||||
import * as api from './api';
|
||||
import * as urls from './urls';
|
||||
import { get, post } from './utils';
|
||||
|
||||
jest.mock('../../../utils', () => {
|
||||
const camelizeMap = (obj) => ({ ...obj, camelized: true });
|
||||
return {
|
||||
...jest.requireActual('../../../utils'),
|
||||
camelize: camelizeMap,
|
||||
camelizeKeys: (list) => list.map(camelizeMap),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('./urls', () => ({
|
||||
block: jest.fn().mockName('urls.block'),
|
||||
blockAncestor: jest.fn().mockName('urls.blockAncestor'),
|
||||
courseImages: jest.fn().mockName('urls.courseImages'),
|
||||
courseAssets: jest.fn().mockName('urls.courseAssets'),
|
||||
}));
|
||||
|
||||
jest.mock('./utils', () => ({
|
||||
@@ -12,6 +24,10 @@ jest.mock('./utils', () => ({
|
||||
post: jest.fn().mockName('post'),
|
||||
}));
|
||||
|
||||
const { camelize } = utils;
|
||||
|
||||
const { apiMethods } = api;
|
||||
|
||||
const blockId = 'coursev1:2uX@4345432';
|
||||
const content = 'Im baby palo santo ugh celiac fashion axe. La croix lo-fi venmo whatever. Beard man braid migas single-origin coffee forage ramps.';
|
||||
const courseId = 'demo2uX';
|
||||
@@ -19,63 +35,118 @@ const studioEndpointUrl = 'hortus.coa';
|
||||
const title = 'remember this needs to go into metadata to save';
|
||||
|
||||
describe('cms api', () => {
|
||||
describe('fetchBlockId', () => {
|
||||
it('should call get with url.blocks', () => {
|
||||
apiMethods.fetchBlockById({ blockId, studioEndpointUrl });
|
||||
expect(get).toHaveBeenCalledWith(urls.block({ blockId, studioEndpointUrl }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchByUnitId', () => {
|
||||
it('should call get with url.blockAncestor', () => {
|
||||
apiMethods.fetchByUnitId({ blockId, studioEndpointUrl });
|
||||
expect(get).toHaveBeenCalledWith(urls.blockAncestor({ studioEndpointUrl, blockId }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeContent', () => {
|
||||
test('return value for blockType: html', () => {
|
||||
expect(apiMethods.normalizeContent({
|
||||
blockId,
|
||||
blockType: 'html',
|
||||
content,
|
||||
courseId,
|
||||
title,
|
||||
})).toEqual({
|
||||
category: 'html',
|
||||
couseKey: courseId,
|
||||
data: content,
|
||||
has_changes: true,
|
||||
id: blockId,
|
||||
metadata: { display_name: title },
|
||||
describe('apiMethods', () => {
|
||||
describe('fetchBlockId', () => {
|
||||
it('should call get with url.blocks', () => {
|
||||
apiMethods.fetchBlockById({ blockId, studioEndpointUrl });
|
||||
expect(get).toHaveBeenCalledWith(urls.block({ blockId, studioEndpointUrl }));
|
||||
});
|
||||
});
|
||||
test('throw error for invalid blockType', () => {
|
||||
expect(() => { apiMethods.normalizeContent({ blockType: 'somethingINVALID' }); })
|
||||
.toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveBlock', () => {
|
||||
it('should call post with urls.block and normalizeContent', () => {
|
||||
apiMethods.saveBlock({
|
||||
blockId,
|
||||
blockType: 'html',
|
||||
content,
|
||||
courseId,
|
||||
studioEndpointUrl,
|
||||
title,
|
||||
describe('fetchByUnitId', () => {
|
||||
it('should call get with url.blockAncestor', () => {
|
||||
apiMethods.fetchByUnitId({ blockId, studioEndpointUrl });
|
||||
expect(get).toHaveBeenCalledWith(urls.blockAncestor({ studioEndpointUrl, blockId }));
|
||||
});
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
urls.block({ studioEndpointUrl }),
|
||||
apiMethods.normalizeContent({
|
||||
});
|
||||
|
||||
describe('fetchImages', () => {
|
||||
it('should call get with url.courseImages', () => {
|
||||
apiMethods.fetchImages({ courseId, studioEndpointUrl });
|
||||
expect(get).toHaveBeenCalledWith(urls.courseImages({ studioEndpointUrl, courseId }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeContent', () => {
|
||||
test('return value for blockType: html', () => {
|
||||
expect(apiMethods.normalizeContent({
|
||||
blockId,
|
||||
blockType: 'html',
|
||||
content,
|
||||
blockId,
|
||||
courseId,
|
||||
title,
|
||||
}),
|
||||
);
|
||||
})).toEqual({
|
||||
category: 'html',
|
||||
couseKey: courseId,
|
||||
data: content,
|
||||
has_changes: true,
|
||||
id: blockId,
|
||||
metadata: { display_name: title },
|
||||
});
|
||||
});
|
||||
test('throw error for invalid blockType', () => {
|
||||
expect(() => { apiMethods.normalizeContent({ blockType: 'somethingINVALID' }); })
|
||||
.toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveBlock', () => {
|
||||
it('should call post with urls.block and normalizeContent', () => {
|
||||
apiMethods.saveBlock({
|
||||
blockId,
|
||||
blockType: 'html',
|
||||
content,
|
||||
courseId,
|
||||
studioEndpointUrl,
|
||||
title,
|
||||
});
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
urls.block({ studioEndpointUrl }),
|
||||
apiMethods.normalizeContent({
|
||||
blockType: 'html',
|
||||
content,
|
||||
blockId,
|
||||
courseId,
|
||||
title,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadImage', () => {
|
||||
const image = { photo: 'dAta' };
|
||||
it('should call post with urls.block and normalizeContent', () => {
|
||||
apiMethods.uploadImage({
|
||||
courseId,
|
||||
studioEndpointUrl,
|
||||
image,
|
||||
});
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
urls.courseAssets({ studioEndpointUrl, courseId }),
|
||||
image,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('loadImage', () => {
|
||||
it('loads incoming image data, replacing the dateAdded with a date field', () => {
|
||||
const [date, time] = ['Jan 20, 2022', '9:30 PM'];
|
||||
const imageData = { some: 'image data', dateAdded: `${date} at ${time}` };
|
||||
expect(api.loadImage(imageData)).toEqual({
|
||||
...imageData,
|
||||
dateAdded: new Date(`${date} ${time}`).getTime(),
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('loadImages', () => {
|
||||
it('loads a list of images into an object by id, using loadImage to translate', () => {
|
||||
const ids = ['id0', 'Id1', 'ID2', 'iD3'];
|
||||
const testData = [
|
||||
{ id: ids[0], some: 'data' },
|
||||
{ id: ids[1], other: 'data' },
|
||||
{ id: ids[2], some: 'DATA' },
|
||||
{ id: ids[3], other: 'DATA' },
|
||||
];
|
||||
const oldLoadImage = api.loadImage;
|
||||
api.loadImage = (imageData) => ({ loadImage: imageData });
|
||||
const out = api.loadImages(testData);
|
||||
expect(out).toEqual({
|
||||
[ids[0]]: api.loadImage(camelize(testData[0])),
|
||||
[ids[1]]: api.loadImage(camelize(testData[1])),
|
||||
[ids[2]]: api.loadImage(camelize(testData[2])),
|
||||
[ids[3]]: api.loadImage(camelize(testData[3])),
|
||||
});
|
||||
api.loadImage = oldLoadImage;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,57 @@ export const fetchBlockById = ({ blockId, studioEndpointUrl }) => mockPromise({
|
||||
export const fetchByUnitId = ({ blockId, studioEndpointUrl }) => mockPromise({
|
||||
data: { ancestors: [{ id: 'unitUrl' }] },
|
||||
});
|
||||
// eslint-disable-next-line
|
||||
export const fetchImages = ({ courseId, studioEndpointUrl }) => mockPromise({
|
||||
data: {
|
||||
assets: [
|
||||
{
|
||||
displayName: 'shahrukh.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
dateAdded: 'Jan 05, 2022 at 17:38 UTC',
|
||||
url: '/asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
|
||||
externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
|
||||
portableUrl: '/static/shahrukh.jpg',
|
||||
thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@shahrukh.jpg',
|
||||
locked: false,
|
||||
id: 'asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
|
||||
},
|
||||
{
|
||||
displayName: 'IMG_5899.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
dateAdded: 'Nov 16, 2021 at 18:55 UTC',
|
||||
url: '/asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
|
||||
externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
|
||||
portableUrl: '/static/IMG_5899.jpg',
|
||||
thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@IMG_5899.jpg',
|
||||
locked: false,
|
||||
id: 'asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
|
||||
},
|
||||
{
|
||||
displayName: 'ccexample.srt',
|
||||
contentType: 'application/octet-stream',
|
||||
dateAdded: 'Nov 01, 2021 at 15:42 UTC',
|
||||
url: '/asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
|
||||
externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
|
||||
portableUrl: '/static/ccexample.srt',
|
||||
thumbnail: null,
|
||||
locked: false,
|
||||
id: 'asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
|
||||
},
|
||||
{
|
||||
displayName: 'Tennis Ball.jpeg',
|
||||
contentType: 'image/jpeg',
|
||||
dateAdded: 'Aug 04, 2021 at 16:52 UTC',
|
||||
url: '/asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
|
||||
externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
|
||||
portableUrl: '/static/Tennis_Ball.jpeg',
|
||||
thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@Tennis_Ball-jpeg.jpg',
|
||||
locked: false,
|
||||
id: 'asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const normalizeContent = ({
|
||||
blockId,
|
||||
@@ -56,3 +107,26 @@ export const saveBlock = ({
|
||||
title,
|
||||
}),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line
|
||||
export const uploadImage = ({
|
||||
courseId,
|
||||
studioEndpointUrl,
|
||||
// image,
|
||||
}) => mockPromise({
|
||||
url: urls.courseAssets({ studioEndpointUrl, courseId }),
|
||||
image: {
|
||||
asset: {
|
||||
display_name: 'journey_escape.jpg',
|
||||
content_type: 'image/jpeg',
|
||||
date_added: 'Jan 05, 2022 at 21:26 UTC',
|
||||
url: '/asset-v1:edX+test101+2021_T1+type@asset+block@journey_escape.jpg',
|
||||
external_url: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@journey_escape.jpg',
|
||||
portable_url: '/static/journey_escape.jpg',
|
||||
thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@journey_escape.jpg',
|
||||
locked: false,
|
||||
id: 'asset-v1:edX+test101+2021_T1+type@asset+block@journey_escape.jpg',
|
||||
},
|
||||
msg: 'Upload completed',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,3 +9,11 @@ export const block = ({ studioEndpointUrl, blockId }) => (
|
||||
export const blockAncestor = ({ studioEndpointUrl, blockId }) => (
|
||||
`${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`
|
||||
);
|
||||
|
||||
export const courseAssets = ({ studioEndpointUrl, courseId }) => (
|
||||
`${studioEndpointUrl}/assets/${courseId}/`
|
||||
);
|
||||
|
||||
export const courseImages = ({ studioEndpointUrl, courseId }) => (
|
||||
`${courseAssets({ studioEndpointUrl, courseId })}?sort=uploadDate&direction=desc&asset_type=Images`
|
||||
);
|
||||
|
||||
@@ -2,11 +2,14 @@ import {
|
||||
unit,
|
||||
block,
|
||||
blockAncestor,
|
||||
courseAssets,
|
||||
courseImages,
|
||||
} from './urls';
|
||||
|
||||
describe('cms url methods', () => {
|
||||
const studioEndpointUrl = 'urLgoeStOstudiO';
|
||||
const blockId = 'blOckIDTeST123';
|
||||
const courseId = 'coUrseiD321';
|
||||
describe('unit', () => {
|
||||
const unitUrl = {
|
||||
data: {
|
||||
@@ -34,4 +37,16 @@ describe('cms url methods', () => {
|
||||
.toEqual(`${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`);
|
||||
});
|
||||
});
|
||||
describe('courseAssets', () => {
|
||||
it('returns url with studioEndpointUrl and courseId', () => {
|
||||
expect(courseAssets({ studioEndpointUrl, courseId }))
|
||||
.toEqual(`${studioEndpointUrl}/assets/${courseId}/`);
|
||||
});
|
||||
});
|
||||
describe('courseImages', () => {
|
||||
it('returns url with studioEndpointUrl, courseId and courseAssets query', () => {
|
||||
expect(courseImages({ studioEndpointUrl, courseId }))
|
||||
.toEqual(`${courseAssets({ studioEndpointUrl, courseId })}?sort=uploadDate&direction=desc&asset_type=Images`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
15
src/editors/utils/camelizeKeys.js
Normal file
15
src/editors/utils/camelizeKeys.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { camelCase } from 'lodash-es';
|
||||
|
||||
const camelizeKeys = (obj) => {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(v => camelizeKeys(v));
|
||||
}
|
||||
if (obj != null && obj.constructor === Object) {
|
||||
return Object.keys(obj).reduce(
|
||||
(result, key) => ({ ...result, [camelCase(key)]: camelizeKeys(obj[key]) }),
|
||||
{},
|
||||
);
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
export default camelizeKeys;
|
||||
32
src/editors/utils/camelizeKeys.test.js
Normal file
32
src/editors/utils/camelizeKeys.test.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { camelizeKeys } from './index';
|
||||
|
||||
const snakeCaseObject = {
|
||||
some_attribute:
|
||||
{
|
||||
another_attribute: [
|
||||
{ a_list: 'a lIsT' },
|
||||
{ of_attributes: 'iN diFferent' },
|
||||
{ different_cases: 'to Test' },
|
||||
],
|
||||
},
|
||||
a_final_attribute: null,
|
||||
a_last_one: undefined,
|
||||
};
|
||||
const camelCaseObject = {
|
||||
someAttribute:
|
||||
{
|
||||
anotherAttribute: [
|
||||
{ aList: 'a lIsT' },
|
||||
{ ofAttributes: 'iN diFferent' },
|
||||
{ differentCases: 'to Test' },
|
||||
],
|
||||
},
|
||||
aFinalAttribute: null,
|
||||
aLastOne: undefined,
|
||||
};
|
||||
|
||||
describe('camelizeKeys', () => {
|
||||
it('converts keys of objects to be camelCase', () => {
|
||||
expect(camelizeKeys(snakeCaseObject)).toEqual(camelCaseObject);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as StrictDict } from './StrictDict';
|
||||
export { default as keyStore } from './keyStore';
|
||||
export { default as camelizeKeys } from './camelizeKeys';
|
||||
|
||||
@@ -87,7 +87,10 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
|
||||
Group: 'Form.Group',
|
||||
Label: 'Form.Label',
|
||||
},
|
||||
SelectableBox: 'SelectableBox',
|
||||
Scrollable: 'Scrollable',
|
||||
SelectableBox: {
|
||||
Set: 'SelectableBox.Set',
|
||||
},
|
||||
Spinner: 'Spinner',
|
||||
Stack: 'Stack',
|
||||
Toast: 'Toast',
|
||||
|
||||
Reference in New Issue
Block a user