diff --git a/app/javascript/article-form/actions.js b/app/javascript/article-form/actions.js index 494dfa92f..3e06c9272 100644 --- a/app/javascript/article-form/actions.js +++ b/app/javascript/article-form/actions.js @@ -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); + } +} diff --git a/app/javascript/article-form/components/ArticleCoverImage.jsx b/app/javascript/article-form/components/ArticleCoverImage.jsx index 8caa0db7b..76c6cc2d3 100644 --- a/app/javascript/article-form/components/ArticleCoverImage.jsx +++ b/app/javascript/article-form/components/ArticleCoverImage.jsx @@ -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 ( -
- {!uploadingImage && mainImage && ( - Post cover - )} -
- {uploadingImage ? ( - - Uploading... - - ) : ( - - - {mainImage && ( - - )} - + {mainImage && ( + + )} + + )} +
+ {uploadError && ( +

{uploadErrorMessage}

)}
- {uploadError && ( -

{uploadErrorMessage}

- )} - + ); } } diff --git a/app/javascript/article-form/components/EditorBody.jsx b/app/javascript/article-form/components/EditorBody.jsx index 7ba79e3eb..3e5c55996 100644 --- a/app/javascript/article-form/components/EditorBody.jsx +++ b/app/javascript/article-form/components/EditorBody.jsx @@ -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 = `![${altText}](${links[0]})\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 (
); diff --git a/app/javascript/article-form/components/Help.jsx b/app/javascript/article-form/components/Help.jsx index c37f4b301..11ae3906e 100644 --- a/app/javascript/article-form/components/Help.jsx +++ b/app/javascript/article-form/components/Help.jsx @@ -235,6 +235,10 @@ export class Help extends Component { {' '} to add rich content such as Tweets, YouTube videos, etc. +
  • + In addition to images for the post's content, you can also drag and + drop a cover image +
  • ); diff --git a/app/javascript/article-form/components/__tests__/ArticleCoverImage.test.jsx b/app/javascript/article-form/components/__tests__/ArticleCoverImage.test.jsx index a627e7006..0ffdbd1cc 100644 --- a/app/javascript/article-form/components/__tests__/ArticleCoverImage.test.jsx +++ b/app/javascript/article-form/components/__tests__/ArticleCoverImage.test.jsx @@ -79,6 +79,8 @@ describe('', () => { }); it('allows a user to change the image', async () => { + global.DragEvent = jest.fn(); + fetch.mockResponse( JSON.stringify({ image: ['/i/changed-fake-link.jpg'], diff --git a/app/javascript/article-form/components/__tests__/dragAndDropHelpers.test.js b/app/javascript/article-form/components/__tests__/dragAndDropHelpers.test.js new file mode 100644 index 000000000..4a17888e4 --- /dev/null +++ b/app/javascript/article-form/components/__tests__/dragAndDropHelpers.test.js @@ -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); + }); + }); +}); diff --git a/app/javascript/article-form/components/dragAndDropHelpers.js b/app/javascript/article-form/components/dragAndDropHelpers.js new file mode 100644 index 000000000..e9916c328 --- /dev/null +++ b/app/javascript/article-form/components/dragAndDropHelpers.js @@ -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, + }); +} diff --git a/app/javascript/chat/chat.jsx b/app/javascript/chat/chat.jsx index 0b97c7f44..1d9e6c388 100644 --- a/app/javascript/chat/chat.jsx +++ b/app/javascript/chat/chat.jsx @@ -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 {
    {channelHeader} -
    { - this.scroller = scroller; - }} - id="messagelist" - onDragOver={this.handleDragHover} + - {this.renderMessages()} -
    -
    +
    { + this.scroller = scroller; + }} + id="messagelist" + > + {this.renderMessages()} +
    +
    +
    { 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(, snackZone); + } + getUserDataAndCsrfToken().then(({ currentUser, csrfToken }) => { window.currentUser = currentUser; window.csrfToken = csrfToken; diff --git a/app/javascript/utilities/__tests__/dragAndDrop.test.js b/app/javascript/utilities/__tests__/dragAndDrop.test.js new file mode 100644 index 000000000..0b1f1f582 --- /dev/null +++ b/app/javascript/utilities/__tests__/dragAndDrop.test.js @@ -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('', () => { + 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( + + + , + ); + + // Rerendering so that the ref gets set to the DOM node. + // represented by the Preact element + , + ); + + 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( + + + , + ); + + // Rerendering so that the ref gets set to the DOM node. + // represented by the Preact element + , + ); + + 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( + , + ); + }).toThrowError( + 'The component children prop is null or was not specified.', + ); + }); + }); +}); diff --git a/app/javascript/utilities/dragAndDrop.js b/app/javascript/utilities/dragAndDrop.js new file mode 100644 index 000000000..f74b1bea9 --- /dev/null +++ b/app/javascript/utilities/dragAndDrop.js @@ -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 ; + * }; + * + * @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 + * + * + * + * + * @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 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, + }); +} diff --git a/app/javascript/utilities/dragAndUpload.js b/app/javascript/utilities/dragAndUpload.js deleted file mode 100644 index 7c35d47fa..000000000 --- a/app/javascript/utilities/dragAndUpload.js +++ /dev/null @@ -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); - } -} diff --git a/package.json b/package.json index 4d4742efc..927483a3b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 7c6d6def8..a1c1648af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"