Feat select image modal (#38)

* feat: select image modal

* chore: fix module config
This commit is contained in:
Ben Warzeski
2022-03-24 16:16:38 -04:00
committed by GitHub
parent 09e9d865c2
commit 1a5497a5ae
24 changed files with 24670 additions and 198 deletions

23675
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,7 @@
"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",

View File

@@ -12,6 +12,7 @@ export const BaseModal = ({
title,
children,
confirmAction,
footerAction,
}) => (
<ModalDialog
title="My dialog"
@@ -21,6 +22,7 @@ export const BaseModal = ({
variant="default"
hasCloseButton
isFullscreenOnMobile
isFullscreenScroll
>
<ModalDialog.Header>
<ModalDialog.Title>
@@ -32,6 +34,8 @@ export const BaseModal = ({
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
{footerAction}
<ActionRow.Spacer />
<ModalDialog.CloseButton variant="tertiary" onClick={close}>
Cancel
</ModalDialog.CloseButton>
@@ -41,12 +45,17 @@ export const BaseModal = ({
</ModalDialog>
);
BaseModal.defaultProps = {
footerAction: null,
};
BaseModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
title: PropTypes.node.isRequired,
children: PropTypes.node.isRequired,
confirmAction: PropTypes.node.isRequired,
footerAction: PropTypes.node,
};
export default BaseModal;

View File

@@ -11,6 +11,7 @@ describe('BaseModal ImageUploadModal template component', () => {
title: 'props.title node',
children: 'props.children node',
confirmAction: 'props.confirmAction node',
footerAction: 'props.footerAction node',
};
expect(shallow(<BaseModal {...props} />)).toMatchSnapshot();
});

View File

@@ -31,6 +31,7 @@ exports[`ImageSettingsModal render snapshot 1`] = `
Save
</Button>
}
footerAction={null}
isOpen={false}
title="Image Settings"
>

View File

@@ -1,66 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { Button, Image } from '@edx/paragon';
import { thunkActions } from '../../../data/redux';
import BaseModal from './BaseModal';
import * as module from './SelectImageModal';
export const hooks = {
imageList: ({ fetchImages }) => {
const [images, setImages] = React.useState([]);
React.useEffect(() => {
fetchImages({ onSuccess: setImages });
}, []);
return images;
},
onSelectClick: ({ setSelection, images }) => () => setSelection(images[0]),
};
export const SelectImageModal = ({
fetchImages,
isOpen,
close,
setSelection,
}) => {
const images = module.hooks.imageList({ fetchImages });
const onSelectClick = module.hooks.onSelectClick({
setSelection,
images,
});
return (
<BaseModal
isOpen={isOpen}
close={close}
title="Add an image"
confirmAction={<Button variant="primary" onClick={onSelectClick}>Next</Button>}
>
{/* Content selection */}
{images.map(
img => (
<div key={img.externalUrl}>
<Image style={{ width: '100px', height: '100px' }} src={img.externalUrl} />
</div>
),
)}
</BaseModal>
);
};
SelectImageModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
setSelection: PropTypes.func.isRequired,
// redux
fetchImages: PropTypes.func.isRequired,
};
export const mapStateToProps = () => ({});
export const mapDispatchToProps = {
fetchImages: thunkActions.app.fetchImages,
};
export default connect(mapStateToProps, mapDispatchToProps)(SelectImageModal);

View File

@@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
export const ErrorAlert = ({
error,
setError,
}) => {
if (!error) {
return null;
}
return (
<Alert
variant="danger"
icon={Info}
dismissible
onClose={() => setError(null)}
>
<Alert.Heading>
Image not uploaded
</Alert.Heading>
<p>
Something went wrong while uploading your image. Please try again.
</p>
</Alert>
);
};
ErrorAlert.propTypes = {
error: PropTypes.string.isRequired,
setError: PropTypes.func.isRequired,
};
export default ErrorAlert;

View File

@@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Scrollable, SelectableBox, Spinner,
} from '@edx/paragon';
import GalleryCard from './GalleryCard';
export const Gallery = ({
loading,
displayList,
highlighted,
onHighlightChange,
}) => {
if (loading) {
return <Spinner animation="border" className="mie-3" screenReaderText="loading" />;
}
return (
<Scrollable className="gallery bg-gray-100" style={{ height: '375px' }}>
<div className="p-4">
<SelectableBox.Set
columns={1}
name="images"
onChange={onHighlightChange}
type="radio"
value={highlighted}
>
{displayList.map(img => <GalleryCard img={img} />)}
</SelectableBox.Set>
</div>
</Scrollable>
);
};
Gallery.defaultProps = {
loading: false,
};
Gallery.propTypes = {
loading: PropTypes.bool,
displayList: PropTypes.arrayOf(PropTypes.object).isRequired,
highlighted: PropTypes.string.isRequired,
onHighlightChange: PropTypes.func.isRequired,
};
export default Gallery;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Image, SelectableBox } from '@edx/paragon';
import { FormattedMessage, FormattedDate, FormattedTime } from '@edx/frontend-platform/i18n';
import messages from './messages';
export const GalleryCard = ({
img,
}) => (
<SelectableBox className="card bg-white" key={img.externalUrl} type="radio" value={img.id}>
<div className="card-div d-flex flex-row flex-nowrap">
<Image
style={{ width: '100px', height: '100px' }}
src={img.externalUrl}
/>
<div className="img-text p-3">
<h3>{img.displayName}</h3>
<p>
<FormattedMessage
{...messages.addedDate}
values={{
date: <FormattedDate value={img.dateAdded} />,
time: <FormattedTime value={img.dateAdded} />,
}}
/>
</p>
</div>
</div>
</SelectableBox>
);
GalleryCard.propTypes = {
img: PropTypes.shape({
contentType: PropTypes.string,
displayName: PropTypes.string,
externalUrl: PropTypes.string,
id: PropTypes.string,
dateAdded: PropTypes.number,
locked: PropTypes.bool,
portableUrl: PropTypes.string,
thumbnail: PropTypes.string,
url: PropTypes.string,
}).isRequired,
};
export default GalleryCard;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Image } from '@edx/paragon';
import { GalleryCard } from './GalleryCard';
describe('GalleryCard component', () => {
const img = {
externalUrl: 'props.img.externalUrl',
displayName: 'props.img.displayName',
dateAdded: 12345,
};
let el;
beforeEach(() => {
el = shallow(<GalleryCard img={img} />);
});
test(`snapshot: dateAdded=${img.dateAdded}`, () => {
expect(el).toMatchSnapshot();
});
it('loads Image with src from image external url', () => {
expect(el.find(Image).props().src).toEqual(img.externalUrl);
});
});

View File

@@ -0,0 +1,71 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
ActionRow, Dropdown, Form, Icon, IconButton,
} from '@edx/paragon';
import { Close, Search } from '@edx/paragon/icons';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { sortKeys, sortMessages } from './utils';
import messages from './messages';
export const SearchSort = ({
searchString,
onSearchChange,
clearSearchString,
sortBy,
onSortClick,
// injected
intl,
}) => (
<ActionRow>
<Form.Group style={{ margin: 0 }}>
<Form.Control
autoFocus
onChange={onSearchChange}
placeholder={intl.formatMessage(messages.searchPlaceholder)}
trailingElement={
searchString
? (
<IconButton
iconAs={Icon}
invertColors
isActive
onClick={clearSearchString}
size="sm"
src={Close}
/>
)
: <Icon src={Search} />
}
value={searchString}
/>
</Form.Group>
<ActionRow.Spacer />
<Dropdown>
<Dropdown.Toggle id="img-sort-button" variant="tertiary">
<FormattedMessage {...sortMessages[sortBy]} />
</Dropdown.Toggle>
<Dropdown.Menu>
{Object.keys(sortKeys).map(key => (
<Dropdown.Item key={key} onClick={onSortClick(key)}>
<FormattedMessage {...sortMessages[key]} />
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
</ActionRow>
);
SearchSort.propTypes = {
searchString: PropTypes.string.isRequired,
onSearchChange: PropTypes.func.isRequired,
clearSearchString: PropTypes.func.isRequired,
sortBy: PropTypes.string.isRequired,
onSortClick: PropTypes.func.isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(SearchSort);

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Dropdown } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { formatMessage } from '../../../../../testUtils';
import { sortKeys, sortMessages } from './utils';
import { SearchSort } from './SearchSort';
describe('SearchSort component', () => {
const props = {
searchString: 'props.searchString',
onSearchChange: jest.fn().mockName('props.onSearchChange'),
clearSearchString: jest.fn().mockName('props.clearSearchString'),
sortBy: sortKeys.dateOldest,
onSortClick: jest.fn().mockName('props.onSortClick'),
intl: { formatMessage },
};
describe('snapshots', () => {
test('with search string (close button)', () => {
expect(shallow(<SearchSort {...props} />)).toMatchSnapshot();
});
test('without search string (search icon)', () => {
expect(shallow(<SearchSort {...props} searchString="" />)).toMatchSnapshot();
});
test('adds a sort option for each sortKey', () => {
const el = shallow(<SearchSort {...props} />);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...sortMessages.dateNewest} />,
)).toEqual(true);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...sortMessages.dateOldest} />,
)).toEqual(true);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...sortMessages.nameAscending} />,
)).toEqual(true);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...sortMessages.nameDescending} />,
)).toEqual(true);
});
});
});

View File

@@ -0,0 +1,47 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GalleryCard component snapshot: dateAdded=12345 1`] = `
<SelectableBox
className="card bg-white"
key="props.img.externalUrl"
type="radio"
>
<div
className="card-div d-flex flex-row flex-nowrap"
>
<Image
src="props.img.externalUrl"
style={
Object {
"height": "100px",
"width": "100px",
}
}
/>
<div
className="img-text p-3"
>
<h3>
props.img.displayName
</h3>
<p>
<FormattedMessage
defaultMessage="Added {date} at {time}"
description="File date-added string"
id="authoring.texteditor.selectimagemodal.addedDate.part1.label"
values={
Object {
"date": <FormattedDate
value={12345}
/>,
"time": <FormattedTime
value={12345}
/>,
}
}
/>
</p>
</div>
</div>
</SelectableBox>
`;

View File

@@ -0,0 +1,152 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SearchSort component snapshots with search string (close button) 1`] = `
<ActionRow>
<Form.Group
style={
Object {
"margin": 0,
}
}
>
<Form.Control
autoFocus={true}
onChange={[MockFunction props.onSearchChange]}
placeholder="Search"
trailingElement={
<IconButton
iconAs="Icon"
invertColors={true}
isActive={true}
onClick={[MockFunction props.clearSearchString]}
size="sm"
src={[MockFunction icons.Close]}
/>
}
value="props.searchString"
/>
</Form.Group>
<ActionRow.Spacer />
<Dropdown>
<Dropdown.Toggle
id="img-sort-button"
variant="tertiary"
>
<FormattedMessage
defaultMessage="By date added (oldest)"
description="Dropdown label for sorting by date (oldest)"
id="authoring.texteditor.selectimagemodal.sort.dateoldest.label"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
key="dateNewest"
>
<FormattedMessage
defaultMessage="By date added (newest)"
description="Dropdown label for sorting by date (newest)"
id="authoring.texteditor.selectimagemodal.sort.datenewest.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="dateOldest"
>
<FormattedMessage
defaultMessage="By date added (oldest)"
description="Dropdown label for sorting by date (oldest)"
id="authoring.texteditor.selectimagemodal.sort.dateoldest.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="nameAscending"
>
<FormattedMessage
defaultMessage="By name (ascending)"
description="Dropdown label for sorting by name (ascending)"
id="authoring.texteditor.selectimagemodal.sort.nameascending.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="nameDescending"
>
<FormattedMessage
defaultMessage="By name (descending)"
description="Dropdown label for sorting by name (descending)"
id="authoring.texteditor.selectimagemodal.sort.namedescending.label"
/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</ActionRow>
`;
exports[`SearchSort component snapshots without search string (search icon) 1`] = `
<ActionRow>
<Form.Group
style={
Object {
"margin": 0,
}
}
>
<Form.Control
autoFocus={true}
onChange={[MockFunction props.onSearchChange]}
placeholder="Search"
trailingElement={<Icon />}
value=""
/>
</Form.Group>
<ActionRow.Spacer />
<Dropdown>
<Dropdown.Toggle
id="img-sort-button"
variant="tertiary"
>
<FormattedMessage
defaultMessage="By date added (oldest)"
description="Dropdown label for sorting by date (oldest)"
id="authoring.texteditor.selectimagemodal.sort.dateoldest.label"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
key="dateNewest"
>
<FormattedMessage
defaultMessage="By date added (newest)"
description="Dropdown label for sorting by date (newest)"
id="authoring.texteditor.selectimagemodal.sort.datenewest.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="dateOldest"
>
<FormattedMessage
defaultMessage="By date added (oldest)"
description="Dropdown label for sorting by date (oldest)"
id="authoring.texteditor.selectimagemodal.sort.dateoldest.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="nameAscending"
>
<FormattedMessage
defaultMessage="By name (ascending)"
description="Dropdown label for sorting by name (ascending)"
id="authoring.texteditor.selectimagemodal.sort.nameascending.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="nameDescending"
>
<FormattedMessage
defaultMessage="By name (descending)"
description="Dropdown label for sorting by name (descending)"
id="authoring.texteditor.selectimagemodal.sort.namedescending.label"
/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</ActionRow>
`;

View File

@@ -0,0 +1,91 @@
import React from 'react';
import * as module from './hooks';
import { sortFunctions, sortKeys } from './utils';
export const state = {
images: (val) => React.useState(val),
highlighted: (val) => React.useState(val),
searchString: (val) => React.useState(val),
sortBy: (val) => React.useState(val),
};
export const searchAndSortHooks = () => {
const [searchString, setSearchString] = module.state.searchString('');
const [sortBy, setSortBy] = module.state.sortBy(sortKeys.dateNewest);
return {
searchString,
onSearchChange: e => setSearchString(e.target.value),
clearSearchString: () => setSearchString(''),
sortBy,
onSortClick: key => () => setSortBy(key),
};
};
export const filteredList = ({ searchString, imageList }) => imageList.filter(
({ displayName }) => displayName.toLowerCase().includes(searchString.toLowerCase()),
);
export const displayList = ({ sortBy, searchString, images }) => module.filteredList({
searchString,
imageList: Object.values(images),
}).sort(sortFunctions[sortBy in sortKeys ? sortKeys[sortBy] : sortKeys.dateNewest]);
export const imgListHooks = ({
fetchImages,
setSelection,
searchSortProps,
}) => {
const [images, setImages] = module.state.images({});
const [highlighted, setHighlighted] = module.state.highlighted(null);
React.useEffect(() => {
fetchImages({ onSuccess: setImages });
}, []);
return {
images,
// highlight by id
selectBtnProps: {
disabled: !highlighted,
onClick: () => setSelection(images[highlighted]),
},
galleryProps: {
highlighted,
onHighlightChange: e => setHighlighted(e.target.value),
displayList: module.displayList({ ...searchSortProps, images }),
},
};
};
export const fileInputHooks = ({ uploadImage }) => {
const ref = React.useRef();
const click = () => ref.current.click();
const resetFile = () => { ref.current.value = ''; };
const addFile = (e) => uploadImage({
file: e.target.files[0],
resetFile,
});
return {
click,
addFile,
ref,
};
};
export const imgHooks = ({ fetchImages, uploadImage, setSelection }) => {
const searchSortProps = module.searchAndSortHooks();
const imgList = module.imgListHooks({ fetchImages, setSelection, searchSortProps });
const fileInput = module.fileInputHooks({ uploadImage });
const { selectBtnProps, galleryProps } = imgList;
return {
fileInput,
galleryProps,
selectBtnProps,
searchSortProps,
};
};
export default {
imgHooks,
};

View File

@@ -0,0 +1,250 @@
import React from 'react';
import { MockUseState } from '../../../../../testUtils';
import { keyStore } from '../../../../utils';
import * as hooks from './hooks';
import { sortFunctions, sortKeys } from './utils';
jest.mock('react', () => ({
...jest.requireActual('react'),
useRef: jest.fn(val => ({ current: val })),
useEffect: jest.fn(),
useCallback: (cb, prereqs) => ({ cb, prereqs }),
}));
const state = new MockUseState(hooks);
const hookKeys = keyStore(hooks);
let hook;
const testValue = 'testVALUE';
describe('SelectImageModal hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('state hooks', () => {
state.testGetter(state.keys.images);
state.testGetter(state.keys.highlighted);
state.testGetter(state.keys.searchString);
state.testGetter(state.keys.sortBy);
});
describe('using state', () => {
beforeEach(() => { state.mock(); });
afterEach(() => { state.restore(); });
describe('searchAndSortHooks', () => {
beforeEach(() => {
hook = hooks.searchAndSortHooks();
});
it('returns searchString value, initialized to an empty string', () => {
expect(state.stateVals.searchString).toEqual(hook.searchString);
expect(state.stateVals.searchString).toEqual('');
});
it('returns highlighted value, initialized to dateNewest', () => {
expect(state.stateVals.sortBy).toEqual(hook.sortBy);
expect(state.stateVals.sortBy).toEqual(sortKeys.dateNewest);
});
test('onSearchChange sets searchString with event target value', () => {
hook.onSearchChange({ target: { value: testValue } });
expect(state.setState.searchString).toHaveBeenCalledWith(testValue);
});
test('clearSearchString sets search string to empty string', () => {
hook.clearSearchString();
expect(state.setState.searchString).toHaveBeenCalledWith('');
});
test('onSortClick takes a key and returns callback to set sortBY to that key', () => {
hook.onSortClick(testValue);
expect(state.setState.sortBy).not.toHaveBeenCalled();
hook.onSortClick(testValue)();
expect(state.setState.sortBy).toHaveBeenCalledWith(testValue);
});
});
describe('filteredList', () => {
const matching = [
'test',
'TEst',
'eeees',
'essSSSS',
];
const notMatching = ['bad', 'other', 'bad stuff'];
const searchString = 'eS';
test('returns list filtered lowercase by displayName', () => {
const filter = jest.fn(cb => ({ filter: cb }));
hook = hooks.filteredList({ searchString, imageList: { filter } });
expect(filter).toHaveBeenCalled();
const [[filterCb]] = filter.mock.calls;
matching.forEach(val => expect(filterCb({ displayName: val })).toEqual(true));
notMatching.forEach(val => expect(filterCb({ displayName: val })).toEqual(false));
});
});
describe('displayList', () => {
const props = {
images: { p1: 'data1', p2: 'data2', p3: 'other distinct data' },
sortBy: sortKeys.dateNewest,
searchString: 'test search string',
};
const load = (loadProps = {}) => {
jest.spyOn(hooks, hookKeys.filteredList).mockImplementationOnce(
({ searchString, imageList }) => ({
sort: (cb) => ({ filteredList: { searchString, imageList }, sort: { cb } }),
}),
);
hook = hooks.displayList({ ...props, ...loadProps });
};
it('returns a sorted filtered list, based on the searchString and imageList values', () => {
load();
expect(hook.filteredList.searchString).toEqual(props.searchString);
expect(hook.filteredList.imageList).toEqual(Object.values(props.images));
});
describe('sort behavior', () => {
Object.keys(sortKeys).forEach(key => {
test(`it sorts by ${key} when selected`, () => {
load({ sortBy: sortKeys[key] });
expect(hook.sort).toEqual({ cb: sortFunctions[key] });
});
});
test('defaults to sorting by dateNewest', () => {
load();
expect(hook.sort).toEqual({ cb: sortFunctions.dateNewest });
});
});
});
describe('imgListHooks outputs', () => {
const props = {
fetchImages: jest.fn(),
setSelection: jest.fn(),
searchSortProps: { searchString: 'Es', sortBy: sortKeys.dateNewest },
};
const displayList = (args) => ({ displayList: args });
const load = () => {
jest.spyOn(hooks, hookKeys.displayList).mockImplementationOnce(displayList);
hook = hooks.imgListHooks(props);
};
beforeEach(() => {
load();
});
it('returns images value, initialized to an empty object', () => {
expect(state.stateVals.images).toEqual(hook.images);
expect(state.stateVals.images).toEqual({});
});
it('calls fetchImages 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 });
});
describe('selectBtnProps', () => {
it('is disabled if nothing is highlighted', () => {
expect(hook.selectBtnProps.disabled).toEqual(true);
state.mockVal(state.keys.highlighted, { some: 'value' });
load();
expect(hook.selectBtnProps.disabled).toEqual(false);
});
test('on click, if sets selection to the image with the same id', () => {
const highlighted = 'id1';
state.mockVal(state.keys.images, { [highlighted]: testValue });
state.mockVal(state.keys.highlighted, highlighted);
load();
expect(props.setSelection).not.toHaveBeenCalled();
hook.selectBtnProps.onClick();
expect(props.setSelection).toHaveBeenCalledWith(testValue);
});
});
describe('galleryProps', () => {
it('returns highlighted value, initialized to null', () => {
expect(hook.galleryProps.highlighted).toEqual(state.stateVals.highlighted);
expect(state.stateVals.highlighted).toEqual(null);
});
test('onHighlightChange sets highlighted with event target value', () => {
expect(hook.galleryProps.onHighlightChange({ target: { value: testValue } }));
expect(state.setState.highlighted).toHaveBeenCalledWith(testValue);
});
test('displayList returns displayListhook called with searchSortProps and images', () => {
expect(hook.galleryProps.displayList).toEqual(displayList({
...props.searchSortProps,
images: hook.images,
}));
});
});
});
});
describe('fileInputHooks', () => {
const uploadImage = jest.fn();
beforeEach(() => {
hook = hooks.fileInputHooks({ uploadImage });
});
it('returns a ref for the file input', () => {
expect(hook.ref).toEqual({ current: undefined });
});
test('click calls current.click on the ref', () => {
const click = jest.fn();
React.useRef.mockReturnValueOnce({ current: { click } });
hook = hooks.fileInputHooks({ uploadImage });
hook.click();
expect(click).toHaveBeenCalled();
});
describe('addFile (uploadImage args)', () => {
const event = { target: { files: [testValue] } };
it('calls uploadImage with the first target file', () => {
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('');
});
});
});
describe('imgHooks wrapper', () => {
const imgListHooks = {
galleryProps: 'some gallery props',
selectBtnProps: 'some select btn props',
};
const searchAndSortHooks = { search: 'props' };
const fileInputHooks = { file: 'input hooks' };
const fetchImages = jest.fn();
const uploadImage = jest.fn();
const setSelection = jest.fn();
const spies = {};
beforeEach(() => {
spies.imgList = jest.spyOn(hooks, hookKeys.imgListHooks)
.mockReturnValueOnce(imgListHooks);
spies.search = jest.spyOn(hooks, hookKeys.searchAndSortHooks)
.mockReturnValueOnce(searchAndSortHooks);
spies.file = jest.spyOn(hooks, hookKeys.fileInputHooks)
.mockReturnValueOnce(fileInputHooks);
hook = hooks.imgHooks({ fetchImages, uploadImage, 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,
});
});
it('initializes imgListHooks with fetchImages, setSelection and searchAndSortHooks', () => {
expect(spies.imgList.mock.calls.length).toEqual(1);
expect(spies.imgList).toHaveBeenCalledWith({
fetchImages,
setSelection,
searchSortProps: searchAndSortHooks,
});
});
it('forwards searchAndSortHooks as searchSortProps', () => {
expect(hook.searchSortProps).toEqual(searchAndSortHooks);
expect(spies.file.mock.calls.length).toEqual(1);
expect(spies.file).toHaveBeenCalledWith({ uploadImage });
});
it('forwards galleryProps and selectBtnProps from the image list hooks', () => {
expect(hook.galleryProps).toEqual(imgListHooks.galleryProps);
expect(hook.selectBtnProps).toEqual(imgListHooks.selectBtnProps);
});
});
});

View File

@@ -0,0 +1,90 @@
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)
export const SelectImageModal = ({
isOpen,
close,
setSelection,
// injected
intl,
// redux
fetchImages,
uploadImage,
}) => {
const {
searchSortProps,
galleryProps,
selectBtnProps,
fileInput,
} = hooks.imgHooks({ fetchImages, uploadImage, setSelection });
return (
<BaseModal
close={close}
confirmAction={(
<Button {...selectBtnProps} variant="primary">
<FormattedMessage {...messages.nextButtonLabel} />
</Button>
)}
isOpen={isOpen}
footerAction={(
<Button iconBefore={Add} onClick={fileInput.click} variant="link">
<FormattedMessage {...messages.uploadButtonLabel} />
</Button>
)}
title={intl.formatMessage(messages.titleLabel)}
>
<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"
/>
</Stack>
</BaseModal>
);
};
SelectImageModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
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));

View File

@@ -0,0 +1,50 @@
export const messages = {
nextButtonLabel: {
id: 'authoring.texteditor.selectimagemodal.next.label',
defaultMessage: 'Next',
description: 'Label for Next button',
},
uploadButtonLabel: {
id: 'authoring.texteditor.selectimagemodal.upload.label',
defaultMessage: 'Upload a new image',
description: 'Label for upload button',
},
titleLabel: {
id: 'authoring.texteditor.selectimagemodal.title.label',
defaultMessage: 'Add an image',
description: 'Title for the select image modal',
},
searchPlaceholder: {
id: 'authoring.texteditor.selectimagemodal.search.placeholder',
defaultMessage: 'Search',
description: 'Placeholder text for search bar',
},
sortByDateNewest: {
id: 'authoring.texteditor.selectimagemodal.sort.datenewest.label',
defaultMessage: 'By date added (newest)',
description: 'Dropdown label for sorting by date (newest)',
},
sortByDateOldest: {
id: 'authoring.texteditor.selectimagemodal.sort.dateoldest.label',
defaultMessage: 'By date added (oldest)',
description: 'Dropdown label for sorting by date (oldest)',
},
sortByNameAscending: {
id: 'authoring.texteditor.selectimagemodal.sort.nameascending.label',
defaultMessage: 'By name (ascending)',
description: 'Dropdown label for sorting by name (ascending)',
},
sortByNameDescending: {
id: 'authoring.texteditor.selectimagemodal.sort.namedescending.label',
defaultMessage: 'By name (descending)',
description: 'Dropdown label for sorting by name (descending)',
},
// Date added messages
addedDate: {
id: 'authoring.texteditor.selectimagemodal.addedDate.part1.label',
defaultMessage: 'Added {date} at {time}',
description: 'File date-added string',
},
};
export default messages;

View File

@@ -0,0 +1,46 @@
import { StrictDict, keyStore } from '../../../../utils';
import messages from './messages';
export const sortKeys = StrictDict({
dateNewest: 'dateNewest',
dateOldest: 'dateOldest',
nameAscending: 'nameAscending',
nameDescending: 'nameDescending',
});
const messageKeys = keyStore(messages);
export const sortMessages = StrictDict({
dateNewest: messages[messageKeys.sortByDateNewest],
dateOldest: messages[messageKeys.sortByDateOldest],
nameAscending: messages[messageKeys.sortByNameAscending],
nameDescending: messages[messageKeys.sortByNameDescending],
});
export const sortFunctions = StrictDict({
dateNewest: (a, b) => b.dateAdded - a.dateAdded,
dateOldest: (a, b) => a.dateAdded - b.dateAdded,
nameAscending: (a, b) => {
const nameA = a.displayName.toLowerCase();
const nameB = b.displayName.toLowerCase();
if (nameA < nameB) { return -1; }
if (nameB < nameA) { return 1; }
return b.dateAdded - a.dateAdded;
},
nameDescending: (a, b) => {
const nameA = a.displayName.toLowerCase();
const nameB = b.displayName.toLowerCase();
if (nameA < nameB) { return 1; }
if (nameB < nameA) { return -1; }
return b.dateAdded - a.dateAdded;
},
});
export const acceptedImgKeys = StrictDict({
gif: '.gif',
jpg: '.jpg',
jpeg: '.jpeg',
png: '.png',
tif: '.tif',
tiff: '.tiff',
});

View File

@@ -4,6 +4,7 @@ exports[`BaseModal ImageUploadModal template component snapshot 1`] = `
<ModalDialog
hasCloseButton={true}
isFullscreenOnMobile={true}
isFullscreenScroll={true}
isOpen={true}
onClose={[MockFunction props.close]}
size="lg"
@@ -20,6 +21,8 @@ exports[`BaseModal ImageUploadModal template component snapshot 1`] = `
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
props.footerAction node
<ActionRow.Spacer />
<ModalDialog.CloseButton
onClick={[MockFunction props.close]}
variant="tertiary"

View File

@@ -46,7 +46,50 @@ export const saveBlock = ({ content, returnToUnit }) => (dispatch) => {
export const fetchImages = ({ onSuccess }) => () => {
// get images
onSuccess(mockData.mockImageData);
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 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 default StrictDict({
@@ -55,4 +98,5 @@ export default StrictDict({
initialize,
saveBlock,
fetchImages,
uploadImage,
});

View File

@@ -53,7 +53,9 @@ jest.mock('@edx/frontend-platform/i18n', () => {
formatMessage: PropTypes.func,
}),
defineMessages: m => m,
FormattedDate: () => 'FormattedDate',
FormattedMessage: () => 'FormattedMessage',
FormattedTime: () => 'FormattedTime',
};
});
@@ -62,6 +64,11 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
Spacer: 'ActionRow.Spacer',
},
Button: 'Button',
Dropdown: {
Item: 'Dropdown.Item',
Menu: 'Dropdown.Menu',
Toggle: 'Dropdown.Toggle',
},
Icon: 'Icon',
IconButton: 'IconButton',
Image: 'Image',
@@ -80,7 +87,9 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
Group: 'Form.Group',
Label: 'Form.Label',
},
SelectableBox: 'SelectableBox',
Spinner: 'Spinner',
Stack: 'Stack',
Toast: 'Toast',
}));

62
www/package-lock.json generated
View File

@@ -1250,6 +1250,7 @@
"@edx/frontend-lib-content-components": {
"version": "file:..",
"requires": {
"@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",
@@ -17618,29 +17619,29 @@
}
},
"@edx/paragon": {
"version": "16.17.0",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-16.17.0.tgz",
"integrity": "sha512-Y0gdOtOLupq+9fiXAVhgz7KoyxRCnApxffaYC9g+JcQGzA4bHRQx37betY9iKgGk+Nh9NYL8B4qizsIuj6zoEA==",
"version": "19.10.2",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-19.10.2.tgz",
"integrity": "sha512-SdcFwmcu00dQfKt5ZeUAVjUVso45jpj+oyxyogYauNv3a9bhCkRYggjdt3rb/TdH4r2C6pwCz/x5DVr1DMD8RA==",
"requires": {
"@fortawesome/fontawesome-svg-core": "^1.2.30",
"@fortawesome/free-solid-svg-icons": "^5.14.0",
"@fortawesome/react-fontawesome": "^0.1.11",
"@popperjs/core": "^2.6.0",
"@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.1.16",
"@popperjs/core": "^2.11.2",
"airbnb-prop-types": "^2.12.0",
"bootstrap": "4.6.0",
"classnames": "^2.2.6",
"classnames": "^2.3.1",
"email-prop-type": "^3.0.0",
"font-awesome": "^4.7.0",
"lodash.uniqby": "^4.7.0",
"mailto-link": "^1.0.0",
"prop-types": "^15.7.2",
"react-bootstrap": "^1.3.0",
"react-focus-on": "^3.5.0",
"react-popper": "^2.2.4",
"prop-types": "^15.8.1",
"react-bootstrap": "^1.6.4",
"react-focus-on": "^3.5.4",
"react-popper": "^2.2.5",
"react-proptype-conditional-require": "^1.0.4",
"react-responsive": "^8.2.0",
"react-table": "^7.6.1",
"react-transition-group": "^4.0.0",
"react-table": "^7.7.0",
"react-transition-group": "^4.4.2",
"tabbable": "^4.0.0",
"uncontrollable": "7.2.1"
}
@@ -17818,16 +17819,16 @@
}
},
"@fortawesome/fontawesome-common-types": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.3.0.tgz",
"integrity": "sha512-CA3MAZBTxVsF6SkfkHXDerkhcQs0QPofy43eFdbWJJkZiq3SfiaH1msOkac59rQaqto5EqWnASboY1dBuKen5w=="
"version": "0.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
"integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg=="
},
"@fortawesome/fontawesome-svg-core": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.3.0.tgz",
"integrity": "sha512-UIL6crBWhjTNQcONt96ExjUnKt1D68foe3xjEensLDclqQ6YagwCRYVQdrp/hW0ALRp/5Fv/VKw+MqTUWYYvPg==",
"version": "1.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz",
"integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==",
"requires": {
"@fortawesome/fontawesome-common-types": "^0.3.0"
"@fortawesome/fontawesome-common-types": "^0.2.36"
}
},
"@fortawesome/free-solid-svg-icons": {
@@ -17836,19 +17837,12 @@
"integrity": "sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w==",
"requires": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
},
"dependencies": {
"@fortawesome/fontawesome-common-types": {
"version": "0.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
"integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg=="
}
}
},
"@fortawesome/react-fontawesome": {
"version": "0.1.17",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.17.tgz",
"integrity": "sha512-dX43Z5IvMaW7fwzU8farosYjKNGfRb2HB/DgjVBHeJZ/NSnuuaujPPx0YOdcAq+n3mqn70tyCde2HM1mqbhiuw==",
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.18.tgz",
"integrity": "sha512-RwLIB4TZw0M9gvy5u+TusAA0afbwM4JQIimNH/j3ygd6aIvYPQLqXMhC9ErY26J23rDPyDZldIfPq/HpTTJ/tQ==",
"requires": {
"prop-types": "^15.8.1"
}
@@ -18444,9 +18438,9 @@
}
},
"@popperjs/core": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.2.tgz",
"integrity": "sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA=="
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.4.tgz",
"integrity": "sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg=="
},
"@restart/context": {
"version": "2.1.4",

View File

@@ -14,7 +14,7 @@
"@edx/frontend-build": "^9.1.1",
"@edx/frontend-platform": "1.14.0",
"@edx/frontend-lib-content-components": "file:..",
"@edx/paragon": "16.17.0",
"@edx/paragon": "19.10.2",
"core-js": "^3.21.1",
"dotenv": "^16.0.0",
"prop-types": "^15.5.10",