Drag and drop an image in the editor (#10145)
* Refactored drag n drop functionality to a hook and component. * Add the snackbar to the article editor. * Now you can drag and drop images in the post body content. * Cleaning up a few things. * Now post cover images can be dragged and dropped. * Removed unused empty function. * Added @testing-library/preact-hooks so that we can test hooks. * Some renaming, small refactor. * Added tests * Added more tests. * Added more tests. * Added message about dropping only one image in post body. * Added message about dropping only one image for cover image. * put onDrop into it's own meethod. * More tests. * Added some API documentation. * Updated editor help with drag and drop image help. * Changed wording. * Now the alt text for markdown removes the file extension. * Updated help wording. Thanks @rhymes * Now the image markdown adds a new line at the end as we currently do not support inline images.
This commit is contained in:
parent
2f36630ff0
commit
f0963c1bfa
14 changed files with 660 additions and 74 deletions
|
|
@ -1,3 +1,5 @@
|
|||
import { validateFileInputs } from '../packs/validateFileInputs';
|
||||
|
||||
export function previewArticle(payload, successCb, failureCb) {
|
||||
fetch('/articles/preview', {
|
||||
method: 'POST',
|
||||
|
|
@ -97,3 +99,23 @@ export function generateMainImage(payload, successCb, failureCb) {
|
|||
})
|
||||
.catch(failureCb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes images for upload.
|
||||
*
|
||||
* @param {FileList} images Images to be uploaded.
|
||||
* @param {Function} handleImageSuccess The handler that runs when the image is uploaded successfully.
|
||||
* @param {Function} handleImageFailure The handler that runs when the image upload fails.
|
||||
*/
|
||||
export function processImageUpload(
|
||||
images,
|
||||
handleImageSuccess,
|
||||
handleImageFailure,
|
||||
) {
|
||||
// Currently only one image is supported for upload.
|
||||
if (images.length > 0 && validateFileInputs()) {
|
||||
const payload = { image: images };
|
||||
|
||||
generateMainImage(payload, handleImageSuccess, handleImageFailure);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { h, Component, Fragment } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import { addSnackbarItem } from '../../Snackbar';
|
||||
import { generateMainImage } from '../actions';
|
||||
import { validateFileInputs } from '../../packs/validateFileInputs';
|
||||
import { onDragOver, onDragExit } from './dragAndDropHelpers';
|
||||
import { Button } from '@crayons';
|
||||
import { Spinner } from '@crayons/Spinner/Spinner';
|
||||
import { DragAndDropZone } from '@utilities/dragAndDrop';
|
||||
|
||||
export class ArticleCoverImage extends Component {
|
||||
state = {
|
||||
|
|
@ -17,14 +20,16 @@ export class ArticleCoverImage extends Component {
|
|||
this.setState({ uploadingImage: false });
|
||||
};
|
||||
|
||||
handleMainImageUpload = (e) => {
|
||||
e.preventDefault();
|
||||
handleMainImageUpload = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
this.setState({ uploadingImage: true });
|
||||
this.clearUploadError();
|
||||
|
||||
if (validateFileInputs()) {
|
||||
const payload = { image: e.target.files, wrap_cloudinary: true };
|
||||
const { files: image } =
|
||||
event instanceof DragEvent ? event.dataTransfer : event.target;
|
||||
const payload = { image, wrap_cloudinary: true };
|
||||
|
||||
generateMainImage(payload, this.onImageUploadSuccess, this.onUploadError);
|
||||
}
|
||||
|
|
@ -53,55 +58,75 @@ export class ArticleCoverImage extends Component {
|
|||
});
|
||||
};
|
||||
|
||||
onDropImage = (event) => {
|
||||
onDragExit(event);
|
||||
|
||||
if (event.dataTransfer.files.length > 1) {
|
||||
addSnackbarItem({
|
||||
message: 'Only one image can be dropped at a time.',
|
||||
addCloseButton: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleMainImageUpload(event);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { mainImage } = this.props;
|
||||
const { uploadError, uploadErrorMessage, uploadingImage } = this.state;
|
||||
const uploadLabel = mainImage ? 'Change' : 'Add a cover image';
|
||||
|
||||
return (
|
||||
<div className="crayons-article-form__cover" role="presentation">
|
||||
{!uploadingImage && mainImage && (
|
||||
<img
|
||||
src={mainImage}
|
||||
className="crayons-article-form__cover__image"
|
||||
width="250"
|
||||
height="105"
|
||||
alt="Post cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
{uploadingImage ? (
|
||||
<span class="lh-base pl-1 border-0 py-2 inline-block">
|
||||
<Spinner /> Uploading...
|
||||
</span>
|
||||
) : (
|
||||
<Fragment>
|
||||
<Button variant="outlined" className="mr-2 whitespace-nowrap">
|
||||
<label htmlFor="cover-image-input">{uploadLabel}</label>
|
||||
<input
|
||||
id="cover-image-input"
|
||||
type="file"
|
||||
onChange={this.handleMainImageUpload}
|
||||
accept="image/*"
|
||||
className="w-100 h-100 absolute left-0 right-0 top-0 bottom-0 overflow-hidden opacity-0 cursor-pointer"
|
||||
data-max-file-size-mb="25"
|
||||
/>
|
||||
</Button>
|
||||
{mainImage && (
|
||||
<Button
|
||||
variant="ghost-danger"
|
||||
onClick={this.triggerMainImageRemoval}
|
||||
>
|
||||
Remove
|
||||
<DragAndDropZone
|
||||
onDragOver={onDragOver}
|
||||
onDragExit={onDragExit}
|
||||
onDrop={this.onDropImage}
|
||||
>
|
||||
<div className="crayons-article-form__cover" role="presentation">
|
||||
{!uploadingImage && mainImage && (
|
||||
<img
|
||||
src={mainImage}
|
||||
className="crayons-article-form__cover__image"
|
||||
width="250"
|
||||
height="105"
|
||||
alt="Post cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
{uploadingImage ? (
|
||||
<span class="lh-base pl-1 border-0 py-2 inline-block">
|
||||
<Spinner /> Uploading...
|
||||
</span>
|
||||
) : (
|
||||
<Fragment>
|
||||
<Button variant="outlined" className="mr-2 whitespace-nowrap">
|
||||
<label htmlFor="cover-image-input">{uploadLabel}</label>
|
||||
<input
|
||||
id="cover-image-input"
|
||||
type="file"
|
||||
onChange={this.handleMainImageUpload}
|
||||
accept="image/*"
|
||||
className="w-100 h-100 absolute left-0 right-0 top-0 bottom-0 overflow-hidden opacity-0 cursor-pointer"
|
||||
data-max-file-size-mb="25"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Fragment>
|
||||
{mainImage && (
|
||||
<Button
|
||||
variant="ghost-danger"
|
||||
onClick={this.triggerMainImageRemoval}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
{uploadError && (
|
||||
<p className="articleform__uploaderror">{uploadErrorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
{uploadError && (
|
||||
<p className="articleform__uploaderror">{uploadErrorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
</DragAndDropZone>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,38 @@
|
|||
import { h } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import Textarea from 'preact-textarea-autosize';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import { Toolbar } from './Toolbar';
|
||||
import {
|
||||
handleImageDrop,
|
||||
handleImageFailure,
|
||||
onDragOver,
|
||||
onDragExit,
|
||||
} from './dragAndDropHelpers';
|
||||
import { useDragAndDrop } from '@utilities/dragAndDrop';
|
||||
|
||||
function handleImageSuccess(textAreaRef) {
|
||||
return function (response) {
|
||||
// Function is within the component to be able to access
|
||||
// textarea ref.
|
||||
const editableBodyElement = textAreaRef.current.base;
|
||||
const { links, image } = response;
|
||||
const altText = image[0].name.replace(/\.[^.]+$/, '');
|
||||
const markdownImageLink = `\n`;
|
||||
const { selectionStart, selectionEnd, value } = editableBodyElement;
|
||||
const before = value.substring(0, selectionStart);
|
||||
const after = value.substring(selectionEnd, value.length);
|
||||
|
||||
editableBodyElement.value = `${before + markdownImageLink} ${after}`;
|
||||
editableBodyElement.selectionStart =
|
||||
selectionStart + markdownImageLink.length;
|
||||
editableBodyElement.selectionEnd = editableBodyElement.selectionStart;
|
||||
|
||||
// Dispatching a new event so that linkstate, https://github.com/developit/linkstate,
|
||||
// the function used to create the onChange prop gets called correctly.
|
||||
editableBodyElement.dispatchEvent(new Event('input'));
|
||||
};
|
||||
}
|
||||
|
||||
export const EditorBody = ({
|
||||
onChange,
|
||||
|
|
@ -9,6 +40,22 @@ export const EditorBody = ({
|
|||
switchHelpContext,
|
||||
version,
|
||||
}) => {
|
||||
const textAreaRef = useRef(null);
|
||||
const { setElement } = useDragAndDrop({
|
||||
onDrop: handleImageDrop(
|
||||
handleImageSuccess(textAreaRef),
|
||||
handleImageFailure,
|
||||
),
|
||||
onDragOver,
|
||||
onDragExit,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (textAreaRef.current) {
|
||||
setElement(textAreaRef.current.base);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="article-form__body"
|
||||
|
|
@ -27,6 +74,7 @@ export const EditorBody = ({
|
|||
switchHelpContext(_event);
|
||||
}}
|
||||
name="body_markdown"
|
||||
ref={textAreaRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -235,6 +235,10 @@ export class Help extends Component {
|
|||
</a>{' '}
|
||||
to add rich content such as Tweets, YouTube videos, etc.
|
||||
</li>
|
||||
<li>
|
||||
In addition to images for the post's content, you can also drag and
|
||||
drop a cover image
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ describe('<ArticleCoverImage />', () => {
|
|||
});
|
||||
|
||||
it('allows a user to change the image', async () => {
|
||||
global.DragEvent = jest.fn();
|
||||
|
||||
fetch.mockResponse(
|
||||
JSON.stringify({
|
||||
image: ['/i/changed-fake-link.jpg'],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
import {
|
||||
handleImageDrop,
|
||||
handleImageFailure,
|
||||
matchesDataTransferType,
|
||||
} from '../dragAndDropHelpers';
|
||||
import { addSnackbarItem } from '../../../Snackbar';
|
||||
import { processImageUpload } from '../../actions';
|
||||
|
||||
jest.mock('../../../Snackbar');
|
||||
jest.mock('../../actions');
|
||||
|
||||
describe('Article drag and drop helpers', () => {
|
||||
beforeEach(() => {
|
||||
addSnackbarItem.mockReset();
|
||||
});
|
||||
|
||||
describe('matchesDataTransferType', () => {
|
||||
it('should match data transfer type Files when no data transfer type to match is set', () => {
|
||||
expect(matchesDataTransferType(['Files'])).toEqual(true);
|
||||
});
|
||||
|
||||
it('should match data transfer type when the type to match is in the list of types', () => {
|
||||
expect(
|
||||
matchesDataTransferType(
|
||||
['SomeType', 'OtherType', 'BestType'],
|
||||
'SomeType',
|
||||
),
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
it('should not match data transfer type when the type to match is not in the list of types', () => {
|
||||
expect(
|
||||
matchesDataTransferType(
|
||||
['SomeType', 'OtherType', 'BestType'],
|
||||
'NotInTheListType',
|
||||
),
|
||||
).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleImageFailure', () => {
|
||||
it('should handle image failure', () => {
|
||||
handleImageFailure(new Error('oh no'));
|
||||
|
||||
expect(addSnackbarItem).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleImageDrop', () => {
|
||||
it('should abort if data dropped is not files', () => {
|
||||
const successHandler = jest.fn();
|
||||
const failureHandler = jest.fn();
|
||||
const imageHandler = handleImageDrop(successHandler, failureHandler);
|
||||
const dropEvent = {
|
||||
preventDefault: jest.fn(),
|
||||
currentTarget: document.createElement('textarea'),
|
||||
dataTransfer: { types: ['NotFiles'] },
|
||||
};
|
||||
|
||||
imageHandler(dropEvent);
|
||||
|
||||
expect(processImageUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should abort if data dropped is multiple files', () => {
|
||||
const successHandler = jest.fn();
|
||||
const failureHandler = jest.fn();
|
||||
const imageHandler = handleImageDrop(successHandler, failureHandler);
|
||||
const dropEvent = {
|
||||
preventDefault: jest.fn(),
|
||||
currentTarget: document.createElement('textarea'),
|
||||
dataTransfer: {
|
||||
types: ['Files'],
|
||||
files: [
|
||||
new File(['(⌐□_□)'], 'chucknorris.png', {
|
||||
type: 'image/png',
|
||||
}),
|
||||
new File(['ʕʘ̅͜ʘ̅ʔ'], 'vandamme.png', {
|
||||
type: 'image/png',
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
imageHandler(dropEvent);
|
||||
|
||||
expect(addSnackbarItem).toHaveBeenCalledTimes(1);
|
||||
expect(processImageUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should process image upload if data dropped is one image file', () => {
|
||||
const successHandler = jest.fn();
|
||||
const failureHandler = jest.fn();
|
||||
const imageHandler = handleImageDrop(successHandler, failureHandler);
|
||||
const dropEvent = {
|
||||
preventDefault: jest.fn(),
|
||||
currentTarget: document.createElement('textarea'),
|
||||
dataTransfer: {
|
||||
types: ['Files'],
|
||||
files: [
|
||||
new File(['(⌐□_□)'], 'chucknorris.png', {
|
||||
type: 'image/png',
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
imageHandler(dropEvent);
|
||||
|
||||
expect(addSnackbarItem).not.toHaveBeenCalled();
|
||||
expect(processImageUpload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
73
app/javascript/article-form/components/dragAndDropHelpers.js
Normal file
73
app/javascript/article-form/components/dragAndDropHelpers.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { addSnackbarItem } from '../../Snackbar';
|
||||
import { processImageUpload } from '../actions';
|
||||
|
||||
/**
|
||||
* Determines if at least one type of drag and drop datum type matches the data transfer type to match.
|
||||
*
|
||||
* @param {string[]} types An array of data transfer types.
|
||||
* @param {string} dataTransferType The data transfer type to match.
|
||||
*/
|
||||
export function matchesDataTransferType(
|
||||
types = [],
|
||||
dataTransferType = 'Files',
|
||||
) {
|
||||
return types.some((type) => type === dataTransferType);
|
||||
}
|
||||
|
||||
// TODO: Document functions
|
||||
export function handleImageDrop(handleImageSuccess, handleImageFailure) {
|
||||
return function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!matchesDataTransferType(event.dataTransfer.types)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.currentTarget.classList.remove('opacity-25');
|
||||
|
||||
const { files } = event.dataTransfer;
|
||||
|
||||
if (files.length > 1) {
|
||||
addSnackbarItem({
|
||||
message: 'Only one image can be dropped at a time.',
|
||||
addCloseButton: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
processImageUpload(files, handleImageSuccess, handleImageFailure);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dragover handler for the editor
|
||||
*
|
||||
* @param {DragEvent} event the drag event.
|
||||
*/
|
||||
export function onDragOver(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.add('opacity-25');
|
||||
}
|
||||
|
||||
/**
|
||||
* DragExit handler for the editor
|
||||
*
|
||||
* @param {DragEvent} event the drag event.
|
||||
*/
|
||||
export function onDragExit(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.remove('opacity-25');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for when image upload fails.
|
||||
*
|
||||
* @param {Error} error an error
|
||||
* @param {string} error.message an error message
|
||||
*/
|
||||
export function handleImageFailure({ message }) {
|
||||
addSnackbarItem({
|
||||
message,
|
||||
addCloseButton: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -8,8 +8,8 @@ import { h, Component } from 'preact';
|
|||
import PropTypes from 'prop-types';
|
||||
import { setupPusher } from '../utilities/connect';
|
||||
import debounceAction from '../utilities/debounceAction';
|
||||
import { dragDrop } from '../utilities/dragAndUpload';
|
||||
import { addSnackbarItem } from '../Snackbar';
|
||||
import { processImageUpload } from '../article-form/actions';
|
||||
import {
|
||||
conductModeration,
|
||||
getAllMessages,
|
||||
|
|
@ -41,6 +41,7 @@ import Message from './message';
|
|||
import ActionMessage from './actionMessage';
|
||||
import Content from './content';
|
||||
import { VideoContent } from './videoContent';
|
||||
import { DragAndDropZone } from '@utilities/dragAndDrop';
|
||||
|
||||
const NARROW_WIDTH_LIMIT = 767;
|
||||
const WIDE_WIDTH_LIMIT = 1600;
|
||||
|
|
@ -130,7 +131,9 @@ export default class Chat extends Component {
|
|||
|
||||
setupObserver(this.observerCallback);
|
||||
|
||||
this.subscribePusher(`private-message-notifications--${appName}-${currentUserId}`);
|
||||
this.subscribePusher(
|
||||
`private-message-notifications--${appName}-${currentUserId}`,
|
||||
);
|
||||
|
||||
if (activeChannelId) {
|
||||
sendOpen(activeChannelId, this.handleChannelOpenSuccess, null);
|
||||
|
|
@ -1412,14 +1415,23 @@ export default class Chat extends Component {
|
|||
.getElementById('jumpback_button')
|
||||
.classList.remove('chatchanneljumpback__hide');
|
||||
};
|
||||
handleImageDrop = (e) => {
|
||||
e.preventDefault();
|
||||
const { files } = e.dataTransfer;
|
||||
|
||||
const messageArea = document.getElementById('messagelist');
|
||||
messageArea.classList.remove('opacity-25');
|
||||
messageArea.classList.add('opacity-100');
|
||||
dragDrop(files, this.handleImageSuccess, this.handleImageFailure);
|
||||
handleDragOver = (event) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.add('opacity-25');
|
||||
};
|
||||
|
||||
handleDragExit = (event) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.remove('opacity-25');
|
||||
};
|
||||
|
||||
handleImageDrop = (event) => {
|
||||
event.preventDefault();
|
||||
const { files } = event.dataTransfer;
|
||||
|
||||
event.currentTarget.classList.remove('opacity-25');
|
||||
processImageUpload(files, this.handleImageSuccess, this.handleImageFailure);
|
||||
};
|
||||
handleImageSuccess = (res) => {
|
||||
const { links, image } = res;
|
||||
|
|
@ -1433,7 +1445,7 @@ export default class Chat extends Component {
|
|||
const after = text.substring(end, text.length);
|
||||
el.value = `${before + mLink} ${after}`;
|
||||
el.selectionStart = start + mLink.length + 1;
|
||||
el.selectionEnd = start + mLink.length + 1;
|
||||
el.selectionEnd = el.selectionStart;
|
||||
el.focus();
|
||||
};
|
||||
handleImageFailure = (e) => {
|
||||
|
|
@ -1448,7 +1460,6 @@ export default class Chat extends Component {
|
|||
e.preventDefault();
|
||||
const messageArea = document.getElementById('messagelist');
|
||||
messageArea.classList.remove('opacity-25');
|
||||
messageArea.classList.add('opacity-100');
|
||||
}
|
||||
renderActiveChatChannel = (channelHeader) => {
|
||||
const { state, props } = this;
|
||||
|
|
@ -1457,20 +1468,26 @@ export default class Chat extends Component {
|
|||
<div className="activechatchannel">
|
||||
<div className="activechatchannel__conversation">
|
||||
{channelHeader}
|
||||
<div
|
||||
className="activechatchannel__messages"
|
||||
onScroll={this.handleMessageScroll}
|
||||
ref={(scroller) => {
|
||||
this.scroller = scroller;
|
||||
}}
|
||||
id="messagelist"
|
||||
onDragOver={this.handleDragHover}
|
||||
<DragAndDropZone
|
||||
onDragOver={this.handleDragOver}
|
||||
onDragExit={this.handleDragExit}
|
||||
onDrop={this.handleImageDrop}
|
||||
>
|
||||
{this.renderMessages()}
|
||||
<div className="messagelist__sentinel" id="messagelist__sentinel" />
|
||||
</div>
|
||||
<div
|
||||
className="activechatchannel__messages"
|
||||
onScroll={this.handleMessageScroll}
|
||||
ref={(scroller) => {
|
||||
this.scroller = scroller;
|
||||
}}
|
||||
id="messagelist"
|
||||
>
|
||||
{this.renderMessages()}
|
||||
<div
|
||||
className="messagelist__sentinel"
|
||||
id="messagelist__sentinel"
|
||||
/>
|
||||
</div>
|
||||
</DragAndDropZone>
|
||||
<div
|
||||
className="chatchanneljumpback chatchanneljumpback__hide"
|
||||
id="jumpback_button"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { h, render } from 'preact';
|
||||
import { getUserDataAndCsrfToken } from '../chat/util';
|
||||
import ArticleForm from '../article-form/articleForm';
|
||||
import { Snackbar } from '../Snackbar';
|
||||
|
||||
HTMLDocument.prototype.ready = new Promise((resolve) => {
|
||||
if (document.readyState !== 'loading') {
|
||||
|
|
@ -11,6 +12,13 @@ HTMLDocument.prototype.ready = new Promise((resolve) => {
|
|||
});
|
||||
|
||||
function loadForm() {
|
||||
// The Snackbar for the article page
|
||||
const snackZone = document.getElementById('snack-zone');
|
||||
|
||||
if (snackZone) {
|
||||
render(<Snackbar lifespan="3" />, snackZone);
|
||||
}
|
||||
|
||||
getUserDataAndCsrfToken().then(({ currentUser, csrfToken }) => {
|
||||
window.currentUser = currentUser;
|
||||
window.csrfToken = csrfToken;
|
||||
|
|
|
|||
179
app/javascript/utilities/__tests__/dragAndDrop.test.js
Normal file
179
app/javascript/utilities/__tests__/dragAndDrop.test.js
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { h } from 'preact';
|
||||
import { render } from '@testing-library/preact';
|
||||
import { renderHook, act } from '@testing-library/preact-hooks';
|
||||
import { DragAndDropZone, useDragAndDrop } from '@utilities/dragAndDrop';
|
||||
|
||||
describe('drag and drop for components', () => {
|
||||
describe('useDragAndDrop', () => {
|
||||
it('should not add drag and drop event listeners when DOM element is null', () => {
|
||||
const onDrop = jest.fn();
|
||||
const onDragOver = jest.fn();
|
||||
const onDragExit = jest.fn();
|
||||
|
||||
HTMLElement.prototype.addEventListener = jest.fn();
|
||||
HTMLDocument.prototype.addEventListener = jest.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDragAndDrop({
|
||||
onDrop,
|
||||
onDragOver,
|
||||
onDragExit,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setElement(null);
|
||||
});
|
||||
|
||||
expect(HTMLElement.prototype.addEventListener).not.toHaveBeenCalled();
|
||||
expect(HTMLDocument.prototype.addEventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should attach drag and drop events when setElement receives a DOM node', () => {
|
||||
const onDrop = jest.fn();
|
||||
const onDragOver = jest.fn();
|
||||
const onDragExit = jest.fn();
|
||||
|
||||
HTMLElement.prototype.addEventListener = jest.fn();
|
||||
HTMLDocument.prototype.addEventListener = jest.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDragAndDrop({
|
||||
onDrop,
|
||||
onDragOver,
|
||||
onDragExit,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setElement(document.createElement('textarea'));
|
||||
});
|
||||
|
||||
expect(HTMLElement.prototype.addEventListener).toHaveBeenCalledTimes(4);
|
||||
expect(HTMLDocument.prototype.addEventListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should remove drag and drop event listeners when the hook is unmounted', () => {
|
||||
const onDrop = jest.fn();
|
||||
const onDragOver = jest.fn();
|
||||
const onDragExit = jest.fn();
|
||||
|
||||
HTMLElement.prototype.removeEventListener = jest.fn();
|
||||
HTMLDocument.prototype.removeEventListener = jest.fn();
|
||||
|
||||
const { unmount, result } = renderHook(() =>
|
||||
useDragAndDrop({
|
||||
onDrop,
|
||||
onDragOver,
|
||||
onDragExit,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setElement(document.createElement('textarea'));
|
||||
});
|
||||
|
||||
unmount();
|
||||
expect(HTMLDocument.prototype.removeEventListener).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
expect(HTMLElement.prototype.removeEventListener).toHaveBeenCalledTimes(
|
||||
4,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<DragAndDropZone />', () => {
|
||||
it('should attach drag and drop events', async () => {
|
||||
const onDrop = jest.fn();
|
||||
const onDragOver = jest.fn();
|
||||
const onDragExit = jest.fn();
|
||||
|
||||
HTMLElement.prototype.addEventListener = jest.fn();
|
||||
HTMLDocument.prototype.addEventListener = jest.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<DragAndDropZone
|
||||
onDragOver={onDragOver}
|
||||
onDragExit={onDragExit}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<textarea>I'm a text area</textarea>
|
||||
</DragAndDropZone>,
|
||||
);
|
||||
|
||||
// Rerendering so that the ref gets set to the DOM node.
|
||||
// represented by the Preact element <textarea />
|
||||
rerender(
|
||||
<DragAndDropZone
|
||||
onDragOver={onDragOver}
|
||||
onDragExit={onDragExit}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<textarea>I'm a text area</textarea>
|
||||
</DragAndDropZone>,
|
||||
);
|
||||
|
||||
expect(HTMLDocument.prototype.addEventListener).toBeCalledTimes(2);
|
||||
expect(HTMLElement.prototype.addEventListener).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('should not attach drag and drop events', async () => {
|
||||
const onDrop = jest.fn();
|
||||
const onDragOver = jest.fn();
|
||||
const onDragExit = jest.fn();
|
||||
|
||||
HTMLElement.prototype.removeEventListener = jest.fn();
|
||||
HTMLDocument.prototype.removeEventListener = jest.fn();
|
||||
|
||||
const { unmount, rerender } = render(
|
||||
<DragAndDropZone
|
||||
onDragOver={onDragOver}
|
||||
onDragExit={onDragExit}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<textarea>I'm a text area</textarea>
|
||||
</DragAndDropZone>,
|
||||
);
|
||||
|
||||
// Rerendering so that the ref gets set to the DOM node.
|
||||
// represented by the Preact element <textarea />
|
||||
rerender(
|
||||
<DragAndDropZone
|
||||
onDragOver={onDragOver}
|
||||
onDragExit={onDragExit}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<textarea>I'm a text area</textarea>
|
||||
</DragAndDropZone>,
|
||||
);
|
||||
|
||||
unmount();
|
||||
expect(HTMLDocument.prototype.removeEventListener).toBeCalledTimes(2);
|
||||
expect(HTMLElement.prototype.removeEventListener).toHaveBeenCalledTimes(
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not attach drag and drop events when no children.', async () => {
|
||||
const onDrop = jest.fn();
|
||||
const onDragOver = jest.fn();
|
||||
const onDragExit = jest.fn();
|
||||
|
||||
HTMLElement.prototype.addEventListener = jest.fn();
|
||||
HTMLDocument.prototype.addEventListener = jest.fn();
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<DragAndDropZone
|
||||
onDragOver={onDragOver}
|
||||
onDragExit={onDragExit}
|
||||
onDrop={onDrop}
|
||||
/>,
|
||||
);
|
||||
}).toThrowError(
|
||||
'The <DragAndDropZone /> component children prop is null or was not specified.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
97
app/javascript/utilities/dragAndDrop.js
Normal file
97
app/javascript/utilities/dragAndDrop.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { cloneElement } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
/**
|
||||
* A custom Preact hook used to attach drag and drop functionality to a DOM element.
|
||||
* @example
|
||||
* function SomeComponent(props) {
|
||||
* const { setElement } = useDragAndDrop({
|
||||
* onDrop: someDropHandler,
|
||||
* onDragOver: someDragOverHandler,
|
||||
* onDragExit: someDragExitHandler
|
||||
* });
|
||||
*
|
||||
* const someDomRef = useRef(null);
|
||||
*
|
||||
* useEffect(() => {
|
||||
* if (someDomRef.current) {
|
||||
* setElement(someDomRef.current);
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* return <textarea ref={someDomRef}>I'm a text area</textarea>;
|
||||
* };
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {Function} props.onDragOver The handler that runs when the dragover event is fired.
|
||||
* @param {Function} props.onDragExit The handler that runs when the dragexit/dragleave events are fired.
|
||||
* @param {Function} props.onDrop The handler that runs when the drop event is fired.
|
||||
*/
|
||||
export function useDragAndDrop({ onDragOver, onDragExit, onDrop }) {
|
||||
const [element, setElement] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const noDragAndDropHandler = (event) => event.preventDefault();
|
||||
|
||||
document.addEventListener('dragover', noDragAndDropHandler);
|
||||
document.addEventListener('drop', noDragAndDropHandler);
|
||||
|
||||
element.addEventListener('dragover', onDragOver);
|
||||
element.addEventListener('dragexit', onDragExit);
|
||||
element.addEventListener('dragleave', onDragExit);
|
||||
element.addEventListener('drop', onDrop);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragover', noDragAndDropHandler);
|
||||
document.removeEventListener('drop', noDragAndDropHandler);
|
||||
|
||||
element.removeEventListener('dragover', onDragOver);
|
||||
element.removeEventListener('dragexit', onDragExit);
|
||||
element.removeEventListener('dragleave', onDragExit);
|
||||
element.removeEventListener('drop', onDrop);
|
||||
};
|
||||
}, [element, onDragOver, onDragExit, onDrop]);
|
||||
|
||||
return { setElement };
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers drag and drop events for the child element that is wrapped by this component.
|
||||
*
|
||||
* @example
|
||||
* <DragAndDropZone
|
||||
* onDragOver={someDragOverHandler}
|
||||
* onDragExit={someDragExitHandler}
|
||||
* onDrop={someDropHandler}
|
||||
* >
|
||||
* <textarea>I'm a text area</textarea>
|
||||
* <DragAndDropZone>
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {JSX.Element} props.children The React element that will register it's DOM counterpart with drag and drop events.
|
||||
* @param {Function} props.onDragOver The handler that runs when the dragover event is fired.
|
||||
* @param {Function} props.onDragExit The handler that runs when the dragexit/dragleave events are fired.
|
||||
* @param {Function} props.onDrop The handler that runs when the drop event is fired.
|
||||
*/
|
||||
export function DragAndDropZone({ children, onDragOver, onDragExit, onDrop }) {
|
||||
if (!children) {
|
||||
throw new Error(
|
||||
'The <DragAndDropZone /> component children prop is null or was not specified.',
|
||||
);
|
||||
}
|
||||
|
||||
const { setElement } = useDragAndDrop({ onDragOver, onDragExit, onDrop });
|
||||
const dropZoneRef = useRef(null);
|
||||
|
||||
if (dropZoneRef.current) {
|
||||
setElement(dropZoneRef.current);
|
||||
}
|
||||
|
||||
return cloneElement(children, {
|
||||
ref: dropZoneRef,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { generateMainImage } from '../article-form/actions';
|
||||
import { validateFileInputs } from '../packs/validateFileInputs';
|
||||
|
||||
export function dragDrop(files, handleImageSuccess, handleImageFailure) {
|
||||
if (files.length > 0 && validateFileInputs()) {
|
||||
const payload = { image: files };
|
||||
generateMainImage(payload, handleImageSuccess, handleImageFailure);
|
||||
}
|
||||
}
|
||||
|
|
@ -100,6 +100,7 @@
|
|||
"@storybook/preact": "^6.0.20",
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/preact": "^2.0.0",
|
||||
"@testing-library/preact-hooks": "^1.0.6",
|
||||
"babel-core": "^7.0.0-bridge.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^26.3.0",
|
||||
|
|
|
|||
|
|
@ -2324,6 +2324,11 @@
|
|||
lodash "^4.17.15"
|
||||
redent "^3.0.0"
|
||||
|
||||
"@testing-library/preact-hooks@^1.0.6":
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@testing-library/preact-hooks/-/preact-hooks-1.0.6.tgz#f66d1f029548ca6200c3a35ba534531671747284"
|
||||
integrity sha512-zNMnuFWqNYp/Ac5jG7o+GPsRAGnaNdHvHORFXZT7k0bseOdKJijLza1Xx+ty19+GsE/AUTYC8BCbAC67Z+sgMg==
|
||||
|
||||
"@testing-library/preact@^2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@testing-library/preact/-/preact-2.0.0.tgz#bf40faa24136a2388e74655d154fe3538541c33b"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue