feat: image upload skeleton (#22)
This commit is contained in:
@@ -1,51 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ModalDialog, ActionRow, Button } from '@edx/paragon';
|
||||
|
||||
const ImageUploadModal = ({ isOpen, close }) => (
|
||||
<ModalDialog
|
||||
title="My dialog"
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
size="lg"
|
||||
variant="default"
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
Im a dialog box
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
<p>
|
||||
Im baby palo santo ugh celiac fashion axe.
|
||||
La croix lo-fi venmo whatever.
|
||||
Beard man braid migas single-origin coffee forage ramps.
|
||||
Tumeric messenger bag bicycle rights wayfarers, try-hard cronut blue bottle health goth.
|
||||
Sriracha tumblr cardigan, cloud bread succulents tumeric copper mug marfa semiotics woke next
|
||||
level organic roof party +1 try-hard.
|
||||
</p>
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
Cancel
|
||||
</ModalDialog.CloseButton>
|
||||
<Button variant="primary">
|
||||
A button
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</ModalDialog>
|
||||
);
|
||||
|
||||
ImageUploadModal.propTypes = {
|
||||
isOpen: PropTypes.bool,
|
||||
close: PropTypes.func,
|
||||
};
|
||||
ImageUploadModal.defaultProps = {
|
||||
isOpen: false,
|
||||
close: () => {},
|
||||
};
|
||||
export default ImageUploadModal;
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
nullMethod,
|
||||
} from './hooks';
|
||||
import messages from './messages';
|
||||
import ImageUploadModal from './ImageUpload/ImageUploadModal';
|
||||
import ImageUploadModal from './components/ImageUploadModal';
|
||||
|
||||
export const TextEditor = ({
|
||||
setEditorRef,
|
||||
@@ -60,9 +60,13 @@ export const TextEditor = ({
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<Editor {...editorConfig({
|
||||
setEditorRef, blockValue, openModal, initializeEditor,
|
||||
})}
|
||||
<Editor
|
||||
{...editorConfig({
|
||||
setEditorRef,
|
||||
blockValue,
|
||||
openModal,
|
||||
initializeEditor,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
52
src/editors/containers/TextEditor/components/BaseModal.jsx
Normal file
52
src/editors/containers/TextEditor/components/BaseModal.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
ActionRow,
|
||||
ModalDialog,
|
||||
} from '@edx/paragon';
|
||||
|
||||
export const BaseModal = ({
|
||||
isOpen,
|
||||
close,
|
||||
title,
|
||||
children,
|
||||
confirmAction,
|
||||
}) => (
|
||||
<ModalDialog
|
||||
title="My dialog"
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
size="lg"
|
||||
variant="default"
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
{title}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
{children}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary" onClick={close}>
|
||||
Cancel
|
||||
</ModalDialog.CloseButton>
|
||||
{confirmAction}
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</ModalDialog>
|
||||
);
|
||||
|
||||
BaseModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
close: PropTypes.func.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
confirmAction: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default BaseModal;
|
||||
@@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Image,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import BaseModal from '../BaseModal';
|
||||
import * as module from '.';
|
||||
|
||||
export const hooks = {
|
||||
dimensions: () => {
|
||||
const [baseDimensions, setBaseDimensions] = React.useState(null);
|
||||
const [dimensions, setDimensions] = React.useState(null);
|
||||
const initialize = ({ height, width }) => {
|
||||
setBaseDimensions({ height, width });
|
||||
setDimensions({ height, width });
|
||||
};
|
||||
const reset = () => setDimensions(baseDimensions);
|
||||
const setWidth = (width) => setDimensions({ ...dimensions, width });
|
||||
const setHeight = (height) => setDimensions({ ...dimensions, height });
|
||||
return {
|
||||
value: dimensions,
|
||||
initialize,
|
||||
reset,
|
||||
setHeight,
|
||||
setWidth,
|
||||
};
|
||||
},
|
||||
altText: () => {
|
||||
const [altText, setAltText] = React.useState('');
|
||||
const [isDecorative, setIsDecorative] = React.useState(false);
|
||||
return {
|
||||
value: altText,
|
||||
set: setAltText,
|
||||
isDecorative,
|
||||
setIsDecorative,
|
||||
};
|
||||
},
|
||||
onImgLoad: (initializeDimensions) => ({ target: img }) => {
|
||||
initializeDimensions({
|
||||
height: img.naturalHeight,
|
||||
width: img.naturalWidth,
|
||||
});
|
||||
},
|
||||
onInputChange: (handleValue) => (e) => handleValue(e.target.value),
|
||||
onCheckboxChange: (handleValue) => (e) => handleValue(e.target.checked),
|
||||
onSave: ({
|
||||
saveToEditor,
|
||||
dimensions,
|
||||
altText,
|
||||
isDecorative,
|
||||
}) => saveToEditor({
|
||||
dimensions,
|
||||
altText,
|
||||
isDecorative,
|
||||
}),
|
||||
};
|
||||
|
||||
export const ImageSettingsModal = ({
|
||||
isOpen,
|
||||
close,
|
||||
selection,
|
||||
saveToEditor,
|
||||
returnToSelection,
|
||||
}) => {
|
||||
const dimensions = module.hooks.dimensions();
|
||||
const altText = module.hooks.altText();
|
||||
const onImgLoad = module.hooks.onImgLoad(dimensions.initialize);
|
||||
const onSaveClick = module.hooks.onSave({
|
||||
saveToEditor,
|
||||
dimensions: dimensions.value,
|
||||
altText: altText.value,
|
||||
isDecorative: altText.isDecorative,
|
||||
});
|
||||
return (
|
||||
<BaseModal
|
||||
title="Image Settings"
|
||||
close={close}
|
||||
isOpen={isOpen}
|
||||
confirmAction={(
|
||||
<Button variant="primary" onClick={onSaveClick}>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<Button onClick={returnToSelection} variant="link" size="inline">
|
||||
Select another image
|
||||
</Button>
|
||||
<br />
|
||||
<Image
|
||||
style={{ maxWidth: '200px', maxHeight: '200px' }}
|
||||
onLoad={onImgLoad}
|
||||
src={selection.externalUrl}
|
||||
/>
|
||||
|
||||
{ dimensions.value && (
|
||||
<Form.Group>
|
||||
<Form.Label>Image Dimensions</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
value={dimensions.value.width}
|
||||
min={0}
|
||||
onChange={module.hooks.onInputChange(dimensions.setWidth)}
|
||||
floatingLabel="Width"
|
||||
/>
|
||||
<Form.Control
|
||||
type="number"
|
||||
value={dimensions.value.height}
|
||||
min={0}
|
||||
onChange={module.hooks.onInputChange(dimensions.setHeight)}
|
||||
floatingLabel="Height"
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
<Form.Group>
|
||||
<Form.Label>Accessibility</Form.Label>
|
||||
<Form.Control
|
||||
type="input"
|
||||
value={altText.value}
|
||||
disabled={altText.isDecorative}
|
||||
onChange={module.hooks.onInputChange(altText.set)}
|
||||
floatingLabel="Alt Text"
|
||||
/>
|
||||
<Form.Checkbox
|
||||
checked={altText.isDecorative}
|
||||
onChange={module.hooks.onCheckboxChange(altText.setIsDecorative)}
|
||||
>
|
||||
This image is decorative (no alt text required).
|
||||
</Form.Checkbox>
|
||||
</Form.Group>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
ImageSettingsModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
close: PropTypes.func.isRequired,
|
||||
selection: PropTypes.shape({
|
||||
url: PropTypes.string,
|
||||
externalUrl: PropTypes.string,
|
||||
}).isRequired,
|
||||
saveToEditor: PropTypes.func.isRequired,
|
||||
returnToSelection: PropTypes.func.isRequired,
|
||||
};
|
||||
export default ImageSettingsModal;
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ImageSettingsModal from './ImageSettingsModal';
|
||||
import SelectImageModal from './SelectImageModal';
|
||||
|
||||
const ImageUploadModal = ({ isOpen, close, editorRef }) => {
|
||||
// selected image file reference data object.
|
||||
// existance of this field determines which child modal is displayed
|
||||
const [selection, setSelection] = React.useState(null);
|
||||
const clearSelection = () => setSelection(null);
|
||||
const saveToEditor = (settings) => {
|
||||
console.log({ selection, settings });
|
||||
// tell editor ref to insert content at cursor location();
|
||||
};
|
||||
const closeAndReset = () => {
|
||||
setSelection(null);
|
||||
close();
|
||||
};
|
||||
if (selection) {
|
||||
return (
|
||||
<ImageSettingsModal
|
||||
{...{
|
||||
isOpen,
|
||||
close: closeAndReset,
|
||||
selection,
|
||||
saveToEditor,
|
||||
returnToSelection: clearSelection,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (<SelectImageModal {...{ isOpen, close, setSelection }} />);
|
||||
};
|
||||
|
||||
ImageUploadModal.defaultProps = {
|
||||
editorRef: null,
|
||||
};
|
||||
ImageUploadModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
close: PropTypes.func.isRequired,
|
||||
editorRef: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.any }),
|
||||
]),
|
||||
};
|
||||
export default ImageUploadModal;
|
||||
@@ -0,0 +1,66 @@
|
||||
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);
|
||||
47
src/editors/data/constants/mockData.js
Normal file
47
src/editors/data/constants/mockData.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const mockImageData = [
|
||||
{
|
||||
displayName: 'shahrukh.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
dateAdded: 'Jan 05, 2022 at 17:38 UTC',
|
||||
url: '/asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
|
||||
externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
|
||||
portableUrl: '/static/shahrukh.jpg',
|
||||
thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@shahrukh.jpg',
|
||||
locked: false,
|
||||
id: 'asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
|
||||
},
|
||||
{
|
||||
displayName: 'IMG_5899.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
dateAdded: 'Nov 16, 2021 at 18:55 UTC',
|
||||
url: '/asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
|
||||
externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
|
||||
portableUrl: '/static/IMG_5899.jpg',
|
||||
thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@IMG_5899.jpg',
|
||||
locked: false,
|
||||
id: 'asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
|
||||
},
|
||||
{
|
||||
displayName: 'ccexample.srt',
|
||||
contentType: 'application/octet-stream',
|
||||
dateAdded: 'Nov 01, 2021 at 15:42 UTC',
|
||||
url: '/asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
|
||||
externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
|
||||
portableUrl: '/static/ccexample.srt',
|
||||
thumbnail: null,
|
||||
locked: false,
|
||||
id: 'asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
|
||||
},
|
||||
{
|
||||
displayName: 'Tennis Ball.jpeg',
|
||||
contentType: 'image/jpeg',
|
||||
dateAdded: 'Aug 04, 2021 at 16:52 UTC',
|
||||
url: '/asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
|
||||
externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
|
||||
portableUrl: '/static/Tennis_Ball.jpeg',
|
||||
thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@Tennis_Ball-jpeg.jpg',
|
||||
locked: false,
|
||||
id: 'asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
|
||||
},
|
||||
];
|
||||
@@ -37,10 +37,14 @@ export const isInitialized = createSelector(
|
||||
|
||||
export const typeHeader = createSelector(
|
||||
[module.simpleSelectors.blockType],
|
||||
(blockType) => ((blockType === blockTypes.html)
|
||||
? 'Text'
|
||||
: blockType[0].toUpperCase() + blockType.substring(1)
|
||||
),
|
||||
(blockType) => {
|
||||
if (blockType === null) {
|
||||
return null;
|
||||
}
|
||||
return (blockType === blockTypes.html)
|
||||
? 'Text'
|
||||
: blockType[0].toUpperCase() + blockType.substring(1);
|
||||
},
|
||||
);
|
||||
|
||||
export default {
|
||||
|
||||
@@ -87,6 +87,9 @@ describe('app selectors unit tests', () => {
|
||||
it('is memoized based on blockType', () => {
|
||||
expect(selectors.typeHeader.preSelectors).toEqual([simpleSelectors.blockType]);
|
||||
});
|
||||
it('returns null if blockType is null', () => {
|
||||
expect(selectors.typeHeader.cb(null)).toEqual(null);
|
||||
});
|
||||
it('returns Text if the blockType is html', () => {
|
||||
expect(selectors.typeHeader.cb('html')).toEqual('Text');
|
||||
});
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,5 @@
|
||||
import { StrictDict } from '../../../utils';
|
||||
import mockData from '../../constants/mockData';
|
||||
import { actions } from '..';
|
||||
import * as requests from './requests';
|
||||
import * as module from './app';
|
||||
@@ -43,9 +44,15 @@ export const saveBlock = ({ content, returnToUnit }) => (dispatch) => {
|
||||
}));
|
||||
};
|
||||
|
||||
export const fetchImages = ({ onSuccess }) => () => {
|
||||
// get images
|
||||
onSuccess(mockData.mockImageData);
|
||||
};
|
||||
|
||||
export default StrictDict({
|
||||
fetchBlock,
|
||||
fetchUnit,
|
||||
initialize,
|
||||
saveBlock,
|
||||
fetchImages,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user