Merge pull request #1394 from omnivore-app/feat/dragndrop

Feat/dragndrop
This commit is contained in:
Jackson Harper 2022-11-22 12:18:54 +08:00 committed by GitHub
commit 6afea97975
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 467 additions and 153 deletions

View file

@ -1,4 +1,6 @@
import { Box, HStack, VStack } from './../../elements/LayoutPrimitives'
import { Box, HStack, SpanBox, VStack } from './../../elements/LayoutPrimitives'
import Dropzone from 'react-dropzone'
import * as Progress from '@radix-ui/react-progress'
import type {
LibraryItem,
LibraryItemsQueryInput,
@ -13,7 +15,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { LibrarySearchBar } from './LibrarySearchBar'
import { StyledText } from '../../elements/StyledText'
import { AddLinkModal } from './AddLinkModal'
import { styled } from '../../tokens/stitches.config'
import { styled, theme } from '../../tokens/stitches.config'
import { ListLayoutIcon } from '../../elements/images/ListLayoutIcon'
import { GridLayoutIcon } from '../../elements/images/GridLayoutIcon'
import {
@ -50,6 +52,8 @@ import {
TypeaheadSearchItemsData,
typeaheadSearchQuery,
} from '../../../lib/networking/queries/typeaheadSearch'
import axios from 'axios'
import { uploadFileRequestMutation } from '../../../lib/networking/mutations/uploadFileMutation'
export type LayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT'
@ -125,8 +129,14 @@ export function HomeFeedContainer(): JSX.Element {
})
)
const { itemsPages, size, setSize, isValidating, performActionOnItem } =
useGetLibraryItemsQuery(queryInputs)
const {
itemsPages,
size,
setSize,
isValidating,
performActionOnItem,
mutate,
} = useGetLibraryItemsQuery(queryInputs)
useEffect(() => {
if (queryValue.startsWith('#')) {
@ -558,6 +568,7 @@ export function HomeFeedContainer(): JSX.Element {
<HomeFeedGrid
items={libraryItems}
actionHandler={handleCardAction}
reloadItems={mutate}
searchTerm={queryInputs.searchQuery}
gridContainerRef={gridContainerRef}
applySearchQuery={(searchQuery: string) => {
@ -611,6 +622,7 @@ export function HomeFeedContainer(): JSX.Element {
type HomeFeedContentProps = {
items: LibraryItem[]
searchTerm?: string
reloadItems: () => void
gridContainerRef: React.RefObject<HTMLDivElement>
applySearchQuery: (searchQuery: string) => void
hasMore: boolean
@ -683,6 +695,20 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
},
})
const DragnDropStyle = styled('div', {
border: '3px dashed gray',
backgroundColor: 'aliceblue',
borderRadius: '5px',
width: '95%',
height: '80%',
position: 'absolute',
opacity: '0.9',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: '1',
})
const removeItem = () => {
if (!props.linkToRemove) {
return
@ -702,6 +728,51 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
setShowUnsubscribeConfirmation(false)
}
const [uploadingFiles, setUploadingFiles] = useState([])
const [inDragOperation, setInDragOperation] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const handleDrop = async (acceptedFiles: any) => {
setInDragOperation(false)
setUploadingFiles(acceptedFiles.map((file: { name: any }) => file.name))
for (const file of acceptedFiles) {
try {
const request = await uploadFileRequestMutation({
// This will tell the backend not to save the URL
// and give it the local filename as the title.
url: `file://local/${file.path}`,
contentType: file.type,
createPageEntry: true,
})
if (!request?.uploadSignedUrl) {
throw 'No upload URL available'
}
const uploadResult = await axios.request({
method: 'PUT',
url: request?.uploadSignedUrl,
data: file,
withCredentials: false,
headers: {
'Content-Type': 'application/pdf',
},
onUploadProgress: (p) => {
console.log('upload progress: ', (p.loaded / p.total) * 100)
setUploadProgress((p.loaded / p.total) * 100)
},
})
console.log('result of uploading: ', uploadResult)
} catch (error) {
console.log('ERROR', error)
}
}
setUploadingFiles([])
props.reloadItems()
}
return (
<>
<VStack
@ -715,6 +786,7 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
}}
>
<Toaster />
{props.isValidating && props.items.length == 0 && <TopBarProgress />}
<HStack alignment="center" distribution="start" css={{ width: '100%' }}>
<StyledText
@ -766,6 +838,7 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
searchTerm={props.searchTerm}
applySearchQuery={props.applySearchQuery}
/>
{viewerData?.me && (
<Box
css={{
@ -813,112 +886,185 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element {
})}
</Box>
)}
{!props.isValidating && props.items.length == 0 ? (
<EmptyLibrary
onAddLinkClicked={() => {
props.setShowAddLinkModal(true)
}}
/>
) : (
<Box
ref={props.gridContainerRef}
css={{
py: '$3',
display: 'grid',
width: '100%',
gridAutoRows: 'auto',
borderRadius: '8px',
gridGap: layout == 'LIST_LAYOUT' ? '0' : '$3',
marginTop: layout == 'LIST_LAYOUT' ? '21px' : '0',
marginBottom: '0px',
paddingTop: layout == 'LIST_LAYOUT' ? '0' : '21px',
paddingBottom: layout == 'LIST_LAYOUT' ? '0px' : '21px',
overflow: 'hidden',
'@smDown': {
border: 'unset',
width: layout == 'LIST_LAYOUT' ? '100vw' : undefined,
margin: layout == 'LIST_LAYOUT' ? '16px -16px' : undefined,
borderRadius: layout == 'LIST_LAYOUT' ? 0 : undefined,
},
'@md': {
gridTemplateColumns:
layout == 'LIST_LAYOUT' ? 'none' : '1fr 1fr',
},
'@lg': {
gridTemplateColumns:
layout == 'LIST_LAYOUT' ? 'none' : 'repeat(3, 1fr)',
},
}}
>
{props.items.map((linkedItem) => (
<Box
className="linkedItemCard"
data-testid="linkedItemCard"
id={linkedItem.node.id}
tabIndex={0}
key={linkedItem.node.id}
css={{
width: '100%',
'&> div': {
bg: '$grayBg',
},
'&:focus': {
'> div': {
bg: '$grayBgActive',
},
},
'&:hover': {
'> div': {
bg: '$grayBgActive',
},
},
}}
>
{viewerData?.me && (
<LinkedItemCard
layout={layout}
item={linkedItem.node}
viewer={viewerData.me}
handleAction={(action: LinkedItemCardAction) => {
if (action === 'delete') {
setShowRemoveLinkConfirmation(true)
props.setLinkToRemove(linkedItem)
} else if (action === 'editTitle') {
props.setShowEditTitleModal(true)
props.setLinkToEdit(linkedItem)
} else if (action == 'unsubscribe') {
setShowUnsubscribeConfirmation(true)
props.setLinkToUnsubscribe(linkedItem)
} else {
props.actionHandler(action, linkedItem)
}
}}
/>
)}
</Box>
))}
</Box>
)}
<HStack
distribution="center"
css={{ width: '100%', mt: '$2', mb: '$4' }}
<Dropzone
onDrop={handleDrop}
onDragEnter={() => {
setInDragOperation(true)
}}
onDragLeave={() => {
setInDragOperation(false)
}}
preventDropOnDocument={true}
noClick={true}
>
{props.hasMore ? (
<Button
style="ctaGray"
css={{
cursor: props.isValidating ? 'not-allowed' : 'pointer',
}}
onClick={props.loadMore}
disabled={props.isValidating}
>
{props.isValidating ? 'Loading' : 'Load More'}
</Button>
) : (
<StyledText style="caption"></StyledText>
{({ getRootProps, getInputProps, acceptedFiles, fileRejections }) => (
<div {...getRootProps({ className: 'dropzone' })}>
{inDragOperation && uploadingFiles.length < 1 && (
<DragnDropStyle>
<Box
css={{
color: '$utilityTextDefault',
fontWeight: '800',
fontSize: '$4',
}}
>
Drop PDF document here to add to your library
</Box>
</DragnDropStyle>
)}
{uploadingFiles.length > 0 && (
<DragnDropStyle>
<Box
css={{
color: '$utilityTextDefault',
fontWeight: '800',
fontSize: '$4',
width: '80%',
}}
>
<Progress.Root
className="ProgressRoot"
value={uploadProgress}
>
<Progress.Indicator
className="ProgressIndicator"
style={{
transform: `translateX(-${100 - uploadProgress}%)`,
}}
/>
</Progress.Root>
<StyledText
style="boldText"
css={{
color: theme.colors.omnivoreGray.toString(),
}}
>
Uploading file
</StyledText>
</Box>
</DragnDropStyle>
)}
<input {...getInputProps()} />
{!props.isValidating && props.items.length == 0 ? (
<EmptyLibrary
onAddLinkClicked={() => {
props.setShowAddLinkModal(true)
}}
/>
) : (
<Box
ref={props.gridContainerRef}
css={{
py: '$3',
display: 'grid',
width: '100%',
gridAutoRows: 'auto',
borderRadius: '8px',
gridGap: layout == 'LIST_LAYOUT' ? '0' : '$3',
marginTop: layout == 'LIST_LAYOUT' ? '21px' : '0',
marginBottom: '0px',
paddingTop: layout == 'LIST_LAYOUT' ? '0' : '21px',
paddingBottom: layout == 'LIST_LAYOUT' ? '0px' : '21px',
overflow: 'hidden',
'@smDown': {
border: 'unset',
width: layout == 'LIST_LAYOUT' ? '100vw' : undefined,
margin:
layout == 'LIST_LAYOUT' ? '16px -16px' : undefined,
borderRadius: layout == 'LIST_LAYOUT' ? 0 : undefined,
},
'@md': {
gridTemplateColumns:
layout == 'LIST_LAYOUT' ? 'none' : '1fr 1fr',
},
'@lg': {
gridTemplateColumns:
layout == 'LIST_LAYOUT' ? 'none' : 'repeat(3, 1fr)',
},
}}
>
{props.items.map((linkedItem) => (
<Box
className="linkedItemCard"
data-testid="linkedItemCard"
id={linkedItem.node.id}
tabIndex={0}
key={linkedItem.node.id}
css={{
width: '100%',
'&> div': {
bg: '$grayBg',
},
'&:focus': {
'> div': {
bg: '$grayBgActive',
},
},
'&:hover': {
'> div': {
bg: '$grayBgActive',
},
},
}}
>
{viewerData?.me && (
<LinkedItemCard
layout={layout}
item={linkedItem.node}
viewer={viewerData.me}
handleAction={(action: LinkedItemCardAction) => {
if (action === 'delete') {
setShowRemoveLinkConfirmation(true)
props.setLinkToRemove(linkedItem)
} else if (action === 'editTitle') {
props.setShowEditTitleModal(true)
props.setLinkToEdit(linkedItem)
} else if (action == 'unsubscribe') {
setShowUnsubscribeConfirmation(true)
props.setLinkToUnsubscribe(linkedItem)
} else {
props.actionHandler(action, linkedItem)
}
}}
/>
)}
</Box>
))}
</Box>
)}
<HStack
distribution="center"
css={{ width: '100%', mt: '$2', mb: '$4' }}
>
{props.hasMore ? (
<Button
style="ctaGray"
css={{
cursor: props.isValidating ? 'not-allowed' : 'pointer',
}}
onClick={props.loadMore}
disabled={props.isValidating}
>
{props.isValidating ? 'Loading' : 'Load More'}
</Button>
) : (
<StyledText style="caption"></StyledText>
)}
</HStack>
</div>
)}
</HStack>
</Dropzone>
</VStack>
{/* Temporary code */}
{/* <div>
<strong>Files:</strong>
<ul>
{uploadingFiles.map((fileName) => (
<li key={fileName}>{fileName}</li>
))}
</ul>
</div> */}
{/* Temporary code */}
{props.showAddLinkModal && (
<AddLinkModal onOpenChange={() => props.setShowAddLinkModal(false)} />
)}

View file

@ -1,7 +1,8 @@
import { useMemo, useState } from 'react'
import Dropzone from 'react-dropzone'
import { Box } from '../../elements/LayoutPrimitives'
import { useGetViewerQuery } from '../../../lib/networking/queries/useGetViewerQuery'
import { useGetUserPreferences } from '../../../lib/networking/queries/useGetUserPreferences'
import { useMemo } from 'react'
import { useGetLibraryItemsQuery } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
import { LinkedItemCardAction } from '../../patterns/LibraryCards/CardTypes'
import { LibraryGridCard } from '../../patterns/LibraryCards/LibraryGridCard'
@ -9,7 +10,6 @@ import { LayoutCoordinator } from './LibraryContainer'
import { EmptyLibrary } from '../homeFeed/EmptyLibrary'
import Masonry from 'react-masonry-css'
export type LibraryListProps = {
layoutCoordinator: LayoutCoordinator
}
@ -28,6 +28,15 @@ export function LibraryList(props: LibraryListProps): JSX.Element {
const { itemsPages, size, setSize, isValidating, performActionOnItem } =
useGetLibraryItemsQuery(defaultQuery)
const [fileNames, setFileNames] = useState([])
const [inDragOperation, setInDragOperation] = useState(false)
const [uploadingFiles, setUploadingFiles] = useState([])
const handleDrop = (acceptedFiles: any) => {
setFileNames(acceptedFiles.map((file: { name: any }) => file.name))
setUploadingFiles(acceptedFiles.map((file: { name: any }) => file.name))
}
const libraryItems = useMemo(() => {
const items =
itemsPages?.flatMap((ad) => {
@ -45,57 +54,116 @@ export function LibraryList(props: LibraryListProps): JSX.Element {
/>
)
}
console.log(fileNames)
return (
<Box css={{ overflowY: 'scroll' }}>
<Masonry
breakpointCols={props.layoutCoordinator.layout == 'LIST_LAYOUT' ? 1 : {
default: 3,
1200: 2,
992: 1
}}
className="omnivore-masonry-grid"
columnClassName="omnivore-masonry-grid_column"
>
{libraryItems.map((linkedItem) => (
{inDragOperation && uploadingFiles.length < 1 && (
<Box
css={{
border: '3px dashed gray',
backgroundColor: 'aliceblue',
borderRadius: '5px',
width: '75%',
height: '70%',
position: 'absolute',
opacity: '0.8',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Box
className="linkedItemCard"
data-testid="linkedItemCard"
id={linkedItem.node.id}
tabIndex={0}
key={linkedItem.node.id}
css={{
width: '100%',
'&> div': {
bg: '$libraryBackground',
},
'&:focus': {
'> div': {
bg: '$grayBgActive',
},
},
'&:hover': {
'> div': {
bg: '$grayBgActive',
},
},
color: '$utilityTextDefault',
fontWeight: '800',
fontSize: '$4',
}}
>
{viewerData?.me && (
<LibraryGridCard
layout={props.layoutCoordinator.layout}
item={linkedItem.node}
viewer={viewerData.me}
handleAction={(action: LinkedItemCardAction) => {
console.log('card clicked')
}}
/>
)}
Drag n drop files here
</Box>
))}
</Masonry>
</Box>
)}
<Dropzone
onDrop={handleDrop}
preventDropOnDocument={true}
onDragEnter={() => {
setInDragOperation(true)
}}
onDragLeave={() => {
setInDragOperation(false)
}}
noClick={true}
noDragEventsBubbling={true}
>
{({ getRootProps, getInputProps, acceptedFiles, fileRejections }) => (
<Box {...getRootProps({ className: 'dropzone' })}>
<input {...getInputProps()} />
<Masonry
breakpointCols={
props.layoutCoordinator.layout == 'LIST_LAYOUT'
? 1
: {
default: 3,
1200: 2,
992: 1,
}
}
className="omnivore-masonry-grid"
columnClassName="omnivore-masonry-grid_column"
>
{libraryItems.map((linkedItem) => (
<Box
className="linkedItemCard"
data-testid="linkedItemCard"
id={linkedItem.node.id}
tabIndex={0}
key={linkedItem.node.id}
css={{
width: '100%',
'&> div': {
bg: '$libraryBackground',
},
'&:focus': {
'> div': {
bg: '$grayBgActive',
},
},
'&:hover': {
'> div': {
bg: '$grayBgActive',
},
},
}}
>
{viewerData?.me && (
<LibraryGridCard
layout={props.layoutCoordinator.layout}
item={linkedItem.node}
viewer={viewerData.me}
handleAction={(action: LinkedItemCardAction) => {
console.log('card clicked')
}}
/>
)}
</Box>
))}
</Masonry>
</Box>
)}
</Dropzone>
{/* Temporary code */}
<div>
<strong>Files:</strong>
<ul>
{fileNames.map((fileName) => (
<li key={fileName}>{fileName}</li>
))}
</ul>
</div>{' '}
{/* Temporary code */}
{/* Extra padding at bottom to give space for scrolling */}
<Box css={{ width: '100%', height: '200px' }} />
</Box>
)
}
}

View file

@ -0,0 +1,55 @@
import { gqlFetcher } from '../networkHelpers'
import { v4 as uuidv4 } from 'uuid'
type UploadFileInput = {
url: string
contentType: string
createPageEntry?: boolean
clientRequestId?: string
}
type UploadFileOutput = {
jobId?: string
url?: string
clientRequestId?: string
}
type UploadFileResponseData = {
uploadFileRequest?: UploadFileData
errorCodes?: unknown[]
}
type UploadFileData = {
id: string
uploadSignedUrl: string
}
export async function uploadFileRequestMutation(
input: UploadFileInput
): Promise<UploadFileData | undefined> {
const mutation = `
mutation UploadFileRequest($input: UploadFileRequestInput!) {
uploadFileRequest(input:$input) {
... on UploadFileRequestError {
errorCodes
}
... on UploadFileRequestSuccess {
id
uploadSignedUrl
}
}
}`
if (!input.clientRequestId) {
input.clientRequestId = uuidv4()
}
const data = await gqlFetcher(mutation, { input })
const output = data as UploadFileResponseData | undefined
const error = output?.errorCodes?.find(() => true)
if (error) {
throw error
}
return output?.uploadFileRequest
}

View file

@ -27,6 +27,7 @@ type LibraryItemsQueryResponse = {
size: number | ((_size: number) => number)
) => Promise<unknown[] | undefined>
performActionOnItem: (action: LibraryItemAction, item: LibraryItem) => void
mutate: () => void
}
type LibraryItemAction =
@ -352,5 +353,6 @@ export function useGetLibraryItemsQuery({
performActionOnItem,
size,
setSize,
mutate
}
}

View file

@ -24,6 +24,7 @@
"@radix-ui/react-dropdown-menu": "^0.1.6",
"@radix-ui/react-id": "^0.1.1",
"@radix-ui/react-popover": "^0.1.1",
"@radix-ui/react-progress": "^1.0.1",
"@radix-ui/react-separator": "^0.1.0",
"@radix-ui/react-tooltip": "^0.1.7",
"@segment/analytics-next": "^1.33.5",
@ -43,6 +44,7 @@
"react-apple-login": "^1.1.3",
"react-colorful": "^5.5.1",
"react-dom": "^17.0.2",
"react-dropzone": "^14.2.3",
"react-hot-toast": "^2.1.1",
"react-masonry-css": "^1.0.16",
"react-pro-sidebar": "^0.7.1",

View file

@ -376,4 +376,19 @@ button {
/* .omnivore-masonry-grid_column > div {
background: grey;
margin-bottom: 16px;
} */
} */
.ProgressRoot {
overflow: hidden;
background: var(--colors-omnivoreGray);
border-radius: 99999px;
height: 25px;
transform: translateZ(0);
}
.ProgressIndicator {
background-color: var(--colors-omnivoreCtaYellow) ;
width: 100%;
height: 100%;
transition: transform 660ms cubic-bezier(0.65, 0, 0.35, 1);
}

View file

@ -5172,6 +5172,11 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@ramonak/react-progress-bar@^5.0.3":
version "5.0.3"
resolved "https://registry.yarnpkg.com/@ramonak/react-progress-bar/-/react-progress-bar-5.0.3.tgz#a3518fb19e6650e593a208dd429dca7cb6b63d52"
integrity sha512-VxXGKN74q94jYoeYuFNJm3xvWhVz9dy+alFZ8S4ZmTTr/05CCq9PjwthT8JB27UdAvn8pHvKBmemV8JU2cZi6A==
"@reach/observe-rect@^1.1.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2"
@ -9907,6 +9912,11 @@ atob@^2.1.2:
resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
attr-accept@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b"
integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==
auto-bind@~4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/auto-bind/-/auto-bind-4.0.0.tgz#e3589fc6c2da8f7ca43ba9f84fa52a744fc997fb"
@ -14178,6 +14188,13 @@ file-loader@^6.2.0:
loader-utils "^2.0.0"
schema-utils "^3.0.0"
file-selector@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.6.0.tgz#fa0a8d9007b829504db4d07dd4de0310b65287dc"
integrity sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==
dependencies:
tslib "^2.4.0"
file-system-cache@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/file-system-cache/-/file-system-cache-1.0.5.tgz#84259b36a2bbb8d3d6eb1021d3132ffe64cfff4f"
@ -21650,7 +21667,7 @@ promzard@^0.3.0:
dependencies:
read "1"
prop-types@^15.0.0, prop-types@^15.6.0:
prop-types@^15.0.0, prop-types@^15.6.0, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@ -22206,6 +22223,15 @@ react-draggable@^4.4.3:
clsx "^1.1.1"
prop-types "^15.6.0"
react-dropzone@^14.2.3:
version "14.2.3"
resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.2.3.tgz#0acab68308fda2d54d1273a1e626264e13d4e84b"
integrity sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==
dependencies:
attr-accept "^2.2.2"
file-selector "^0.6.0"
prop-types "^15.8.1"
react-element-to-jsx-string@^14.3.4:
version "14.3.4"
resolved "https://registry.yarnpkg.com/react-element-to-jsx-string/-/react-element-to-jsx-string-14.3.4.tgz#709125bc72f06800b68f9f4db485f2c7d31218a8"