props.img.displayName
-
+
+
+
+ }
+ value="props.searchString"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`SearchSort component snapshots with filterKeys without search string (search icon) 1`] = `
+
+
+ }
+ value=""
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`SearchSort component snapshots without filterKeys with search string (close button) 1`] = `
+
+
+
+ }
+ value="props.searchString"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`SearchSort component snapshots without filterKeys without search string (search icon) 1`] = `
+
+
+ }
+ value=""
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/editors/sharedComponents/SelectionModal/index.jsx b/src/editors/sharedComponents/SelectionModal/index.jsx
new file mode 100644
index 000000000..3e8592288
--- /dev/null
+++ b/src/editors/sharedComponents/SelectionModal/index.jsx
@@ -0,0 +1,155 @@
+import React from 'react';
+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 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,
+ isLoaded,
+ isFetchError,
+ isUploadError,
+ // injected
+ intl,
+}) => {
+ const {
+ confirmMsg,
+ uploadButtonMsg,
+ titleMsg,
+ fetchError,
+ uploadError,
+ } = modalMessages;
+
+ let background = '#FFFFFF';
+ let showGallery = true;
+ if (isLoaded && !isFetchError && !isUploadError && !inputError.show) {
+ background = '#EBEBEB';
+ } else if (isLoaded) {
+ showGallery = false;
+ }
+
+ const galleryPropsValues = {
+ isLoaded,
+ show: showGallery,
+ ...galleryProps,
+ };
+ return (
+
+
+
+ )}
+ isOpen={isOpen}
+ size={size}
+ isFullscreenScroll={isFullscreenScroll}
+ footerAction={(
+
+ )}
+ title={intl.formatMessage(titleMsg)}
+ bodyStyle={{ background, padding: '9px 24px' }}
+ headerComponent={(
+
+
+
+ )}
+ >
+ {/* Error Alerts */}
+
+
+
+
+
+
+ {/* User Feedback Alerts */}
+
+
+
+
+
+
+
+
+ );
+};
+
+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: PropTypes.shape({}).isRequired,
+ }).isRequired,
+ inputError: PropTypes.shape({
+ dismiss: PropTypes.func.isRequired,
+ show: PropTypes.bool.isRequired,
+ set: PropTypes.func.isRequired,
+ message: PropTypes.shape({}).isRequired,
+ }).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: PropTypes.shape({}).isRequired,
+ uploadButtonMsg: PropTypes.shape({}).isRequired,
+ titleMsg: PropTypes.shape({}).isRequired,
+ fetchError: PropTypes.shape({}).isRequired,
+ uploadError: PropTypes.shape({}).isRequired,
+ }).isRequired,
+ isLoaded: PropTypes.bool.isRequired,
+ isFetchError: PropTypes.bool.isRequired,
+ isUploadError: PropTypes.bool.isRequired,
+ // injected
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(SelectionModal);
diff --git a/src/editors/sharedComponents/SelectionModal/index.test.jsx b/src/editors/sharedComponents/SelectionModal/index.test.jsx
new file mode 100644
index 000000000..164204194
--- /dev/null
+++ b/src/editors/sharedComponents/SelectionModal/index.test.jsx
@@ -0,0 +1,185 @@
+import React from 'react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { render, screen } from '@testing-library/react';
+import { formatMessage } from '../../../testUtils';
+import SelectionModal from '.';
+import '@testing-library/jest-dom';
+
+const props = {
+ isOpen: jest.fn(),
+ isClose: jest.fn(),
+ size: 'fullscreen',
+ isFullscreenScroll: false,
+ galleryError: {
+ show: false,
+ set: jest.fn(),
+ dismiss: jest.fn(),
+ message: {
+ id: 'Gallery error id',
+ defaultMessage: 'Gallery error',
+ description: 'Gallery error',
+ },
+ },
+ inputError: {
+ show: false,
+ 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',
+ },
+ },
+ isLoaded: true,
+ isFetchError: false,
+ isUploadError: false,
+ intl: { formatMessage },
+};
+
+const mockGalleryFn = jest.fn();
+const mockFileInputFn = jest.fn();
+const mockFetchErrorAlertFn = jest.fn();
+const mockUploadErrorAlertFn = jest.fn();
+
+jest.mock('../BaseModal', () => 'BaseModal');
+jest.mock('./SearchSort', () => 'SearchSort');
+jest.mock('./Gallery', () => (componentProps) => {
+ mockGalleryFn(componentProps);
+ return (Gallery
);
+});
+jest.mock('../FileInput', () => (componentProps) => {
+ mockFileInputFn(componentProps);
+ return (FileInput
);
+});
+jest.mock('../ErrorAlerts/ErrorAlert', () => () => (ErrorAlert
));
+jest.mock('../ErrorAlerts/FetchErrorAlert', () => (componentProps) => {
+ mockFetchErrorAlertFn(componentProps);
+ return (FetchErrorAlert
);
+});
+jest.mock('../ErrorAlerts/UploadErrorAlert', () => (componentProps) => {
+ mockUploadErrorAlertFn(componentProps);
+ return (UploadErrorAlert
);
+});
+
+describe('Selection Modal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ test('rendering correctly with expected Input', async () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Gallery')).toBeInTheDocument();
+ expect(screen.getByText('FileInput')).toBeInTheDocument();
+ expect(screen.getByText('FetchErrorAlert')).toBeInTheDocument();
+ expect(screen.getByText('UploadErrorAlert')).toBeInTheDocument();
+
+ expect(mockGalleryFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ...props.galleryProps,
+ isLoaded: props.isLoaded,
+ show: true,
+ }),
+ );
+ expect(mockFetchErrorAlertFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isFetchError: props.isFetchError,
+ message: props.modalMessages.fetchError,
+ }),
+ );
+ expect(mockUploadErrorAlertFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isUploadError: props.isUploadError,
+ message: props.modalMessages.uploadError,
+ }),
+ );
+ expect(mockFileInputFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ acceptedFiles: '.png',
+ fileInput: props.fileInput,
+ }),
+ );
+ });
+ test('rendering correctly with errors', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Gallery')).toBeInTheDocument();
+ expect(screen.getByText('FileInput')).toBeInTheDocument();
+ expect(screen.getByText('FetchErrorAlert')).toBeInTheDocument();
+ expect(screen.getByText('UploadErrorAlert')).toBeInTheDocument();
+
+ expect(mockFetchErrorAlertFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isFetchError: true,
+ message: props.modalMessages.fetchError,
+ }),
+ );
+ expect(mockGalleryFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ...props.galleryProps,
+ isLoaded: props.isLoaded,
+ show: false,
+ }),
+ );
+ });
+ test('rendering correctly with loading', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Gallery')).toBeInTheDocument();
+ expect(screen.getByText('FileInput')).toBeInTheDocument();
+ expect(screen.getByText('FetchErrorAlert')).toBeInTheDocument();
+ expect(screen.getByText('UploadErrorAlert')).toBeInTheDocument();
+
+ expect(mockGalleryFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ...props.galleryProps,
+ isLoaded: false,
+ show: true,
+ }),
+ );
+ });
+});
diff --git a/src/editors/sharedComponents/SelectionModal/messages.js b/src/editors/sharedComponents/SelectionModal/messages.js
new file mode 100644
index 000000000..10d18d0c2
--- /dev/null
+++ b/src/editors/sharedComponents/SelectionModal/messages.js
@@ -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;
diff --git a/src/editors/sharedComponents/SourceCodeModal/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/SourceCodeModal/__snapshots__/index.test.jsx.snap
index c7b019463..2676d51a9 100644
--- a/src/editors/sharedComponents/SourceCodeModal/__snapshots__/index.test.jsx.snap
+++ b/src/editors/sharedComponents/SourceCodeModal/__snapshots__/index.test.jsx.snap
@@ -2,6 +2,7 @@
exports[`SourceCodeModal renders as expected with default behavior 1`] = `
}
footerAction={null}
+ headerComponent={null}
+ isFullscreenScroll={true}
isOpen={false}
size="xl"
title="Edit Source Code"
diff --git a/src/editors/utils/formatDuration.js b/src/editors/utils/formatDuration.js
new file mode 100644
index 000000000..8458f51fc
--- /dev/null
+++ b/src/editors/utils/formatDuration.js
@@ -0,0 +1,18 @@
+import * as moment from 'moment-shortformat';
+
+const formatDuration = (duration) => {
+ const d = moment.duration(duration, 'seconds');
+ if (d.hours() > 0) {
+ return (
+ `${d.hours().toString().padStart(2, '0')}:`
+ + `${d.minutes().toString().padStart(2, '0')}:`
+ + `${d.seconds().toString().padStart(2, '0')}`
+ );
+ }
+ return (
+ `${d.minutes().toString().padStart(2, '0')}:`
+ + `${d.seconds().toString().padStart(2, '0')}`
+ );
+};
+
+export default formatDuration;
diff --git a/src/editors/utils/formatDuration.test.js b/src/editors/utils/formatDuration.test.js
new file mode 100644
index 000000000..6720130d7
--- /dev/null
+++ b/src/editors/utils/formatDuration.test.js
@@ -0,0 +1,12 @@
+import formatDuration from './formatDuration';
+
+describe('formatDuration', () => {
+ test.each([
+ [60, '01:00'],
+ [35, '00:35'],
+ [60 * 10 + 15, '10:15'],
+ [60 * 60 + 60 * 15 + 13, '01:15:13'],
+ ])('correct functionality of formatDuration with duration as %p', (duration, expected) => {
+ expect(formatDuration(duration)).toEqual(expected);
+ });
+});
diff --git a/src/editors/utils/index.js b/src/editors/utils/index.js
index b765c1382..14ecc749c 100644
--- a/src/editors/utils/index.js
+++ b/src/editors/utils/index.js
@@ -3,3 +3,4 @@ export { default as StrictDict } from './StrictDict';
export { default as keyStore } from './keyStore';
export { default as camelizeKeys } from './camelizeKeys';
export { default as removeItemOnce } from './removeOnce';
+export { default as formatDuration } from './formatDuration';
diff --git a/src/index.jsx b/src/index.jsx
index a92a3b872..847deec60 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -1,6 +1,7 @@
import Placeholder from './Placeholder';
import messages from './i18n/index';
import EditorPage from './editors/EditorPage';
+import VideoSelectorPage from './editors/VideoSelectorPage';
-export { messages, EditorPage };
+export { messages, EditorPage, VideoSelectorPage };
export default Placeholder;