diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/upload/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/Examples.tsx index 349c14ec012..ed3ce133a2d 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/upload/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/Examples.tsx @@ -12,7 +12,6 @@ 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 }) @@ -35,46 +34,6 @@ const useMockFiles = (setFiles, extend) => { }, []) } -export async function mockAsyncFileUpload( - newFiles: UploadValue, -): Promise { - 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 = () => ( { return ( @@ -85,7 +84,7 @@ export const WithPath = () => { export const WithAsyncFileHandler = () => { return ( - + {() => { const MyForm = () => { return ( @@ -95,7 +94,7 @@ export const WithAsyncFileHandler = () => { 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} + fileHandler={mockAsyncFileUpload} required /> @@ -105,6 +104,47 @@ export const WithAsyncFileHandler = () => { ) } + async function mockAsyncFileUpload( + newFiles: UploadValue, + ): Promise { + const updatedFiles: UploadValue = [] + + for (const [, file] of Object.entries(newFiles)) { + const formData = new FormData() + formData.append('file', file.file, file.file.name) + + const request = createRequest() + await request(Math.floor(Math.random() * 2000) + 1000) // Simulate a request + + try { + const mockResponse = { + ok: false, // Fails virus check + json: async () => ({ + server_generated_id: + file.file.name + '_' + crypto.randomUUID(), + }), + } + + if (!mockResponse.ok) { + throw new Error('Unable to upload this file') + } + + const data = await mockResponse.json() + updatedFiles.push({ + ...file, + id: data.server_generated_id, + }) + } catch (error) { + updatedFiles.push({ + ...file, + errorMessage: error.message, + }) + } + } + + return updatedFiles + } + const Output = () => { const { files } = useUpload('async_upload_context_id') return @@ -115,3 +155,44 @@ export const WithAsyncFileHandler = () => { ) } + +export const WithSyncFileHandler = () => { + return ( + + {() => { + const MyForm = () => { + return ( + console.log(form)}> + + + + + + + ) + } + + function mockSyncFileUpload(newFiles: UploadValue) { + return newFiles.map((file) => { + if (file.file.name.length > 5) { + file.errorMessage = 'File length is too long' + } + return file + }) + } + + const Output = () => { + const { files } = useUpload('sync_upload_context_id') + return + } + + return + }} + + ) +} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/demos.mdx index d8cce0a083d..f7ce6eed8eb 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/demos.mdx @@ -28,4 +28,12 @@ import * as Examples from './Examples' ### With asynchronous file handler +The `fileHandler` property supports an asynchronous function, and can be used for handling/validating files asynchronously, like to upload files to a virus checker and display errors based on the outcome: + + +### With synchronous file handler + +The `fileHandler` property supports a synchronous function, and can be used for handling/validating files synchronously, like to check for file names that's too long: + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/info.mdx index 2c5ac985757..f67dc9d6114 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/info.mdx @@ -59,9 +59,10 @@ The `value` property represents an array with an object described above: render() ``` -## About the `asyncFileHandler` property +## About the `fileHandler` 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. +The `fileHandler` is a handler function that supports both an asynchronous and synchronous function. It takes newly added files as a parameter and returns processed files (a promise when asynchronous). +The component will automatically handle asynchronous 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) { diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx index 8430e563aba..b2ced390916 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx @@ -40,7 +40,9 @@ export type Props = Omit< | 'onFileDelete' | 'skeleton' > & { - asyncFileHandler?: (newFiles: UploadValue) => Promise + fileHandler?: ( + newFiles: UploadValue + ) => UploadValue | Promise } const validateRequired = ( @@ -62,7 +64,7 @@ const validateRequired = ( const updateFileLoadingState = ( files: UploadValue, - isLoading: boolean + { isLoading } = { isLoading: false } ) => { return files.map((file) => ({ ...file, isLoading })) } @@ -96,7 +98,7 @@ function UploadComponent(props: Props) { handleChange, handleFocus, handleBlur, - asyncFileHandler, + fileHandler, ...rest } = useFieldProps(preparedProps, { executeOnChangeRegardlessOfError: true, @@ -131,20 +133,21 @@ function UploadComponent(props: Props) { // Set loading setFiles([ ...fileContext, - ...updateFileLoadingState(newFiles, true), + ...updateFileLoadingState(newFiles, { isLoading: true }), ]) const uploadedFiles = updateFileLoadingState( - await asyncFileHandler(newFiles), - false + await fileHandler(newFiles), + { isLoading: false } ) + // Set error, if any handleChange([...fileContext, ...uploadedFiles]) } else { handleChange(files) } }, - [fileContext, asyncFileHandler, setFiles, updateFileLoadingState] + [fileContext, setFiles, fileHandler, handleChange] ) const changeHandler = useCallback( @@ -153,13 +156,13 @@ function UploadComponent(props: Props) { handleBlur() handleFocus() - if (asyncFileHandler) { + if (fileHandler) { handleChangeAsync(files) } else { handleChange(files) } }, - [handleBlur, handleChange, handleFocus, asyncFileHandler, fileContext] + [handleBlur, handleFocus, fileHandler, handleChangeAsync, handleChange] ) const width = widthProp as FieldBlockWidth diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/UploadDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/UploadDocs.ts index 84fb5c9dda2..9563077829b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/UploadDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/UploadDocs.ts @@ -5,8 +5,8 @@ import { import { PropertiesTableProps } from '../../../../shared/types' export const UploadFieldProperties: PropertiesTableProps = { - asyncFileHandler: { - doc: 'Asynchronous handler function that takes newly added files (`newFiles: UploadValue`) as a parameter and returns a promise containing the processed files (`Promise`).', + fileHandler: { + doc: 'File handler function that takes newly added files (`newFiles: UploadValue`) as a parameter and returns the processed files. The function can either be synchronous or asynchronous. It returns a promise (`Promise`) containing the processed files when asynchronous.', type: 'function', status: 'optional', }, diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/__tests__/Upload.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/__tests__/Upload.test.tsx index 9b611945513..fddb679040b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/__tests__/Upload.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/__tests__/Upload.test.tsx @@ -905,7 +905,51 @@ describe('Field.Upload', () => { ).toHaveTextContent(nbForms.Upload.errorRequired) }) - it('should handle displaying error from asyncFileHandler', async () => { + it('should handle displaying error from fileHandler with sync function', async () => { + const fileValid = createMockFile('1.png', 100, 'image/png') + const fileInValid = createMockFile('invalid.png', 100, 'image/png') + + const syncFileHandlerFnError = function mockSyncFileUpload( + newFiles: UploadValue + ) { + return newFiles.map((file) => { + if (file.file.name.length > 5) { + file.errorMessage = 'File length is too long' + } + return file + }) + } + + render() + + const element = getRootElement() + + await waitFor(() => + fireEvent.drop(element, { + dataTransfer: { + files: [fileValid], + }, + }) + ) + + expect( + document.querySelector('.dnb-form-status') + ).not.toBeInTheDocument() + + await waitFor(() => + fireEvent.drop(element, { + dataTransfer: { + files: [fileInValid], + }, + }) + ) + + expect(document.querySelector('.dnb-form-status')).toHaveTextContent( + 'File length is too long' + ) + }) + + it('should handle displaying error from fileHandler with async function', async () => { const file = createMockFile('fileName-1.png', 100, 'image/png') const asyncValidatorResolvingWithErrorMessage = () => @@ -928,7 +972,7 @@ describe('Field.Upload', () => { asyncValidatorResolvingWithErrorMessage ) - render() + render() const element = getRootElement() @@ -949,7 +993,7 @@ describe('Field.Upload', () => { }) }) - it('should handle displaying success from asyncFileHandler', async () => { + it('should handle displaying success from fileHandler with async function', async () => { const file = createMockFile('fileName-1.png', 100, 'image/png') const asyncValidatorResolvingWithSuccess = () => @@ -971,7 +1015,7 @@ describe('Field.Upload', () => { asyncValidatorResolvingWithSuccess ) - render() + render() const element = getRootElement() @@ -992,16 +1036,14 @@ describe('Field.Upload', () => { }) }) - it('should display spinner when loading asyncFileHandler', async () => { + it('should display spinner when loading fileHandler with async function', async () => { const file = createMockFile('fileName-1.png', 100, 'image/png') const asyncValidatorResolvingWithSuccess = () => new Promise(() => jest.fn()) render( - + ) const element = getRootElement() @@ -1028,7 +1070,7 @@ describe('Field.Upload', () => { }) }) - it('should add new files from asyncFileHandler', async () => { + it('should add new files from fileHandler with async function', async () => { const fileExisting = createMockFile( 'fileName-existing.png', 100, @@ -1071,7 +1113,7 @@ describe('Field.Upload', () => { render( ) @@ -1112,7 +1154,7 @@ describe('Field.Upload', () => { }) }) - it('should not add existing file using asyncFileHandler', async () => { + it('should not add existing file using fileHandler with async function', async () => { const file = createMockFile('fileName.png', 100, 'image/png') const asyncValidatorResolvingWithSuccess = () => @@ -1134,7 +1176,7 @@ describe('Field.Upload', () => { render( ) @@ -1160,7 +1202,7 @@ describe('Field.Upload', () => { }) }) - it('should handle a mix of successful and failed files in asyncFileHandler', async () => { + it('should handle a mix of successful and failed files in fileHandler with async function', async () => { const successFile = createMockFile('successFile.png', 100, 'image/png') const failFile = createMockFile('failFile.png', 100, 'image/png') @@ -1187,7 +1229,7 @@ describe('Field.Upload', () => { const asyncFileHandlerFn = jest.fn(asyncValidatorWithMixedResults) - render() + render() const element = getRootElement() diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/stories/Upload.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/stories/Upload.stories.tsx index fc72908ae49..263b6a86106 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/stories/Upload.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/stories/Upload.stories.tsx @@ -1,27 +1,19 @@ -import { Field, Form } from '../../..' +import { Field, Form, Tools } from '../../..' import { Flex } from '../../../../../components' -// import { createMockFile } from '../../../../../components/upload/__tests__/testHelpers' +import useUpload from '../../../../../components/upload/useUpload' +import { createRequest } from '../../../Form/Handler/stories/FormHandler.stories' +import { UploadValue } from '../Upload' export default { title: 'Eufemia/Extensions/Forms/Upload', } export function Upload() { - // const { setFiles } = OriginalUpload.useUpload('unique-id') - - // React.useEffect(() => { - // setFiles([ - // { file: createMockFile('fileName-1.png', 100, 'image/png') }, - // ]) - // }, [setFiles]) - return ( { console.log('global onChange', data) @@ -47,3 +39,65 @@ export function Upload() { ) } + +async function mockAsyncFileUpload__withoutPromises( + newFiles: UploadValue +): Promise { + const updatedFiles: UploadValue = [] + + for (const [index, file] of Object.entries(newFiles)) { + const formData = new FormData() + formData.append('file', file.file, file.file.name) + + const request = createRequest() + await request(Math.floor(Math.random() * 2000) + 1000) // Simulate a request + + try { + const mockResponse = { + ok: (parseFloat(index) + 2) % 2 === 0, // Every other request will fail + json: async () => ({ + server_generated_id: `${file.file.name}_${crypto.randomUUID()}`, + }), + } + + if (!mockResponse.ok) { + throw new Error('Unable to upload this file') + } + + const data = await mockResponse.json() + updatedFiles.push({ + ...file, + id: data.server_generated_id, + }) + } catch (error: any) { + updatedFiles.push({ + ...file, + errorMessage: error.message, + }) + } + } + + return updatedFiles +} + +const Output = () => { + const { files } = useUpload('async_upload_context_id') + return +} +export const WithAsyncFileHandler = () => { + return ( + console.log(form)}> + + + + + + + ) +}