Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Forms): add asyncFileHandler to Field.Upload to support async file handling during upload #4281

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Section,
Upload,
} from '@dnb/eufemia/src'
import { UploadValue } from '@dnb/eufemia/src/extensions/forms/Field/Upload'

export function createMockFile(name: string, size: number, type: string) {
const file = new File([], name, { type })
Expand All @@ -34,6 +35,46 @@ const useMockFiles = (setFiles, extend) => {
}, [])
}

export async function mockAsyncFileUpload(
newFiles: UploadValue,
): Promise<UploadValue> {
const promises = newFiles.map(async (file, index) => {
const formData = new FormData()
formData.append('file', file.file, file.file.name)

await new Promise((resolve) =>
setTimeout(resolve, Math.floor(Math.random() * 2000) + 1000),
)

const mockResponse = {
ok: (index + 2) % 2 === 0, // Every other request will fail
json: async () => ({
server_generated_id: `${file.file.name}_${crypto.randomUUID()}`,
}),
}

return await Promise.resolve(mockResponse)
.then((res) => {
if (res.ok) return res.json()
throw new Error('Unable to upload this file')
})
.then((data) => {
return {
...file,
id: data.server_generated_id,
}
})
.catch((error) => {
return {
...file,
errorMessage: error.message,
}
})
})

return await Promise.all(promises)
}

export const UploadPrefilledFileList = () => (
<ComponentBox
data-visual-test="upload-file-list"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { Flex } from '@dnb/eufemia/src'
import ComponentBox from '../../../../../../../shared/tags/ComponentBox'
import { Field, Form } from '@dnb/eufemia/src/extensions/forms'
import { createMockFile } from '../../../../../../../docs/uilib/components/upload/Examples'
import { Field, Form, Tools } from '@dnb/eufemia/src/extensions/forms'
import {
createMockFile,
mockAsyncFileUpload,
} from '../../../../../../../docs/uilib/components/upload/Examples'
import useUpload from '@dnb/eufemia/src/components/upload/useUpload'

export const BasicUsage = () => {
return (
Expand Down Expand Up @@ -78,3 +82,36 @@ export const WithPath = () => {
</ComponentBox>
)
}

export const WithAsyncFileHandler = () => {
return (
<ComponentBox scope={{ mockAsyncFileUpload, useUpload, Tools }}>
{() => {
const MyForm = () => {
return (
<Form.Handler onSubmit={async (form) => console.log(form)}>
<Flex.Stack>
<Field.Upload
id="async_upload_context_id"
path="/attachments"
labelDescription="Upload multiple files at once to see the upload error message. This demo has been set up so that every other file in a batch will fail."
asyncFileHandler={mockAsyncFileUpload}
required
/>
<Form.SubmitButton />
</Flex.Stack>
<Output />
</Form.Handler>
)
}

const Output = () => {
const { files } = useUpload('async_upload_context_id')
return <Tools.Log data={files} top />
}

return <MyForm />
}}
</ComponentBox>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@ import * as Examples from './Examples'
### Customized

<Examples.Customized />

### With asynchronous file handler

<Examples.WithAsyncFileHandler />
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,36 @@ The `value` property represents an array with an object described above:
```tsx
render(<Field.Upload value={files} />)
```

## About the `asyncFileHandler` property

The `asyncFileHandler` is an asynchronous handler function that takes newly added files as a parameter and returns a promise containing the processed files. The component will automatically handle loading states during the upload process. This feature is useful for tasks like uploading files to a virus checker, which returns a new file ID if the file passes the check. To indicate a failed upload, set the `errorMessage` on the specific file object with the desired message to display next to the file in the upload list.

```js
async function virusCheck(newFiles) {
const promises = newFiles.map(async (file) => {
const formData = new FormData()
formData.append('file', file.file, file.file.name)

return await fetch('/', { method: 'POST', body: formData })
.then((response) => {
if (response.ok) return response.json()
throw new Error('Unable to upload this file')
})
.then((data) => {
return {
...file,
id: data.server_generated_id,
}
})
.catch((error) => {
return {
...file,
errorMessage: error.message,
}
})
})

return await Promise.all(promises)
}
```
51 changes: 47 additions & 4 deletions packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export type Props = FieldHelpProps &
| 'fileMaxSize'
| 'onFileDelete'
| 'skeleton'
>
> & {
asyncFileHandler?: (newFiles: UploadValue) => Promise<UploadValue>
}

const validateRequired = (
value: UploadValue,
Expand All @@ -53,6 +55,13 @@ const validateRequired = (
return undefined
}

const updateFileLoadingState = (
files: UploadValue,
isLoading: boolean
) => {
return files.map((file) => ({ ...file, isLoading }))
}

function UploadComponent(props: Props) {
const sharedTr = useSharedTranslation().Upload
const formsTr = useFormsTranslation().Upload
Expand Down Expand Up @@ -82,6 +91,7 @@ function UploadComponent(props: Props) {
handleChange,
handleFocus,
handleBlur,
asyncFileHandler,
...rest
} = useFieldProps(preparedProps, {
executeOnChangeRegardlessOfError: true,
Expand All @@ -98,20 +108,53 @@ function UploadComponent(props: Props) {
onFileDelete,
} = rest

const { setFiles } = useUpload(id)
const { files: fileContext, setFiles } = useUpload(id)

useEffect(() => {
setFiles(value)
}, [setFiles, value])

const handleChangeAsync = useCallback(
async (files: UploadValue) => {
// Filter out existing files
const existingFileIds = fileContext?.map((file) => file.id) || []
const newFiles = files.filter(
(file) => !existingFileIds.includes(file.id)
)

if (newFiles.length > 0) {
// Set loading
setFiles([
...fileContext,
...updateFileLoadingState(newFiles, true),
])

const uploadedFiles = updateFileLoadingState(
await asyncFileHandler(newFiles),
false
)

handleChange([...fileContext, ...uploadedFiles])
} else {
handleChange(files)
}
},
[fileContext, asyncFileHandler, setFiles, updateFileLoadingState]
)

const changeHandler = useCallback(
({ files }: { files: UploadValue }) => {
// Prevents the form-status from showing up
handleBlur()
handleFocus()
handleChange(files)

if (asyncFileHandler) {
handleChangeAsync(files)
} else {
handleChange(files)
}
},
[handleBlur, handleChange, handleFocus]
[handleBlur, handleChange, handleFocus, asyncFileHandler, fileContext]
)

const width = widthProp as FieldBlockWidth
Expand Down