feat: image upload skeleton (#22)

This commit is contained in:
Ben Warzeski
2022-03-01 11:17:03 -05:00
committed by GitHub
parent f3d80995c5
commit 9c9d3c8fdf
11 changed files with 385 additions and 59 deletions

View File

@@ -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;

View File

@@ -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>

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View 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',
},
];

View File

@@ -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 {

View File

@@ -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');
});

View File

@@ -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,
});