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:
Ben Warzeski
2022-03-30 15:13:19 -04:00
committed by GitHub
parent 1a5497a5ae
commit 0f87a61639
34 changed files with 900 additions and 23742 deletions

View File

@@ -11,4 +11,7 @@ module.exports = createConfig('jest', {
snapshotSerializers: [
'enzyme-to-json/serializer',
],
moduleNameMapper: {
'^lodash-es$': 'lodash',
},
});

23611
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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..."
/>
`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
/* istanbul ignore file */
export const mockImageData = [
{
displayName: 'shahrukh.jpg',

View File

@@ -11,4 +11,6 @@ export const RequestKeys = StrictDict({
fetchBlock: 'fetchBlock',
fetchUnit: 'fetchUnit',
saveBlock: 'saveBlock',
fetchImages: 'fetchImages',
uploadImage: 'uploadImage',
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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