Feat select image modal (#38)
* feat: select image modal * chore: fix module config
This commit is contained in:
23675
package-lock.json
generated
23675
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ exports[`ImageSettingsModal render snapshot 1`] = `
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
footerAction={null}
|
||||
isOpen={false}
|
||||
title="Image Settings"
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
62
www/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user