Skip to content

Commit

Permalink
feat(Upload): support files dropped on the document body (#1719)
Browse files Browse the repository at this point in the history
* feat(Upload): support files dropped on the document body

* Add UploadDragEvent type

* Support only one (the first one)

* Enhance handling of existing files and their ID

* Fix existing file handling when file get dropped on body
  • Loading branch information
tujoworker authored Nov 17, 2022
1 parent b504d06 commit f206243
Show file tree
Hide file tree
Showing 5 changed files with 149 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,9 @@ The "backend" receiving the files is decoupled and can be any existing or new sy
## Limit the amount of files

By default, the Upload component accepts multiple files. You can use the prop `filesAmountLimit={1}` to make the component accept only one file.

## Page wide drop support

Once the Upload component mounts, it also adds support for dropping files to the entire browser body.

**NB:** When you have several mounted components, only the first Upload component will receive the dropped files.
15 changes: 10 additions & 5 deletions packages/dnb-eufemia/src/components/upload/Upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,14 @@ const Upload = (localProps: UploadAllProps) => {

const spacingClasses = createSpacingClasses(props)

const { files, setFiles, setInternalFiles, existsInFiles } =
const { files, setFiles, setInternalFiles, getExistsingFile } =
useUpload(id)

const filesRef = React.useRef<UploadFile[]>(files)
React.useEffect(() => {
filesRef.current = files
}) // keep our ref updated on every re-render

return (
<UploadContext.Provider
value={{
Expand All @@ -89,16 +94,16 @@ const Upload = (localProps: UploadAllProps) => {
)

function onInputUpload(newFiles: UploadFile[]) {
const files = filesRef.current
const mergedFiles = [
...files,
...newFiles.map((fileItem) => {
const { file } = fileItem

if (!fileItem.id) {
fileItem.id = makeUniqueId()
}
const existingFile = getExistsingFile(file, files)

fileItem.exists = existsInFiles(file, files)
fileItem.exists = Boolean(existingFile)
fileItem.id = fileItem.exists ? existingFile.id : makeUniqueId()

return fileItem
}),
Expand Down
50 changes: 42 additions & 8 deletions packages/dnb-eufemia/src/components/upload/UploadDropzone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import HeightAnimation from '../height-animation/HeightAnimation'
import { UploadContext } from './UploadContext'
import type { UploadAllProps, UploadFile, UploadProps } from './types'

export type UploadDragEvent = React.DragEvent | DragEvent

export default function UploadDropzone({
children,
className,
Expand All @@ -15,47 +17,79 @@ export default function UploadDropzone({
const [hover, setHover] = React.useState(false)
const hoverTimeout = React.useRef<NodeJS.Timer>()

const { onInputUpload } = context
const { onInputUpload, id } = context

const getFiles = (event: React.DragEvent) => {
const getFiles = (event: UploadDragEvent) => {
const fileData = event.dataTransfer

const files: UploadFile[] = []

Array.from(fileData.files).forEach((file, i) => {
Array.from(fileData.files).forEach((file) => {
files.push({ file })
})

return files
}

const hoverHandler = (event: React.DragEvent, state: boolean) => {
const hoverHandler = (event: UploadDragEvent, state: boolean) => {
event.stopPropagation()
event.preventDefault()
clearTimers()
setHover(state)
}

const dropHandler = (event: React.DragEvent) => {
const dropHandler = (event: UploadDragEvent) => {
const files = getFiles(event)

onInputUpload(files)
hoverHandler(event, false)
}

const dragEnterHandler = (event: React.DragEvent) => {
const dragEnterHandler = (event: UploadDragEvent) => {
hoverHandler(event, true)
}

const dragLeaveHandler = (event: React.DragEvent) => {
const dragLeaveHandler = (event: UploadDragEvent) => {
hoverHandler(event, false)
}

const clearTimers = () => {
clearTimeout(hoverTimeout.current)
}

React.useEffect(() => clearTimers, [])
React.useEffect(() => {
const elem = document.body
const execute = () => {
try {
if (!elem.hasAttribute('data-upload-drop-zone')) {
const add = elem.addEventListener
add('drop', dropHandler)
add('dragover', dragEnterHandler)
add('dragleave', dragLeaveHandler)
elem.setAttribute('data-upload-drop-zone', id)
}
} catch (e) {
//
}
}
const timeoutId = setTimeout(execute, 10) // Add the listeners delayed (ms) without prioritization, in case of re-renders

return () => {
clearTimers()
clearTimeout(timeoutId)
try {
if (elem.getAttribute('data-upload-drop-zone') === id) {
const remove = elem.removeEventListener
remove('drop', dropHandler)
remove('dragover', dragEnterHandler)
remove('dragleave', dragLeaveHandler)
elem.removeAttribute('data-upload-drop-zone')
}
} catch (e) {
//
}
}
}, [])

return (
<HeightAnimation
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react'
import { fireEvent, render, waitFor } from '@testing-library/react'
import { wait } from '@testing-library/user-event/dist/utils'
import UploadDropzone from '../UploadDropzone'
import { createMockFile } from './testHelpers'
import { UploadContext } from '../UploadContext'
Expand All @@ -11,6 +12,7 @@ const defaultProps: Partial<UploadAllProps> = {
}

const defaultContext: UploadContextProps = {
id: 'unique-id',
acceptedFileTypes: ['png'],
onInputUpload: jest.fn(),
buttonText: 'upload button text',
Expand Down Expand Up @@ -51,6 +53,7 @@ describe('Upload', () => {
})

it('has drop event', () => {
defaultContext.onInputUpload = jest.fn()
render(<MockComponent {...defaultProps} />)

const dropZone = getRootElement()
Expand Down Expand Up @@ -97,4 +100,85 @@ describe('Upload', () => {
)
)
})

describe('body listeners', () => {
const getBodyElement = () => document.body

beforeEach(() => {
document.body = document.createElement('body')
})

it('has attribute while mounted', async () => {
const { unmount } = render(<MockComponent {...defaultProps} />)

await waitFor(() =>
expect(document.body.getAttribute('data-upload-drop-zone')).toBe(
'unique-id'
)
)

unmount()

expect(document.body.hasAttribute('data-upload-drop-zone')).toBe(
false
)
})

it('has drop event', async () => {
defaultContext.onInputUpload = jest.fn()
render(<MockComponent {...defaultProps} />)

const bodyDropZone = getBodyElement()
const file1 = createMockFile('fileName-1.png', 100, 'image/png')
const file2 = createMockFile('fileName-2.png', 100, 'image/png')

await wait(10)

fireEvent.drop(bodyDropZone, {
dataTransfer: { files: [file1, file2] },
})

expect(defaultContext.onInputUpload).toHaveBeenCalledTimes(1)
expect(defaultContext.onInputUpload).toHaveBeenLastCalledWith([
{ file: file1 },
{ file: file2 },
])
})

it('has "active" class on dragEnter event', async () => {
render(<MockComponent {...defaultProps} />)

const bodyDropZone = getBodyElement()

await wait(10)

fireEvent.dragOver(bodyDropZone)

expect(Array.from(getRootElement().classList)).toEqual(
expect.arrayContaining(['dnb-upload--active'])
)
})

it('has not "active" class on dragLeave event', async () => {
render(<MockComponent {...defaultProps} />)

const bodyDropZone = getBodyElement()

await wait(10)

fireEvent.dragOver(bodyDropZone)

expect(Array.from(getRootElement().classList)).toEqual(
expect.arrayContaining(['dnb-upload--active'])
)

fireEvent.dragLeave(bodyDropZone)

await waitFor(() =>
expect(Array.from(getRootElement().classList)).not.toContain(
'dnb-upload--active'
)
)
})
})
})
11 changes: 7 additions & 4 deletions packages/dnb-eufemia/src/components/upload/useUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export type useUploadReturn = {
setFiles: (files: UploadFile[]) => void
internalFiles: UploadFile[]
setInternalFiles: (files: UploadFile[]) => void
existsInFiles: (file: File, fileItems?: UploadFile[]) => boolean
getExistsingFile: (file: File, fileItems?: UploadFile[]) => UploadFile
}

/**
Expand All @@ -26,8 +26,11 @@ function useUpload(id: string): useUploadReturn {
const files = data?.files || []
const internalFiles = data?.internalFiles || []

const existsInFiles = (file: File, fileItems: UploadFile[] = files) => {
return fileItems.some(({ file: f }) => {
const getExistsingFile = (
file: File,
fileItems: UploadFile[] = files
) => {
return fileItems.find(({ file: f }) => {
return (
f.name === file.name &&
f.size === file.size &&
Expand All @@ -41,7 +44,7 @@ function useUpload(id: string): useUploadReturn {
setFiles,
internalFiles,
setInternalFiles,
existsInFiles,
getExistsingFile,
}
}

Expand Down

0 comments on commit f206243

Please sign in to comment.