feat: Video selection page created

The SelectImageModal component has been refactored so that it can also be used on the video selection page; and all its child components.
Now this component is called SelectionModal and is used both for the image selector and in this new video selection screen.
The assets api has been used to get the videos.
This commit is contained in:
XnpioChV
2023-03-15 11:57:55 -05:00
parent 2a5f6795d3
commit 14504073e0
37 changed files with 1317 additions and 422 deletions

34
src/editors/Selector.jsx Normal file
View File

@@ -0,0 +1,34 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import VideoGallery from './containers/VideoGallery';
import * as hooks from './hooks';
export const Selector = ({
learningContextId,
lmsEndpointUrl,
studioEndpointUrl,
}) => {
const dispatch = useDispatch();
hooks.initializeApp({
dispatch,
data: {
blockId: '',
blockType: 'video',
learningContextId,
lmsEndpointUrl,
studioEndpointUrl,
},
});
return (
<VideoGallery />
);
};
Selector.propTypes = {
learningContextId: PropTypes.string.isRequired,
lmsEndpointUrl: PropTypes.string.isRequired,
studioEndpointUrl: PropTypes.string.isRequired,
};
export default Selector;

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { shallow } from 'enzyme';
import * as hooks from './hooks';
import Selector from './Selector';
jest.mock('./hooks', () => ({
initializeApp: jest.fn(),
}));
jest.mock('./containers/VideoGallery', () => 'VideoGallery');
const props = {
learningContextId: 'course-v1:edX+DemoX+Demo_Course',
lmsEndpointUrl: 'evenfakerurl.com',
studioEndpointUrl: 'fakeurl.com',
};
const initData = {
blockId: '',
blockType: 'video',
...props,
};
describe('Editor', () => {
describe('render', () => {
test('rendering correctly with expected Input', () => {
expect(shallow(<Selector {...props} />)).toMatchSnapshot();
});
});
describe('behavior', () => {
it('calls initializeApp hook with dispatch, and passed data', () => {
shallow(<Selector {...props} />);
expect(hooks.initializeApp).toHaveBeenCalledWith({
dispatch: useDispatch(),
data: initData,
});
});
});
});

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { Provider } from 'react-redux';
import PropTypes from 'prop-types';
import ErrorBoundary from './sharedComponents/ErrorBoundary';
import { Selector } from './Selector';
import store from './data/store';
const SelectorPage = ({
courseId,
lmsEndpointUrl,
studioEndpointUrl,
}) => (
<ErrorBoundary>
<Provider store={store}>
<Selector
{...{
learningContextId: courseId,
lmsEndpointUrl,
studioEndpointUrl,
}}
/>
</Provider>
</ErrorBoundary>
);
SelectorPage.defaultProps = {
courseId: null,
lmsEndpointUrl: null,
studioEndpointUrl: null,
};
SelectorPage.propTypes = {
courseId: PropTypes.string,
lmsEndpointUrl: PropTypes.string,
studioEndpointUrl: PropTypes.string,
};
export default SelectorPage;

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { shallow } from 'enzyme';
import SelectorPage from './SelectorPage';
const props = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
lmsEndpointUrl: 'evenfakerurl.com',
studioEndpointUrl: 'fakeurl.com',
};
jest.mock('react-redux', () => ({
Provider: 'Provider',
}));
jest.mock('./Selector', () => 'Selector');
describe('Selector Page', () => {
describe('snapshots', () => {
test('rendering correctly with expected Input', () => {
expect(shallow(<SelectorPage {...props} />)).toMatchSnapshot();
});
test('rendering with props to null', () => {
expect(shallow(<SelectorPage />)).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Editor render rendering correctly with expected Input 1`] = `<VideoGallery />`;

View File

@@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Selector Page snapshots rendering correctly with expected Input 1`] = `
<ErrorBoundary>
<Provider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<Component
learningContextId="course-v1:edX+DemoX+Demo_Course"
lmsEndpointUrl="evenfakerurl.com"
studioEndpointUrl="fakeurl.com"
/>
</Provider>
</ErrorBoundary>
`;
exports[`Selector Page snapshots rendering with props to null 1`] = `
<ErrorBoundary>
<Provider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<Component
learningContextId={null}
lmsEndpointUrl={null}
studioEndpointUrl={null}
/>
</Provider>
</ErrorBoundary>
`;

View File

@@ -25,6 +25,7 @@ exports[`EditorContainer component render snapshot: initialized. enable save and
</Button>
}
footerAction={null}
isFullscreenScroll={true}
isOpen={false}
size="md"
title="Exit the editor?"
@@ -103,6 +104,7 @@ exports[`EditorContainer component render snapshot: not initialized. disable sav
</Button>
}
footerAction={null}
isFullscreenScroll={true}
isOpen={false}
size="md"
title="Exit the editor?"

View File

@@ -21,6 +21,7 @@ exports[`SwitchToAdvancedEditorCard snapshot snapshot: SwitchToAdvancedEditorCar
</Button>
}
footerAction={null}
isFullscreenScroll={true}
isOpen={false}
size="md"
title={

View File

@@ -0,0 +1,92 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VideoGallery component snapshot 1`] = `
<div>
<SelectionModal
acceptedFiles={
Object {
"mp4": ".mp4",
}
}
close={[Function]}
fileInput={
Object {
"addFile": "videoHooks.fileInput.addFile",
"click": "videoHooks.fileInput.click",
"ref": "videoHooks.fileInput.ref",
}
}
galleryError={
Object {
"dismiss": [MockFunction],
"message": Object {
"defaultMessage": "Gallery error",
"description": "Gallery error",
"id": "Gallery error id",
},
"set": [MockFunction],
"show": "ShoWERror gAlLery",
}
}
galleryProps={
Object {
"gallery": "props",
}
}
inputError={
Object {
"dismiss": [MockFunction],
"message": Object {
"defaultMessage": "Input error",
"description": "Input error",
"id": "Input error id",
},
"set": [MockFunction],
"show": "ShoWERror inPUT",
}
}
isFullscreenScroll={false}
isOpen={true}
modalMessages={
Object {
"confirmMsg": Object {
"defaultMessage": "Select video",
"description": "Label for Select video button",
"id": "authoring.selectvideomodal.selectvideo.label",
},
"fetchError": Object {
"defaultMessage": "Failed to obtain course videos. Please try again.",
"description": "Message presented to user when videos are not found",
"id": "authoring.selectvideomodal.error.fetchVideosError",
},
"titleMsg": Object {
"defaultMessage": "Add video to your course",
"description": "Title for the select video modal",
"id": "authoring.selectvideomodal.title.label",
},
"uploadButtonMsg": Object {
"defaultMessage": "Upload or embed a new video",
"description": "Label for upload button",
"id": "authoring.selectvideomodal.upload.label",
},
"uploadError": Object {
"defaultMessage": "Failed to upload video. Please try again.",
"description": "Message presented to user when video fails to upload",
"id": "authoring.selectvideomodal.error.uploadVideoError",
},
}
}
searchSortProps={
Object {
"search": "sortProps",
}
}
selectBtnProps={
Object {
"select": "btnProps",
}
}
size="fullscreen"
/>
</div>
`;

View File

@@ -0,0 +1,134 @@
import React from 'react';
import * as module from './hooks';
import messages from './messages';
import {
filterKeys,
filterMessages,
sortKeys,
sortMessages,
} from './utils';
export const state = {
highlighted: (val) => React.useState(val),
searchString: (val) => React.useState(val),
showSelectVideoError: (val) => React.useState(val),
showSizeError: (val) => React.useState(val),
sortBy: (val) => React.useState(val),
filertBy: (val) => React.useState(val),
hideSelectedVideos: (val) => React.useState(val),
};
export const searchAndSortHooks = () => {
const [searchString, setSearchString] = module.state.searchString('');
const [sortBy, setSortBy] = module.state.sortBy(sortKeys.dateNewest);
const [filterBy, setFilterBy] = module.state.filertBy(filterKeys.videoStatus);
const [hideSelectedVideos, setHideSelectedVideos] = module.state.hideSelectedVideos(false);
return {
searchString,
onSearchChange: (e) => setSearchString(e.target.value),
clearSearchString: () => setSearchString(''),
sortBy,
onSortClick: (key) => () => setSortBy(key),
sortKeys,
sortMessages,
filterBy,
onFilterClick: (key) => () => setFilterBy(key),
filterKeys,
filterMessages,
showSwitch: true,
hideSelectedVideos,
switchMessage: messages.hideSelectedCourseVideosSwitchLabel,
onSwitchClick: () => setHideSelectedVideos(!hideSelectedVideos),
};
};
export const videoListHooks = ({ videos }) => {
const [highlighted, setHighlighted] = module.state.highlighted(null);
const [
showSelectVideoError,
setShowSelectVideoError,
] = module.state.showSelectVideoError(false);
const [
showSizeError,
setShowSizeError,
] = module.state.showSizeError(false);
const filteredList = videos; // TODO missing filters and sort
return {
galleryError: {
show: showSelectVideoError,
set: () => setShowSelectVideoError(true),
dismiss: () => setShowSelectVideoError(false),
message: messages.selectVideoError,
},
// TODO We need to update this message when implementing the video upload screen
inputError: {
show: showSizeError,
set: () => setShowSizeError(true),
dismiss: () => setShowSelectVideoError(false),
message: messages.fileSizeError,
},
galleryProps: {
galleryIsEmpty: Object.keys(filteredList).length === 0,
searchIsEmpty: filteredList.length === 0,
displayList: filteredList,
highlighted,
onHighlightChange: (e) => setHighlighted(e.target.value),
emptyGalleryLabel: messages.emptyGalleryLabel,
showIdsOnCards: true,
height: '100%',
},
selectBtnProps: {
onclick: () => {
// TODO Update this when implementing the selection feature
},
},
};
};
export const fileInputHooks = () => {
// TODO [Update video] Implement this
const ref = React.useRef();
const click = () => ref.current.click();
return {
click,
addFile: () => {},
ref,
};
};
export const filterAssets = ({ assets }) => {
let videos = [];
const assetsList = Object.values(assets);
if (assetsList.length > 0) {
videos = assetsList.filter(asset => asset?.contentType?.startsWith('video/'));
}
return videos;
};
export const videoHooks = ({ videos }) => {
const searchSortProps = module.searchAndSortHooks();
const videoList = module.videoListHooks({ videos });
const {
galleryError,
galleryProps,
inputError,
selectBtnProps,
} = videoList;
const fileInput = module.fileInputHooks();
return {
galleryError,
inputError,
fileInput,
galleryProps,
searchSortProps,
selectBtnProps,
};
};
export default {
videoHooks,
filterAssets,
};

View File

@@ -0,0 +1,64 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { selectors } from '../../data/redux';
import hooks from './hooks';
import SelectionModal from '../../sharedComponents/SelectionModal';
import { acceptedImgKeys } from './utils';
import messages from './messages';
export const VideoGallery = ({
// redux
assets,
}) => {
const videos = hooks.filterAssets({ assets });
const {
galleryError,
inputError,
fileInput,
galleryProps,
searchSortProps,
selectBtnProps,
} = hooks.videoHooks({ videos });
const modalMessages = {
confirmMsg: messages.selectVideoButtonlabel,
titleMsg: messages.titleLabel,
uploadButtonMsg: messages.uploadButtonLabel,
fetchError: messages.fetchVideosError,
uploadError: messages.uploadVideoError,
};
return (
<div>
<SelectionModal
{...{
isOpen: true,
close: () => { /* TODO */ },
size: 'fullscreen',
isFullscreenScroll: false,
galleryError,
inputError,
fileInput,
galleryProps,
searchSortProps,
selectBtnProps,
acceptedFiles: acceptedImgKeys,
modalMessages,
}}
/>
</div>
);
};
VideoGallery.propTypes = {
assets: PropTypes.shape({}).isRequired,
};
export const mapStateToProps = (state) => ({
assets: selectors.app.assets(state),
});
export const mapDispatchToProps = {};
export default connect(mapStateToProps, mapDispatchToProps)(VideoGallery);

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { shallow } from 'enzyme';
import SelectionModal from '../../sharedComponents/SelectionModal';
import hooks from './hooks';
import * as module from '.';
jest.mock('../../sharedComponents/SelectionModal', () => 'SelectionModal');
jest.mock('./hooks', () => ({
filterAssets: jest.fn(() => []),
videoHooks: jest.fn(() => ({
galleryError: {
show: 'ShoWERror gAlLery',
set: jest.fn(),
dismiss: jest.fn(),
message: {
id: 'Gallery error id',
defaultMessage: 'Gallery error',
description: 'Gallery error',
},
},
inputError: {
show: 'ShoWERror inPUT',
set: jest.fn(),
dismiss: jest.fn(),
message: {
id: 'Input error id',
defaultMessage: 'Input error',
description: 'Input error',
},
},
fileInput: {
addFile: 'videoHooks.fileInput.addFile',
click: 'videoHooks.fileInput.click',
ref: 'videoHooks.fileInput.ref',
},
galleryProps: { gallery: 'props' },
searchSortProps: { search: 'sortProps' },
selectBtnProps: { select: 'btnProps' },
})),
}));
jest.mock('../../data/redux', () => ({
selectors: {
requests: {
isPending: (state, { requestKey }) => ({ isPending: { state, requestKey } }),
},
},
}));
describe('VideoGallery', () => {
describe('component', () => {
const props = {
assets: { sOmEaSsET: { staTICUrl: '/assets/sOmEaSsET' } },
};
let el;
const videoHooks = hooks.videoHooks();
beforeEach(() => {
el = shallow(<module.VideoGallery {...props} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('provides confirm action, forwarding selectBtnProps from imgHooks', () => {
expect(el.find(SelectionModal).props().selectBtnProps).toEqual(
expect.objectContaining({ ...hooks.videoHooks().selectBtnProps }),
);
});
it('provides file upload button linked to fileInput.click', () => {
expect(el.find(SelectionModal).props().fileInput.click).toEqual(
videoHooks.fileInput.click,
);
});
it('provides a SearchSort component with searchSortProps from imgHooks', () => {
expect(el.find(SelectionModal).props().searchSortProps).toEqual(videoHooks.searchSortProps);
});
it('provides a Gallery component with galleryProps from imgHooks', () => {
expect(el.find(SelectionModal).props().galleryProps).toEqual(videoHooks.galleryProps);
});
it('provides a FileInput component with fileInput props from imgHooks', () => {
expect(el.find(SelectionModal).props().fileInput).toMatchObject(videoHooks.fileInput);
});
});
});

View File

@@ -0,0 +1,117 @@
export const messages = {
// Gallery
emptyGalleryLabel: {
id: 'authoring.selectvideomodal.emptyGalleryLabel',
defaultMessage:
'No videos found in your gallery. Please upload a video using the button below.',
description: 'Label for when video gallery is empty.',
},
selectVideoButtonlabel: {
id: 'authoring.selectvideomodal.selectvideo.label',
defaultMessage: 'Select video',
description: 'Label for Select video button',
},
titleLabel: {
id: 'authoring.selectvideomodal.title.label',
defaultMessage: 'Add video to your course',
description: 'Title for the select video modal',
},
uploadButtonLabel: {
id: 'authoring.selectvideomodal.upload.label',
defaultMessage: 'Upload or embed a new video',
description: 'Label for upload button',
},
// Sort Dropdown
sortByDateNewest: {
id: 'authoring.selectvideomodal.sort.datenewest.label',
defaultMessage: 'By date added (newest)',
description: 'Dropdown label for sorting by date (newest)',
},
sortByDateOldest: {
id: 'authoring.selectvideomodal.sort.dateoldest.label',
defaultMessage: 'By date added (oldest)',
description: 'Dropdown label for sorting by date (oldest)',
},
sortByNameAscending: {
id: 'authoring.selectvideomodal.sort.nameascending.label',
defaultMessage: 'By name (ascending)',
description: 'Dropdown label for sorting by name (ascending)',
},
sortByNameDescending: {
id: 'authoring.selectvideomodal.sort.namedescending.label',
defaultMessage: 'By name (descending)',
description: 'Dropdown label for sorting by name (descending)',
},
sortByDurationShortest: {
id: 'authoring.selectvideomodal.sort.durationshortest.label',
defaultMessage: 'By duration (shortest)',
description: 'Dropdown label for sorting by duration (shortest)',
},
sortByDurationLongest: {
id: 'authoring.selectvideomodal.sort.durationlongest.label',
defaultMessage: 'By duration (longest)',
description: 'Dropdown label for sorting by duration (longest)',
},
// Filter Dropdown
filterByVideoStatusNone: {
id: 'authoring.selectvideomodal.filter.videostatusnone.label',
defaultMessage: 'Video status',
description: 'Dropdown label for filter by video status (none)',
},
filterByVideoStatusUploading: {
id: 'authoring.selectvideomodal.filter.videostatusuploading.label',
defaultMessage: 'Uploading',
description: 'Dropdown label for filter by video status (uploading)',
},
filterByVideoStatusProcessing: {
id: 'authoring.selectvideomodal.filter.videostatusprocessing.label',
defaultMessage: 'Processing',
description: 'Dropdown label for filter by video status (processing)',
},
filterByVideoStatusReady: {
id: 'authoring.selectvideomodal.filter.videostatusready.label',
defaultMessage: 'Ready',
description: 'Dropdown label for filter by video status (ready)',
},
filterByVideoStatusFailed: {
id: 'authoring.selectvideomodal.filter.videostatusfailed.label',
defaultMessage: 'Failed',
description: 'Dropdown label for filter by video status (failed)',
},
// Hide switch
hideSelectedCourseVideosSwitchLabel: {
id: 'authoring.selectvideomodal.switch.hideselectedcoursevideos.label',
defaultMessage: 'Hide selected course videos',
description: 'Switch label for hide selected course videos',
},
// Errors
selectVideoError: {
id: 'authoring.selectvideomodal.error.selectVideoError',
defaultMessage: 'Select a video to continue.',
description:
'Message presented to user when clicking Next without selecting a video',
},
fileSizeError: {
id: 'authoring.selectvideomodal.error.fileSizeError',
defaultMessage:
'Video must be 10 MB or less. Please resize image and try again.',
description:
'Message presented to user when file size of video is larger than 10 MB',
},
uploadVideoError: {
id: 'authoring.selectvideomodal.error.uploadVideoError',
defaultMessage: 'Failed to upload video. Please try again.',
description: 'Message presented to user when video fails to upload',
},
fetchVideosError: {
id: 'authoring.selectvideomodal.error.fetchVideosError',
defaultMessage: 'Failed to obtain course videos. Please try again.',
description: 'Message presented to user when videos are not found',
},
};
export default messages;

View File

@@ -0,0 +1,42 @@
import { StrictDict, keyStore } from '../../utils';
import messages from './messages';
const messageKeys = keyStore(messages);
export const sortKeys = StrictDict({
dateNewest: 'dateNewest',
dateOldest: 'dateOldest',
nameAscending: 'nameAscending',
nameDescending: 'nameDescending',
durationShortest: 'durationShortest',
durationLongest: 'durationLongest',
});
export const sortMessages = StrictDict({
dateNewest: messages[messageKeys.sortByDateNewest],
dateOldest: messages[messageKeys.sortByDateOldest],
nameAscending: messages[messageKeys.sortByNameAscending],
nameDescending: messages[messageKeys.sortByNameDescending],
durationShortest: messages[messageKeys.sortByDurationShortest],
durationLongest: messages[messageKeys.sortByDurationLongest],
});
export const filterKeys = StrictDict({
videoStatus: 'videoStatus',
uploading: 'uploading',
processing: 'processing',
ready: 'ready',
failed: 'failed',
});
export const filterMessages = StrictDict({
videoStatus: messages[messageKeys.filterByVideoStatusNone],
uploading: messages[messageKeys.filterByVideoStatusUploading],
processing: messages[messageKeys.filterByVideoStatusProcessing],
ready: messages[messageKeys.filterByVideoStatusReady],
failed: messages[messageKeys.filterByVideoStatusFailed],
});
export const acceptedImgKeys = StrictDict({
mp4: '.mp4',
});

View File

@@ -17,6 +17,7 @@ export const BaseModal = ({
confirmAction,
footerAction,
size,
isFullscreenScroll,
}) => (
<ModalDialog
isOpen={isOpen}
@@ -25,7 +26,7 @@ export const BaseModal = ({
variant="default"
hasCloseButton
isFullscreenOnMobile
isFullscreenScroll
isFullscreenScroll={isFullscreenScroll}
>
<ModalDialog.Header>
<ModalDialog.Title>
@@ -51,6 +52,7 @@ export const BaseModal = ({
BaseModal.defaultProps = {
footerAction: null,
size: 'lg',
isFullscreenScroll: true,
};
BaseModal.propTypes = {
@@ -61,6 +63,7 @@ BaseModal.propTypes = {
confirmAction: PropTypes.node.isRequired,
footerAction: PropTypes.node,
size: PropTypes.string,
isFullscreenScroll: PropTypes.bool,
};
export default BaseModal;

View File

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

View File

@@ -1,71 +0,0 @@
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

@@ -1,189 +1,94 @@
// 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>
<SelectionModal
acceptedFiles={
Object {
"gif": ".gif",
"ico": ".ico",
"jpeg": ".jpeg",
"jpg": ".jpg",
"png": ".png",
"tif": ".tif",
"tiff": ".tiff",
}
}
footerAction={
<Button
onClick="imgHooks.fileInput.click"
variant="link"
>
<FormattedMessage
defaultMessage="Upload a new image (10 MB max)"
description="Label for upload button"
id="authoring.texteditor.selectimagemodal.upload.label"
/>
</Button>
close={[MockFunction props.close]}
fileInput={
Object {
"addFile": "imgHooks.fileInput.addFile",
"click": "imgHooks.fileInput.click",
"ref": "imgHooks.fileInput.ref",
}
}
galleryError={
Object {
"dismiss": [MockFunction],
"message": Object {
"defaultMessage": "Gallery error",
"description": "Gallery error",
"id": "Gallery error id",
},
"set": [MockFunction],
"show": "ShoWERror gAlLery",
}
}
galleryProps={
Object {
"gallery": "props",
}
}
inputError={
Object {
"dismiss": [MockFunction],
"message": Object {
"defaultMessage": "Input error",
"description": "Input error",
"id": "Input error id",
},
"set": [MockFunction],
"show": "ShoWERror inPUT",
}
}
isOpen={true}
title="Add an image"
>
<FetchErrorAlert
message={
Object {
modalMessages={
Object {
"confirmMsg": Object {
"defaultMessage": "Next",
"description": "Label for Next button",
"id": "authoring.texteditor.selectimagemodal.next.label",
},
"fetchError": Object {
"defaultMessage": "Failed to obtain course images. Please try again.",
"description": "Message presented to user when images are not found",
"id": "authoring.texteditor.selectimagemodal.error.fetchImagesError",
}
}
/>
<UploadErrorAlert
message={
Object {
},
"titleMsg": Object {
"defaultMessage": "Add an image",
"description": "Title for the select image modal",
"id": "authoring.texteditor.selectimagemodal.title.label",
},
"uploadButtonMsg": Object {
"defaultMessage": "Upload a new image (10 MB max)",
"description": "Label for upload button",
"id": "authoring.texteditor.selectimagemodal.upload.label",
},
"uploadError": Object {
"defaultMessage": "Failed to upload image. Please try again.",
"description": "Message presented to user when image fails to upload",
"id": "authoring.texteditor.selectimagemodal.error.uploadImageError",
}
},
}
/>
<ErrorAlert
dismissError={[MockFunction]}
hideHeading={true}
isError="ShoWERror inPUT"
>
<FormattedMessage
defaultMessage="Images must be 10 MB or less. Please resize image and try again."
description=" Message presented to user when file size of image is larger than 10 MB"
id="authoring.texteditor.selectimagemodal.error.fileSizeError"
/>
</ErrorAlert>
<ErrorAlert
dismissError={[MockFunction]}
hideHeading={true}
isError="ShoWERror gAlLery"
>
<FormattedMessage
defaultMessage="Select an image to continue."
description="Message presented to user when clicking Next without selecting an image"
id="authoring.texteditor.selectimagemodal.error.selectImageError"
/>
</ErrorAlert>
<Stack
gap={3}
>
<SearchSort
search="sortProps"
/>
<Gallery
gallery="props"
/>
<FileInput
acceptedFiles=".gif,.jpg,.jpeg,.png,.tif,.tiff,.ico"
fileInput={
Object {
"addFile": "imgHooks.fileInput.addFile",
"click": "imgHooks.fileInput.click",
"ref": "imgHooks.fileInput.ref",
}
}
/>
</Stack>
</BaseModal>
`;
exports[`SelectImageModal component snapshot: uploaded image not loaded, show spinner 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 (10 MB max)"
description="Label for upload button"
id="authoring.texteditor.selectimagemodal.upload.label"
/>
</Button>
searchSortProps={
Object {
"search": "sortProps",
}
}
isOpen={true}
title="Add an image"
>
<FetchErrorAlert
message={
Object {
"defaultMessage": "Failed to obtain course images. Please try again.",
"description": "Message presented to user when images are not found",
"id": "authoring.texteditor.selectimagemodal.error.fetchImagesError",
}
selectBtnProps={
Object {
"select": "btnProps",
}
/>
<UploadErrorAlert
message={
Object {
"defaultMessage": "Failed to upload image. Please try again.",
"description": "Message presented to user when image fails to upload",
"id": "authoring.texteditor.selectimagemodal.error.uploadImageError",
}
}
/>
<ErrorAlert
dismissError={[MockFunction]}
hideHeading={true}
isError="ShoWERror inPUT"
>
<FormattedMessage
defaultMessage="Images must be 10 MB or less. Please resize image and try again."
description=" Message presented to user when file size of image is larger than 10 MB"
id="authoring.texteditor.selectimagemodal.error.fileSizeError"
/>
</ErrorAlert>
<ErrorAlert
dismissError={[MockFunction]}
hideHeading={true}
isError="ShoWERror gAlLery"
>
<FormattedMessage
defaultMessage="Select an image to continue."
description="Message presented to user when clicking Next without selecting an image"
id="authoring.texteditor.selectimagemodal.error.selectImageError"
/>
</ErrorAlert>
<Stack
gap={3}
>
<SearchSort
search="sortProps"
/>
<Spinner
animation="border"
className="mie-3"
screenReaderText="loading..."
/>
<FileInput
acceptedFiles=".gif,.jpg,.jpeg,.png,.tif,.tiff,.ico"
fileInput={
Object {
"addFile": "imgHooks.fileInput.addFile",
"click": "imgHooks.fileInput.click",
"ref": "imgHooks.fileInput.ref",
}
}
/>
</Stack>
</BaseModal>
}
/>
`;

View File

@@ -3,7 +3,8 @@ import { useDispatch } from 'react-redux';
import { thunkActions } from '../../../data/redux';
import * as module from './hooks';
import { sortFunctions, sortKeys } from './utils';
import { sortFunctions, sortKeys, sortMessages } from './utils';
import messages from './messages';
export const state = {
highlighted: (val) => React.useState(val),
@@ -22,6 +23,8 @@ export const searchAndSortHooks = () => {
clearSearchString: () => setSearchString(''),
sortBy,
onSortClick: (key) => () => setSortBy(key),
sortKeys,
sortMessages,
};
};
@@ -49,11 +52,13 @@ export const imgListHooks = ({ searchSortProps, setSelection, images }) => {
show: showSelectImageError,
set: () => setShowSelectImageError(true),
dismiss: () => setShowSelectImageError(false),
message: messages.selectImageError,
},
inputError: {
show: showSizeError,
set: () => setShowSizeError(true),
dismiss: () => setShowSizeError(false),
message: messages.fileSizeError,
},
images,
galleryProps: {
@@ -62,6 +67,7 @@ export const imgListHooks = ({ searchSortProps, setSelection, images }) => {
displayList: list,
highlighted,
onHighlightChange: (e) => setHighlighted(e.target.value),
emptyGalleryLabel: messages.emptyGalleryLabel,
},
// highlight by id
selectBtnProps: {

View File

@@ -1,27 +1,8 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { Button, Stack, Spinner } from '@edx/paragon';
import { Add } from '@edx/paragon/icons';
import {
FormattedMessage,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { selectors } from '../../../data/redux';
import { RequestKeys } from '../../../data/constants/requests';
import { acceptedImgKeys } from './utils';
import hooks from './hooks';
import { acceptedImgKeys } from './utils';
import SelectionModal from '../../SelectionModal';
import messages from './messages';
import BaseModal from '../../BaseModal';
import SearchSort from './SearchSort';
import Gallery from './Gallery';
import FileInput from '../../FileInput';
import FetchErrorAlert from '../../ErrorAlerts/FetchErrorAlert';
import UploadErrorAlert from '../../ErrorAlerts/UploadErrorAlert';
import ErrorAlert from '../../ErrorAlerts/ErrorAlert';
export const SelectImageModal = ({
isOpen,
@@ -29,10 +10,6 @@ export const SelectImageModal = ({
setSelection,
clearSelection,
images,
// injected
intl,
// redux
inputIsLoading,
}) => {
const {
galleryError,
@@ -43,53 +20,29 @@ export const SelectImageModal = ({
selectBtnProps,
} = hooks.imgHooks({ setSelection, clearSelection, images });
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)}
>
{/* Error Alerts */}
<FetchErrorAlert message={messages.fetchImagesError} />
<UploadErrorAlert message={messages.uploadImageError} />
<ErrorAlert
dismissError={inputError.dismiss}
hideHeading
isError={inputError.show}
>
<FormattedMessage {...messages.fileSizeError} />
</ErrorAlert>
const modalMessages = {
confirmMsg: messages.nextButtonLabel,
titleMsg: messages.titleLabel,
uploadButtonMsg: messages.uploadButtonLabel,
fetchError: messages.fetchImagesError,
uploadError: messages.uploadImageError,
};
{/* User Feedback Alerts */}
<ErrorAlert
dismissError={galleryError.dismiss}
hideHeading
isError={galleryError.show}
>
<FormattedMessage {...messages.selectImageError} />
</ErrorAlert>
<Stack gap={3}>
<SearchSort {...searchSortProps} />
{!inputIsLoading ? <Gallery {...galleryProps} /> : (
<Spinner
animation="border"
className="mie-3"
screenReaderText={intl.formatMessage(messages.loading)}
/>
)}
<FileInput fileInput={fileInput} acceptedFiles={Object.values(acceptedImgKeys).join()} />
</Stack>
</BaseModal>
return (
<SelectionModal
{...{
isOpen,
close,
galleryError,
inputError,
fileInput,
galleryProps,
searchSortProps,
selectBtnProps,
acceptedFiles: acceptedImgKeys,
modalMessages,
}}
/>
);
};
@@ -99,16 +52,6 @@ SelectImageModal.propTypes = {
setSelection: PropTypes.func.isRequired,
clearSelection: PropTypes.func.isRequired,
images: PropTypes.arrayOf(PropTypes.string).isRequired,
// injected
intl: intlShape.isRequired,
// redux
inputIsLoading: PropTypes.bool.isRequired,
};
export const mapStateToProps = (state) => ({
inputIsLoading: selectors.requests.isPending(state, { requestKey: RequestKeys.uploadAsset }),
});
export const mapDispatchToProps = {};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(SelectImageModal));
export default SelectImageModal;

View File

@@ -2,22 +2,11 @@ import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from '../../../../testUtils';
import { RequestKeys } from '../../../data/constants/requests';
import { selectors } from '../../../data/redux';
import BaseModal from '../../BaseModal';
import FileInput from '../../FileInput';
import Gallery from './Gallery';
import SearchSort from './SearchSort';
import SelectionModal from '../../SelectionModal';
import hooks from './hooks';
import { SelectImageModal, mapStateToProps, mapDispatchToProps } from '.';
import { SelectImageModal } from '.';
jest.mock('../../BaseModal', () => 'BaseModal');
jest.mock('../../FileInput', () => 'FileInput');
jest.mock('./Gallery', () => 'Gallery');
jest.mock('./SearchSort', () => 'SearchSort');
jest.mock('../../ErrorAlerts/FetchErrorAlert', () => 'FetchErrorAlert');
jest.mock('../../ErrorAlerts/UploadErrorAlert', () => 'UploadErrorAlert');
jest.mock('../..//ErrorAlerts/ErrorAlert', () => 'ErrorAlert');
jest.mock('../../SelectionModal', () => 'SelectionModal');
jest.mock('./hooks', () => ({
imgHooks: jest.fn(() => ({
@@ -25,11 +14,21 @@ jest.mock('./hooks', () => ({
show: 'ShoWERror gAlLery',
set: jest.fn(),
dismiss: jest.fn(),
message: {
id: 'Gallery error id',
defaultMessage: 'Gallery error',
description: 'Gallery error',
},
},
inputError: {
show: 'ShoWERror inPUT',
set: jest.fn(),
dismiss: jest.fn(),
message: {
id: 'Input error id',
defaultMessage: 'Input error',
description: 'Input error',
},
},
fileInput: {
addFile: 'imgHooks.fileInput.addFile',
@@ -58,7 +57,6 @@ describe('SelectImageModal', () => {
setSelection: jest.fn().mockName('props.setSelection'),
clearSelection: jest.fn().mockName('props.clearSelection'),
intl: { formatMessage },
inputIsLoading: false,
};
let el;
const imgHooks = hooks.imgHooks();
@@ -68,42 +66,24 @@ describe('SelectImageModal', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
test('snapshot: uploaded image not loaded, show spinner', () => {
props.inputIsLoading = true;
expect(shallow(<SelectImageModal {...props} />)).toMatchSnapshot();
props.inputIsLoading = false;
});
it('provides confirm action, forwarding selectBtnProps from imgHooks', () => {
expect(el.find(BaseModal).props().confirmAction.props).toEqual(
expect.objectContaining({ ...hooks.imgHooks().selectBtnProps, variant: 'primary' }),
expect(el.find(SelectionModal).props().selectBtnProps).toEqual(
expect.objectContaining({ ...hooks.imgHooks().selectBtnProps }),
);
});
it('provides file upload button linked to fileInput.click', () => {
expect(el.find(BaseModal).props().footerAction.props.onClick).toEqual(
expect(el.find(SelectionModal).props().fileInput.click).toEqual(
imgHooks.fileInput.click,
);
});
it('provides a SearchSort component with searchSortProps from imgHooks', () => {
expect(el.find(SearchSort).props()).toEqual(imgHooks.searchSortProps);
expect(el.find(SelectionModal).props().searchSortProps).toEqual(imgHooks.searchSortProps);
});
it('provides a Gallery component with galleryProps from imgHooks', () => {
expect(el.find(Gallery).props()).toEqual(imgHooks.galleryProps);
expect(el.find(SelectionModal).props().galleryProps).toEqual(imgHooks.galleryProps);
});
it('provides a FileInput component with fileInput props from imgHooks', () => {
expect(el.find(FileInput).props()).toMatchObject({ fileInput: imgHooks.fileInput });
});
});
describe('mapStateToProps', () => {
const testState = { some: 'testState' };
test('loads inputIsLoading from requests.isPending selector for uploadAsset request', () => {
expect(mapStateToProps(testState).inputIsLoading).toEqual(
selectors.requests.isPending(testState, { requestKey: RequestKeys.uploadAsset }),
);
});
});
describe('mapDispatchToProps', () => {
test('is empty', () => {
expect(mapDispatchToProps).toEqual({});
expect(el.find(SelectionModal).props().fileInput).toMatchObject(imgHooks.fileInput);
});
});
});

View File

@@ -17,11 +17,6 @@ const messages = defineMessages({
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',
},
// Sort Dropdown
sortByDateNewest: {
@@ -46,27 +41,12 @@ const messages = defineMessages({
},
// Gallery
addedDate: {
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',
},
emptyGalleryLabel: {
id: 'authoring.texteditor.selectimagemodal.emptyGalleryLabel',
defaultMessage:
'No images found in your gallery. Please upload an image using the button below.',
description: 'Label for when image gallery is empty.',
},
emptySearchLabel: {
id: 'authoring.texteditor.selectimagemodal.emptySearchLabel',
defaultMessage: 'No search results.',
description: 'Label for when search returns nothing.',
},
// Errors
uploadImageError: {

View File

@@ -6,10 +6,15 @@ import {
Scrollable, SelectableBox, Spinner,
} from '@edx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
FormattedMessage,
injectIntl,
intlShape,
MessageDescriptor,
} from '@edx/frontend-platform/i18n';
import { selectors } from '../../../data/redux';
import { RequestKeys } from '../../../data/constants/requests';
import { selectors } from '../../data/redux';
import { RequestKeys } from '../../data/constants/requests';
import messages from './messages';
import GalleryCard from './GalleryCard';
@@ -20,6 +25,9 @@ export const Gallery = ({
displayList,
highlighted,
onHighlightChange,
emptyGalleryLabel,
showIdsOnCards,
height,
// injected
intl,
// redux
@@ -36,20 +44,20 @@ export const Gallery = ({
}
if (galleryIsEmpty) {
return (
<div className="gallery p-4 bg-gray-100" style={{ height: '375px' }}>
<FormattedMessage {...messages.emptyGalleryLabel} />
<div className="gallery p-4 bg-gray-100" style={{ height }}>
<FormattedMessage {...emptyGalleryLabel} />
</div>
);
}
if (searchIsEmpty) {
return (
<div className="gallery p-4 bg-gray-100" style={{ height: '375px' }}>
<div className="gallery p-4 bg-gray-100" style={{ height }}>
<FormattedMessage {...messages.emptySearchLabel} />
</div>
);
}
return (
<Scrollable className="gallery bg-gray-100" style={{ height: '375px' }}>
<Scrollable className="gallery bg-gray-100" style={{ height }}>
<div className="p-4">
<SelectableBox.Set
columns={1}
@@ -58,7 +66,7 @@ export const Gallery = ({
type="radio"
value={highlighted}
>
{displayList.map(img => <GalleryCard key={img.id} img={img} />)}
{ displayList.map(asset => <GalleryCard key={asset.id} asset={asset} showId={showIdsOnCards} />) }
</SelectableBox.Set>
</div>
</Scrollable>
@@ -67,6 +75,9 @@ export const Gallery = ({
Gallery.defaultProps = {
highlighted: '',
showIdsOnCards: false,
height: '375px',
emptyGalleryLabel: null,
};
Gallery.propTypes = {
galleryIsEmpty: PropTypes.bool.isRequired,
@@ -74,6 +85,9 @@ Gallery.propTypes = {
displayList: PropTypes.arrayOf(PropTypes.object).isRequired,
highlighted: PropTypes.string,
onHighlightChange: PropTypes.func.isRequired,
emptyGalleryLabel: MessageDescriptor,
showIdsOnCards: PropTypes.bool,
height: PropTypes.string,
// injected
intl: intlShape.isRequired,
// redux

View File

@@ -1,12 +1,12 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from '../../../../testUtils';
import { RequestKeys } from '../../../data/constants/requests';
import { selectors } from '../../../data/redux';
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', () => ({
jest.mock('../../data/redux', () => ({
selectors: {
requests: {
isFinished: (state, { requestKey }) => ({ isFinished: { state, requestKey } }),

View File

@@ -1,28 +1,42 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Image, SelectableBox } from '@edx/paragon';
import {
Button,
Icon,
Image,
SelectableBox,
} from '@edx/paragon';
import { FormattedMessage, FormattedDate, FormattedTime } from '@edx/frontend-platform/i18n';
import { Link } from '@edx/paragon/icons';
import messages from './messages';
export const GalleryCard = ({
img,
asset,
showId,
}) => (
<SelectableBox className="card bg-white" key={img.externalUrl} type="radio" value={img.id}>
<SelectableBox className="card bg-white" key={asset.externalUrl} type="radio" value={asset.id}>
<div className="card-div d-flex flex-row flex-nowrap">
<Image
style={{ width: '100px', height: '100px' }}
src={img.externalUrl}
src={asset.externalUrl}
/>
<div className="img-text p-3">
<h3>{img.displayName}</h3>
<h3>{asset.displayName}</h3>
{ showId && (
<p>
<Button variant="link" size="inline" onClick={() => { /* TODO */ }}>
<Icon src={Link} /> {asset.id}
</Button>
</p>
)}
<p>
<FormattedMessage
{...messages.addedDate}
values={{
date: <FormattedDate value={img.dateAdded} />,
time: <FormattedTime value={img.dateAdded} />,
date: <FormattedDate value={asset.dateAdded} />,
time: <FormattedTime value={asset.dateAdded} />,
}}
/>
</p>
@@ -31,8 +45,12 @@ export const GalleryCard = ({
</SelectableBox>
);
GalleryCard.defaultProps = {
showId: false,
};
GalleryCard.propTypes = {
img: PropTypes.shape({
asset: PropTypes.shape({
contentType: PropTypes.string,
displayName: PropTypes.string,
externalUrl: PropTypes.string,
@@ -43,6 +61,7 @@ GalleryCard.propTypes = {
thumbnail: PropTypes.string,
url: PropTypes.string,
}).isRequired,
showId: PropTypes.bool,
};
export default GalleryCard;

View File

@@ -12,7 +12,7 @@ describe('GalleryCard component', () => {
};
let el;
beforeEach(() => {
el = shallow(<GalleryCard img={img} />);
el = shallow(<GalleryCard asset={img} />);
});
test(`snapshot: dateAdded=${img.dateAdded}`, () => {
expect(el).toMatchSnapshot();

View File

@@ -0,0 +1,135 @@
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,
MessageDescriptor,
intlShape,
} from '@edx/frontend-platform/i18n';
import messages from './messages';
export const SearchSort = ({
searchString,
onSearchChange,
clearSearchString,
sortBy,
onSortClick,
sortKeys,
sortMessages,
filterBy,
onFilterClick,
filterKeys,
filterMessages,
showSwitch,
switchMessage,
onSwitchClick,
// 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>
{ !showSwitch && <ActionRow.Spacer /> }
<Dropdown>
<Dropdown.Toggle id="gallery-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>
{ filterKeys && filterMessages && (
<Dropdown>
<Dropdown.Toggle id="gallery-filter-button" variant="tertiary">
<FormattedMessage {...filterMessages[filterBy]} />
</Dropdown.Toggle>
<Dropdown.Menu>
{Object.keys(filterKeys).map(key => (
<Dropdown.Item key={key} onClick={onFilterClick(key)}>
<FormattedMessage {...filterMessages[key]} />
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
)}
{ showSwitch && (
<>
<ActionRow.Spacer />
<Form.SwitchSet
name="switch"
onChange={onSwitchClick}
isInline
>
<Form.Switch value="switch-value" floatLabelLeft>
<FormattedMessage {...switchMessage} />
</Form.Switch>
</Form.SwitchSet>
</>
)}
</ActionRow>
);
SearchSort.defaultProps = {
filterBy: '',
onFilterClick: null,
filterKeys: null,
filterMessages: null,
showSwitch: false,
switchMessage: null,
onSwitchClick: null,
};
SearchSort.propTypes = {
searchString: PropTypes.string.isRequired,
onSearchChange: PropTypes.func.isRequired,
clearSearchString: PropTypes.func.isRequired,
sortBy: PropTypes.string.isRequired,
onSortClick: PropTypes.func.isRequired,
sortKeys: PropTypes.shape({}).isRequired,
sortMessages: PropTypes.shape({}).isRequired,
filterBy: PropTypes.string,
onFilterClick: PropTypes.func,
filterKeys: PropTypes.shape({}),
filterMessages: PropTypes.shape({}),
showSwitch: PropTypes.bool,
switchMessage: MessageDescriptor,
onSwitchClick: PropTypes.func,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(SearchSort);

View File

@@ -3,9 +3,9 @@ import { shallow } from 'enzyme';
import { Dropdown } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { formatMessage } from '../../../../testUtils';
import { formatMessage } from '../../../testUtils';
import { sortKeys, sortMessages } from './utils';
import { sortKeys, sortMessages } from '../ImageUploadModal/SelectImageModal/utils';
import { SearchSort } from './SearchSort';
describe('SearchSort component', () => {
@@ -14,6 +14,8 @@ describe('SearchSort component', () => {
onSearchChange: jest.fn().mockName('props.onSearchChange'),
clearSearchString: jest.fn().mockName('props.clearSearchString'),
sortBy: sortKeys.dateOldest,
sortKeys,
sortMessages,
onSortClick: jest.fn().mockName('props.onSortClick'),
intl: { formatMessage },
};

View File

@@ -9,11 +9,7 @@ exports[`TextEditor Image Gallery component component snapshot: loaded but no im
}
}
>
<FormattedMessage
defaultMessage="No images found in your gallery. Please upload an image using the button below."
description="Label for when image gallery is empty."
id="authoring.texteditor.selectimagemodal.emptyGalleryLabel"
/>
<FormattedMessage />
</div>
`;
@@ -29,7 +25,7 @@ exports[`TextEditor Image Gallery component component snapshot: loaded but searc
<FormattedMessage
defaultMessage="No search results."
description="Label for when search returns nothing."
id="authoring.texteditor.selectimagemodal.emptySearchLabel"
id="authoring.selectionmodal.emptySearchLabel"
/>
</div>
`;
@@ -54,28 +50,31 @@ exports[`TextEditor Image Gallery component component snapshot: loaded, show gal
value="props.highlighted"
>
<GalleryCard
img={
asset={
Object {
"id": 1,
}
}
key="1"
showId={false}
/>
<GalleryCard
img={
asset={
Object {
"id": 2,
}
}
key="2"
showId={false}
/>
<GalleryCard
img={
asset={
Object {
"id": 3,
}
}
key="3"
showId={false}
/>
</SelectableBox.Set>
</div>

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.label"
id="authoring.selectionmodal.addedDate.label"
values={
Object {
"date": <FormattedDate

View File

@@ -29,7 +29,7 @@ exports[`SearchSort component snapshots with search string (close button) 1`] =
<ActionRow.Spacer />
<Dropdown>
<Dropdown.Toggle
id="img-sort-button"
id="gallery-sort-button"
variant="tertiary"
>
<FormattedMessage
@@ -100,7 +100,7 @@ exports[`SearchSort component snapshots without search string (search icon) 1`]
<ActionRow.Spacer />
<Dropdown>
<Dropdown.Toggle
id="img-sort-button"
id="gallery-sort-button"
variant="tertiary"
>
<FormattedMessage

View File

@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Selection Modal snapshots rendering correctly with expected Input 1`] = `
<ContextConsumer>
<Component />
</ContextConsumer>
`;
exports[`Selection Modal snapshots rendering with props to null 1`] = `
<ContextConsumer>
<Component />
</ContextConsumer>
`;

View File

@@ -0,0 +1,131 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, Stack } from '@edx/paragon';
import { Add } from '@edx/paragon/icons';
import {
FormattedMessage,
injectIntl,
MessageDescriptor,
intlShape,
} from '@edx/frontend-platform/i18n';
import BaseModal from '../BaseModal';
import SearchSort from './SearchSort';
import Gallery from './Gallery';
import FileInput from '../FileInput';
import ErrorAlert from '../ErrorAlerts/ErrorAlert';
import FetchErrorAlert from '../ErrorAlerts/FetchErrorAlert';
import UploadErrorAlert from '../ErrorAlerts/UploadErrorAlert';
export const SelectionModal = ({
isOpen,
close,
size,
isFullscreenScroll,
galleryError,
inputError,
fileInput,
galleryProps,
searchSortProps,
selectBtnProps,
acceptedFiles,
modalMessages,
// injected
intl,
}) => {
const {
confirmMsg,
uploadButtonMsg,
titleMsg,
fetchError,
uploadError,
} = modalMessages;
return (
<BaseModal
close={close}
confirmAction={(
<Button {...selectBtnProps} variant="primary">
<FormattedMessage {...confirmMsg} />
</Button>
)}
isOpen={isOpen}
size={size}
isFullscreenScroll={isFullscreenScroll}
footerAction={(
<Button iconBefore={Add} onClick={fileInput.click} variant="link">
<FormattedMessage {...uploadButtonMsg} />
</Button>
)}
title={intl.formatMessage(titleMsg)}
>
{/* Error Alerts */}
<FetchErrorAlert message={fetchError} />
<UploadErrorAlert message={uploadError} />
<ErrorAlert
dismissError={inputError.dismiss}
hideHeading
isError={inputError.show}
>
<FormattedMessage {...inputError.message} />
</ErrorAlert>
{/* User Feedback Alerts */}
<ErrorAlert
dismissError={galleryError.dismiss}
hideHeading
isError={galleryError.show}
>
<FormattedMessage {...galleryError.message} />
</ErrorAlert>
<Stack gap={3}>
<SearchSort {...searchSortProps} />
<Gallery {...galleryProps} />
<FileInput fileInput={fileInput} acceptedFiles={Object.values(acceptedFiles).join()} />
</Stack>
</BaseModal>
);
};
SelectionModal.defaultProps = {
size: 'lg',
isFullscreenScroll: true,
};
SelectionModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
size: PropTypes.string,
isFullscreenScroll: PropTypes.bool,
galleryError: PropTypes.shape({
dismiss: PropTypes.func.isRequired,
show: PropTypes.bool.isRequired,
set: PropTypes.func.isRequired,
message: MessageDescriptor,
}).isRequired,
inputError: PropTypes.shape({
dismiss: PropTypes.func.isRequired,
show: PropTypes.bool.isRequired,
set: PropTypes.func.isRequired,
message: MessageDescriptor,
}).isRequired,
fileInput: PropTypes.shape({
click: PropTypes.func.isRequired,
addFile: PropTypes.func.isRequired,
}).isRequired,
galleryProps: PropTypes.shape({}).isRequired,
searchSortProps: PropTypes.shape({}).isRequired,
selectBtnProps: PropTypes.shape({}).isRequired,
acceptedFiles: PropTypes.shape({}).isRequired,
modalMessages: PropTypes.shape({
confirmMsg: MessageDescriptor,
uploadButtonMsg: MessageDescriptor,
titleMsg: MessageDescriptor,
fetchError: MessageDescriptor,
uploadError: MessageDescriptor,
}).isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(SelectionModal);

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from '../../../testUtils';
import SelectionModal from '.';
const props = {
isOpen: jest.fn(),
isClose: jest.fn(),
size: 'fullscreen',
isFullscreenScroll: false,
galleryError: {
show: 'ShoWERror gAlLery',
set: jest.fn(),
dismiss: jest.fn(),
message: {
id: 'Gallery error id',
defaultMessage: 'Gallery error',
description: 'Gallery error',
},
},
inputError: {
show: 'ShoWERror inPUT',
set: jest.fn(),
dismiss: jest.fn(),
message: {
id: 'Input error id',
defaultMessage: 'Input error',
description: 'Input error',
},
},
fileInput: {
addFile: 'imgHooks.fileInput.addFile',
click: 'imgHooks.fileInput.click',
ref: 'imgHooks.fileInput.ref',
},
galleryProps: { gallery: 'props' },
searchSortProps: { search: 'sortProps' },
selectBtnProps: { select: 'btnProps' },
acceptedFiles: { png: '.png' },
modalMessages: {
confirmMsg: {
id: 'confirmMsg',
defaultMessage: 'confirmMsg',
description: 'confirmMsg',
},
uploadButtonMsg: {
id: 'uploadButtonMsg',
defaultMessage: 'uploadButtonMsg',
description: 'uploadButtonMsg',
},
titleMsg: {
id: 'titleMsg',
defaultMessage: 'titleMsg',
description: 'titleMsg',
},
fetchError: {
id: 'fetchError',
defaultMessage: 'fetchError',
description: 'fetchError',
},
uploadError: {
id: 'uploadError',
defaultMessage: 'uploadError',
description: 'uploadError',
},
},
intl: { formatMessage },
};
jest.mock('../BaseModal', () => 'BaseModal');
jest.mock('./SearchSort', () => 'SearchSort');
jest.mock('./Gallery', () => 'Gallery');
jest.mock('../FileInput', () => 'FileInput');
jest.mock('../ErrorAlerts/ErrorAlert', () => 'ErrorAlert');
jest.mock('../ErrorAlerts/FetchErrorAlert', () => 'FetchErrorAlert');
jest.mock('../ErrorAlerts/UploadErrorAlert', () => 'UploadErrorAlert');
describe('Selection Modal', () => {
describe('snapshots', () => {
test('rendering correctly with expected Input', () => {
expect(shallow(<SelectionModal {...props} />)).toMatchSnapshot();
});
test('rendering with props to null', () => {
expect(shallow(<SelectionModal />)).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,24 @@
export const messages = {
searchPlaceholder: {
id: 'authoring.selectionmodal.search.placeholder',
defaultMessage: 'Search',
description: 'Placeholder text for search bar',
},
emptySearchLabel: {
id: 'authoring.selectionmodal.emptySearchLabel',
defaultMessage: 'No search results.',
description: 'Label for when search returns nothing.',
},
loading: {
id: 'authoring.selectionmodal.spinner.readertext',
defaultMessage: 'loading...',
description: 'Gallery loading spinner screen-reader text',
},
addedDate: {
id: 'authoring.selectionmodal.addedDate.label',
defaultMessage: 'Added {date} at {time}',
description: 'File date-added string',
},
};
export default messages;

View File

@@ -24,6 +24,7 @@ exports[`SourceCodeModal renders as expected with default behavior 1`] = `
</Button>
}
footerAction={null}
isFullscreenScroll={true}
isOpen={false}
size="xl"
title="Edit Source Code"

View File

@@ -1,6 +1,7 @@
import Placeholder from './Placeholder';
import messages from './i18n/index';
import EditorPage from './editors/EditorPage';
import SelectorPage from './editors/SelectorPage';
export { messages, EditorPage };
export { messages, EditorPage, SelectorPage };
export default Placeholder;