diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/upload.md b/packages/dnb-design-system-portal/src/docs/uilib/components/upload.md new file mode 100644 index 00000000000..ede9be6478b --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/upload.md @@ -0,0 +1,14 @@ +--- +title: 'Upload' +description: 'The Upload widget should be used in scenarios where the customer has to upload files. Files can be uploaded by clicking button. You also have the opportunity to add descriptive texts below the title where you could put max file size, allowed fileformats etc.' +status: 'new' +showTabs: true +hideTabs: + - title: Events +--- + +import UploadInfo from 'Docs/uilib/components/upload/info' +import UploadDemos from 'Docs/uilib/components/upload/demos' + + + 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 new file mode 100644 index 00000000000..85d93e1db1e --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/Examples.tsx @@ -0,0 +1,172 @@ +/** + * UI lib Component Example + * + */ + +import React from 'react' +import ComponentBox from 'dnb-design-system-portal/src/shared/tags/ComponentBox' + +export const UploadBasic = () => ( + + { + /* jsx */ ` +const Component = () => { + const {files} = Upload.useUpload('upload-basic') + + return ( + + ) +} +render() + ` + } + +) + +export const UploadMultipleFiles = () => ( + + { + /* jsx */ ` +const Component = () => { + const {files, setFiles} = Upload.useUpload('upload-multiple-files') + return ( + + ) +} +render() + ` + } + +) +export const UploadRemoveFile = () => ( + + { + /* jsx */ ` +const Component = () => { + const {files, setFiles} = Upload.useUpload('upload-remove-files') + return ( + <> + + + + ) +} +render() + ` + } + +) + +export const UploadIsLoading = () => ( + + { + /* jsx */ ` +const Component = () => { + const {files, setFiles} = Upload.useUpload('upload-is-loading') + + return ( + <> + + + + ) +} +render() + ` + } + +) + +export const UploadErrorMessage = () => ( + + { + /* jsx */ ` +const Component = () => { + const {files, setFiles} = Upload.useUpload('upload-error-message') + return ( + <> + + + + ) +} +render() + ` + } + +) + +export const UploadAcceptedFormats = () => ( + + { + /* jsx */ ` +const Component = () => { + const {files, setFiles} = Upload.useUpload('upload-accepted-formats') + + return ( + + ) +} +render() + ` + } + +) + +export const UploadCustomText = () => ( + + { + /* jsx */ ` + + ` + } + +) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/upload/demos.md b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/demos.md new file mode 100644 index 00000000000..58a1709d20a --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/demos.md @@ -0,0 +1,52 @@ +--- +showTabs: true +--- + +import {UploadBasic} from 'Docs/uilib/components/upload/Examples' +import {UploadRemoveFile} from 'Docs/uilib/components/upload/Examples' +import {UploadMultipleFiles} from 'Docs/uilib/components/upload/Examples' +import {UploadIsLoading} from 'Docs/uilib/components/upload/Examples' +import {UploadErrorMessage} from 'Docs/uilib/components/upload/Examples' +import {UploadAcceptedFormats} from 'Docs/uilib/components/upload/Examples' +import {UploadCustomText} from 'Docs/uilib/components/upload/Examples' + + +## Demos + +### Upload (default) + + + +### Upload.useUpload + +using the Upload.useUpload you can remove or add files displayed in the component + + + +### Upload multiple files + + + +### Upload loading state + +When uploading the file you can set the loading state of the request using the Upload.useUpload hook and passing isLoading to the file that is being uploaded. + + + +### Upload error message + +The only checks we do currently is for the file size and the file type. These errors are handled by the HTML element ´input´ so they aren't selectable. If you want any other error messages you can use the Upload.useUpload the same way as with the loading state. + + + +### Upload specific accepted file formats + +You can pass the file formats as a string array. This will restrict which files that can be selected. + + + +### Upload custom text + +All the text can be custom + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/upload/info.md b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/info.md new file mode 100644 index 00000000000..52827966143 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/info.md @@ -0,0 +1,7 @@ +--- +showTabs: true +--- + +## Description + +The Upload should be used in scenarios where the user has to upload files diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/upload/properties.md b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/properties.md new file mode 100644 index 00000000000..ca4d12e7494 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/properties.md @@ -0,0 +1,23 @@ +--- +showTabs: true +--- + +## Properties + + +| Properties | Description | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| [Space](/uilib/components/space/properties) | _(optional)_ Spacing properties like `top` or `bottom` are supported. | +| `className` | _(optional)_ Custom className for the component root. | +| `skeleton` | _(optional)_ Skeleton should be applied when loading content Default: null. | +| `acceptedFileTypes` | _(required)_ List of accepted file types. | +| `fileMaxSize` | _(optional)_ fileMaxSize is max size of each file in MB | +| `multipleFiles` | _(optional)_ if set true, accepting multiple files is allowed | +| `title` | _(optional)_ Custom text property. Replaces the default title | +| `text` | _(optional)_ Custom text property. Replaces the default text | +| `formatsDescription` | _(optional)_ Custom text property. Replaces the default accepted format description | +| `fileSizeDescription` | _(optional)_ Custom text property. Replaces the default max file size description | +| `fileSizeContent` | _(optional)_ Custom text property. Replaces the default file size content | +| `uploadButtonText` | _(optional)_ Custom text property. Replaces the default upload button text | +| `uploadLoadingText` | _(optional)_ Custom text property. Replaces the default loading text | +| `deleteButton` | _(optional)_ Custom text property. Replaces the default delete button text | diff --git a/packages/dnb-eufemia/src/components/Upload.js b/packages/dnb-eufemia/src/components/Upload.js new file mode 100644 index 00000000000..90d57a9f132 --- /dev/null +++ b/packages/dnb-eufemia/src/components/Upload.js @@ -0,0 +1,14 @@ +/** + * ATTENTION: This file is auto generated by using "prepareTemplates". + * Do not change the content! + * + */ + +/** + * Library Index upload to autogenerate all the components and extensions + * Used by "prepareUploads" + */ + +import Upload from './upload/Upload' +export * from './upload/Upload' +export default Upload diff --git a/packages/dnb-eufemia/src/components/index.js b/packages/dnb-eufemia/src/components/index.js index 992019406e8..83e8722eddd 100644 --- a/packages/dnb-eufemia/src/components/index.js +++ b/packages/dnb-eufemia/src/components/index.js @@ -54,6 +54,7 @@ import Textarea from './textarea/Textarea' import Timeline from './timeline/Timeline' import ToggleButton from './toggle-button/ToggleButton' import Tooltip from './tooltip/Tooltip' +import Upload from './upload/Upload' import VisuallyHidden from './visually-hidden/VisuallyHidden' // define / export all the available components @@ -102,5 +103,6 @@ export { Timeline, ToggleButton, Tooltip, + Upload, VisuallyHidden, } diff --git a/packages/dnb-eufemia/src/components/lib.js b/packages/dnb-eufemia/src/components/lib.js index a6af619a972..a075f9a7b0a 100644 --- a/packages/dnb-eufemia/src/components/lib.js +++ b/packages/dnb-eufemia/src/components/lib.js @@ -56,6 +56,7 @@ import Textarea from './textarea/Textarea' import Timeline from './timeline/Timeline' import ToggleButton from './toggle-button/ToggleButton' import Tooltip from './tooltip/Tooltip' +import Upload from './upload/Upload' import VisuallyHidden from './visually-hidden/VisuallyHidden' // define / export all the available components @@ -104,6 +105,7 @@ export { Timeline, ToggleButton, Tooltip, + Upload, VisuallyHidden, } @@ -153,6 +155,7 @@ export const getComponents = () => { Timeline, ToggleButton, Tooltip, + Upload, VisuallyHidden, } } diff --git a/packages/dnb-eufemia/src/components/upload/Upload.tsx b/packages/dnb-eufemia/src/components/upload/Upload.tsx new file mode 100644 index 00000000000..253ddada5be --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/Upload.tsx @@ -0,0 +1,166 @@ +import React from 'react' +import classnames from 'classnames' + +// Components +import Lead from '../../elements/Lead' + +// Shared +import { createSpacingClasses } from '../space/SpacingHelper' +import { createSkeletonClass } from '../skeleton/SkeletonHelper' +import Context from '../../shared/Context' +import { extendPropsWithContext } from '../../shared/component-helper' +import { format } from '../number-format/NumberUtils' + +// Internal +import UploadFileInput from './UploadFileInput' +import type { UploadFile, UploadProps } from './types' +import UploadFileListCell from './UploadFileListCell' +import useUpload from './useUpload' + +export const defaultProps = { + fileMaxSize: 5000, +} + +const Upload = (localProps: UploadProps) => { + // Every component should have a context + const context = React.useContext(Context) + // Extract additional props from global context + const { + id, + skeleton, + className, + acceptedFileTypes, + multipleFiles, + fileMaxSize, + title, + text, + formatsDescription, + fileSizeDescription, + fileSizeContent, + uploadButtonText, + uploadLoadingText, + uploadErrorLargeFile, + deleteButton, + ...props + } = extendPropsWithContext( + localProps, + defaultProps, + { skeleton: context?.skeleton }, + context.getTranslation().Upload, + context.Upload + ) + + const skeletonClasses = createSkeletonClass('shape', skeleton, context) + const spacingClasses = createSpacingClasses(props) + + const { files, setFiles } = useUpload(id) + + const prettyfiedAcceptedFileFormats = acceptedFileTypes + .join(', ') + .toUpperCase() + + return ( +
+ + {title} + + + + {text} + + +
+
+ {formatsDescription} +
+
+ {prettyfiedAcceptedFileFormats} +
+ +
+ {fileSizeDescription} +
+
+ {String(fileSizeContent).replace( + '%size', + format(fileMaxSize).toString() + )} +
+
+ +
+ + + +
+
+ ) + + function UploadFileList() { + if (files == null || files.length < 1) return null + + return ( +
+ {files.map((uploadFile: UploadFile, index: number) => { + return ( + onDeleteFile(uploadFile)} + deleteButtonText={deleteButton} + uploadLoadingText={uploadLoadingText} + /> + ) + })} +
+ ) + } + + function onDeleteFile(removeFile: UploadFile) { + const cleanedFiles = files.filter( + (fileListElement) => fileListElement.file != removeFile.file + ) + setFiles(cleanedFiles) + } + + function onInputUpload(addedFiles: UploadFile[]) { + const addedFile = [...files, ...addedFiles] + + setFiles(addedFile) + } +} + +Upload.useUpload = useUpload + +export default Upload diff --git a/packages/dnb-eufemia/src/components/upload/UploadFileInput.tsx b/packages/dnb-eufemia/src/components/upload/UploadFileInput.tsx new file mode 100644 index 00000000000..4000e89a5e0 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/UploadFileInput.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useRef, useState } from 'react' + +// Components +import Button from '../button/Button' +import FormLabel from '../form-label/FormLabel' + +// Icons +import { folder as FolderIcon } from '../../icons' + +// Shared +import { makeUniqueId } from '../../shared/component-helper' +import { format } from '../number-format/NumberUtils' + +// Internal +import { UploadFile } from './types' + +export interface UploadFileInputProps { + acceptedFormats: string[] + onUpload: (files: UploadFile[]) => void + fileMaxSize: number + uploadFileButtonText: React.ReactNode + uploadErrorLargeFile: React.ReactNode + multipleFiles: boolean +} + +const BYTES_IN_A_MEGA_BYTE = 1048576 + +const UploadFileInput = ({ + acceptedFormats, + uploadFileButtonText, + onUpload, + fileMaxSize, + uploadErrorLargeFile, + multipleFiles = false, +}: UploadFileInputProps) => { + const fileInput = useRef(null) + + const accept = acceptedFormats.reduce((accept, format, index) => { + const previus = index === 0 ? '' : `${accept},` + return `${previus} .${format}` + }, '') + + useEffect(() => { + fileInput.current.value = null + fileInput.current.accept = accept + }) + + const [id] = useState(makeUniqueId) + + const openFileDialog = () => fileInput.current?.click() + + return ( +
+ + + + + +
+ ) + + function handleFileInput({ target: { files } }) { + const uploadFile = [...Array(files.length)].map((_item, index) => { + const file: UploadFile = { file: files[index] } + const errorMessage = getErrorMessage(file.file.size) + + if (errorMessage) return { ...file, errorMessage } + return file + }) + onUpload(uploadFile) + } + + function getErrorMessage(fileSize: number) { + const errorMessage = String(uploadErrorLargeFile).replace( + '%size', + format(fileMaxSize).toString() + ) + // Converts from b (binary) to MB (decimal) + return fileSize / BYTES_IN_A_MEGA_BYTE > fileMaxSize + ? errorMessage + : null + } +} + +export default UploadFileInput diff --git a/packages/dnb-eufemia/src/components/upload/UploadFileListCell.tsx b/packages/dnb-eufemia/src/components/upload/UploadFileListCell.tsx new file mode 100644 index 00000000000..817be9ad9e6 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/UploadFileListCell.tsx @@ -0,0 +1,169 @@ +import React from 'react' +import classnames from 'classnames' + +// Components +import Button from '../button/Button' +import Icon from '../../components/Icon' +import FormStatus from '../../components/FormStatus' +import ProgressIndicator from '../../components/progress-indicator' + +// Icons +import { + trash as TrashIcon, + exclamation_medium as ExclamationIcon, + file_pdf_medium, + file_xls_medium, + file_ppt_medium, + file_csv_medium, + file_txt_medium, + file_xml_medium, + file_medium, +} from '../../icons' +import { UploadFile } from './types' + +const images = { + pdf: file_pdf_medium, + xls: file_xls_medium, + ppt: file_ppt_medium, + csv: file_csv_medium, + txt: file_txt_medium, + xml: file_xml_medium, + file: file_medium, +} + +export interface UploadFileListCellProps { + /** + * Uploaded file + */ + uploadFile: UploadFile + /** + * Calls onDelete when clicking the delete button + */ + onDelete: () => void + /** + * Text + */ + uploadLoadingText: React.ReactNode + deleteButtonText: React.ReactNode +} + +const UploadFileListCell = ({ + uploadFile, + onDelete, + uploadLoadingText, + deleteButtonText, +}: UploadFileListCellProps) => { + const { file, errorMessage, isLoading } = uploadFile + const { name, type } = file + + const fileType = type.split('/')[1] + + const hasWarning = errorMessage != null + + const imageUrl = URL.createObjectURL(file) + + return ( +
+
+
+ {getIcon()} + {getTitle()} +
+
+ +
+
+ + {getWarning()} +
+ ) + + function getIcon() { + if (isLoading) { + return + } + + if (hasWarning) + return ( + + ) + + let iconFileType = fileType + + if (!Object.prototype.hasOwnProperty.call(images, fileType)) { + iconFileType = 'file' + } + return ( + + ) + } + + function getTitle() { + return isLoading ? ( +
+ {uploadLoadingText} +
+ ) : ( +
+ + {name} + + + {fileType.toUpperCase()} + +
+ ) + } + + function getWarning() { + return hasWarning ? ( + + ) : null + } +} + +export default UploadFileListCell diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/Upload.screenshot.test.js b/packages/dnb-eufemia/src/components/upload/__tests__/Upload.screenshot.test.js new file mode 100644 index 00000000000..5c52396ae0e --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/__tests__/Upload.screenshot.test.js @@ -0,0 +1,47 @@ +/** + * Screenshot Test + * This file will not run on "test:staged" because we don't require any related files + */ + +import { + testPageScreenshot, + setupPageScreenshot, +} from '../../../core/jest/jestSetupScreenshots' + +describe('Avatar screenshot', () => { + setupPageScreenshot({ url: '/uilib/components/upload/demos' }) + + it('have to match the default', async () => { + const screenshot = await testPageScreenshot({ + selector: '[data-visual-test="upload-basic"]', + }) + expect(screenshot).toMatchImageSnapshot() + }) + + it('have to match the loading state', async () => { + const screenshot = await testPageScreenshot({ + selector: '[data-visual-test="upload-is-loading"]', + }) + expect(screenshot).toMatchImageSnapshot() + }) + + it('have to match the error message', async () => { + const screenshot = await testPageScreenshot({ + selector: '[data-visual-test="upload-error-message"]', + }) + expect(screenshot).toMatchImageSnapshot() + }) + it('have to match the custom accepted formats', async () => { + const screenshot = await testPageScreenshot({ + selector: '[data-visual-test="upload-accepted-formats"]', + }) + expect(screenshot).toMatchImageSnapshot() + }) + + it('have to match the custom text', async () => { + const screenshot = await testPageScreenshot({ + selector: '[data-visual-test="upload-custom-text"]', + }) + expect(screenshot).toMatchImageSnapshot() + }) +}) diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/Upload.test.tsx b/packages/dnb-eufemia/src/components/upload/__tests__/Upload.test.tsx new file mode 100644 index 00000000000..17bab551234 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/__tests__/Upload.test.tsx @@ -0,0 +1,350 @@ +import React, { useEffect } from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import Upload from '../Upload' +import nbNO from '../../../shared/locales/nb-NO' +import createMockFile from './testHelpers' +import { loadScss, axeComponent } from '../../../core/jest/jestSetup' +import { UploadProps } from '../types' +import useUpload from '../useUpload' + +const nb = nbNO['nb-NO'].Upload + +global.URL.createObjectURL = jest.fn(() => 'url') + +const defaultProps: UploadProps = { + id: 'id', + acceptedFileTypes: ['png'], +} + +describe('Upload', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('renders the component', () => { + render() + + expect(screen.queryByTestId('upload')).not.toBeNull() + }) + + it('renders the upload file input section', () => { + render() + + expect(screen.queryByTestId('upload-file-input')).not.toBeNull() + }) + + describe('Text', () => { + it('renders the title', () => { + render() + + const element = screen.queryByTestId('upload-title') + + expect(element).not.toBeNull() + + expect(element.textContent).toMatch(nb.title) + }) + + it('renders the custom title', () => { + const customTitle = 'custom title' + + render() + + const element = screen.queryByTestId('upload-title') + + expect(element.textContent).toMatch(customTitle) + }) + + it('renders the text', () => { + render() + + const element = screen.queryByTestId('upload-text') + + expect(element).not.toBeNull() + + expect(element.textContent).toMatch(nb.text) + }) + + it('renders the custom text', () => { + const customText = 'custom text' + + render() + + const element = screen.queryByTestId('upload-text') + + expect(element.textContent).toMatch(customText) + }) + + it('renders the format description', () => { + render() + + const element = screen.queryByTestId( + 'upload-accepted-formats-description' + ) + + expect(element).not.toBeNull() + + expect(element.textContent).toMatch(nb.formatsDescription) + }) + + it('renders the custom format description', () => { + const customFormatDescription = 'custom formats description' + + render( + + ) + + const element = screen.queryByTestId( + 'upload-accepted-formats-description' + ) + + expect(element.textContent).toMatch(customFormatDescription) + }) + + it('renders the custom accepted format', () => { + const acceptedFileTypes = ['png, jpg'] + + render( + + ) + + const element = screen.queryByTestId('upload-accepted-formats') + + const formattedFileTypes = acceptedFileTypes.join(', ').toUpperCase() + + expect(element).not.toBeNull() + expect(element.textContent).toMatch(formattedFileTypes) + }) + + it('renders the file size description', () => { + render() + + const element = screen.queryByTestId('upload-file-size-description') + + expect(element).not.toBeNull() + }) + + it('renders the custom file size description', () => { + const fileSizeDescription = 'file size description' + + render( + + ) + + const element = screen.queryByTestId('upload-file-size-description') + + expect(element.textContent).toMatch(fileSizeDescription) + }) + + it('renders the file size', () => { + const fileMaxSize = 2 + render() + + const element = screen.queryByTestId('upload-file-size') + + expect(element).not.toBeNull() + expect(element.textContent).toMatch( + String(nb.fileSizeContent).replace('%size', fileMaxSize.toString()) + ) + }) + + it('renders the custom file size', () => { + const fileMaxSize = 2 + const fileSizeContent = '%size custom' + + render( + + ) + + const element = screen.queryByTestId('upload-file-size') + + expect(element.textContent).toMatch( + String(fileMaxSize).replace('%size', `${fileMaxSize}`) + ) + }) + + it('renders the upload file input section button text', () => { + render() + + const element = screen.queryByTestId('upload-file-input-button') + + expect(element.textContent).toMatch(nb.uploadButtonText) + }) + + it('renders the upload file input section button custom text', () => { + const uploadButtonText = 'upload button text' + + render( + + ) + + const element = screen.queryByTestId('upload-file-input-button') + + expect(element.textContent).toMatch(uploadButtonText) + }) + }) + + describe('useUpload', () => { + it('calls uses the useUpload hook to store files', async () => { + const validationFunction = jest.fn() + + const file = createMockFile('fileName.png', 100, 'image/png') + + const id = 'random-id' + + const expectedResult = [{ file }] + + render() + + const inputElement = screen.queryByTestId('upload-file-input-input') + + await waitFor(() => + fireEvent.change(inputElement, { + target: { files: [file] }, + }) + ) + + const MockComponent = () => { + const { files } = useUpload(id) + useEffect(() => validationFunction(files), []) + + return
+ } + + render() + + expect(validationFunction).toHaveBeenCalledWith(expectedResult) + }) + + it('renders the list of files', async () => { + const files = [ + { file: createMockFile('fileName.png', 100, 'image/png') }, + { file: createMockFile('fileName2.png', 100, 'image/png') }, + { file: createMockFile('fileName3.png', 100, 'image/png') }, + ] + + const id = 'random-id' + + render() + + const MockComponent = () => { + const { setFiles } = useUpload(id) + + useEffect(() => setFiles(files), []) + + return
+ } + + render() + + const fileCells = screen.queryAllByTestId('upload-file-list-cell') + + expect(fileCells.length).toBe(files.length) + }) + + it('shows no files', async () => { + const files = [] + + const id = 'random-id3' + + const { queryByTestId } = render( + + ) + + const MockComponent = () => { + const { setFiles } = useUpload(id) + + useEffect(() => setFiles(files), []) + + return
+ } + render() + + const emptyFileCell = queryByTestId('upload-file-list-cell') + + expect(emptyFileCell).toBeNull() + }) + + it('shows the file when the file is added', async () => { + const files = [ + { file: createMockFile('fileName.png', 100, 'image/png') }, + ] + + const id = 'random-id2' + + render() + + const MockComponent = () => { + const { setFiles } = useUpload(id) + + useEffect(() => setFiles(files), []) + + return
+ } + + const emptyFileCell = screen.queryByTestId('upload-file-list-cell') + + expect(emptyFileCell).toBeNull() + + render() + + const fileCell = screen.queryByTestId('upload-file-list-cell') + + expect(fileCell).not.toBeNull() + }) + + it('removes the file from the list when clicking delete', () => { + const files = [ + { file: createMockFile('fileName.png', 100, 'image/png') }, + ] + + const id = 'random-id3' + + const { queryByTestId } = render( + + ) + const MockComponent = () => { + const { setFiles } = useUpload(id) + + useEffect(() => setFiles(files), []) + + return
+ } + + render() + + const fileCell = queryByTestId('upload-file-list-cell') + + expect(fileCell).not.toBeNull() + + const deleteButton = queryByTestId('upload-delete-button') + + fireEvent.click(deleteButton) + + expect(queryByTestId('upload-file-list-cell')).toBeNull() + }) + }) +}) + +describe('Upload aria', () => { + it('should validate', async () => { + const Component = render() + expect(await axeComponent(Component)).toHaveNoViolations() + }) +}) + +describe('Upload scss', () => { + it('have to match snapshot', () => { + const scss = loadScss(require.resolve('../style/dnb-upload.scss')) + expect(scss).toMatchSnapshot() + }) +}) diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/UploadFileInput.test.tsx b/packages/dnb-eufemia/src/components/upload/__tests__/UploadFileInput.test.tsx new file mode 100644 index 00000000000..fcde96bb360 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/__tests__/UploadFileInput.test.tsx @@ -0,0 +1,159 @@ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import UploadFileInput, { UploadFileInputProps } from '../UploadFileInput' +import createMockFile from './testHelpers' + +const defaultProps: UploadFileInputProps = { + acceptedFormats: ['png'], + onUpload: jest.fn(), + uploadFileButtonText: 'upload button text', + fileMaxSize: 1000, + uploadErrorLargeFile: 'error message', + multipleFiles: false, +} + +describe('UploadFileInput', () => { + it('renders the component', () => { + render() + + expect(screen.queryByTestId('upload-file-input')).not.toBeNull() + }) + + it('renders the upload button', () => { + render() + expect(screen.queryByTestId('upload-file-input-button')).not.toBeNull() + }) + + it('renders the upload button text', () => { + const buttonText = 'button text' + render( + + ) + expect( + screen.queryByTestId('upload-file-input-button').textContent + ).toMatch(buttonText) + }) + + it('accepts multiple files when multipleFiles is true', () => { + render() + + const element = screen.queryByTestId('upload-file-input-input') + + expect(element.hasAttribute('multiple')).toBeTruthy() + }) + + it('renders the input', () => { + render() + const element = screen.queryByTestId('upload-file-input-input') + + expect(element).not.toBeNull() + expect(element.getAttribute('class')).toMatch('dnb-upload__file-input') + }) + + it('renders the sr only label for the input', () => { + render() + + const element = screen.queryByTestId('upload-file-input-sr-label') + + expect(element).not.toBeNull() + }) + + it('renders the correct id for input and input sr label', () => { + render() + + const srLabelElement = screen.queryByTestId( + 'upload-file-input-sr-label' + ) + const inputElement = screen.queryByTestId('upload-file-input-input') + + expect(srLabelElement.getAttribute('for')).toBe( + inputElement.getAttribute('id') + ) + }) + + it('simulates a click on the input when clicking the button', () => { + render() + + const buttonElement = screen.queryByTestId('upload-file-input-button') + + const inputElement = screen.queryByTestId('upload-file-input-input') + + const clickEventListener = jest.fn() + inputElement.addEventListener('click', clickEventListener) + + fireEvent.click(buttonElement) + + expect(clickEventListener).toHaveBeenCalled() + }) + + it('calls the onUpload function', async () => { + const file = createMockFile('fileName.png', 100, 'image/png') + + const onUpload = jest.fn() + + render() + + const inputElement = screen.queryByTestId('upload-file-input-input') + + await waitFor(() => + fireEvent.change(inputElement, { + target: { files: [file] }, + }) + ) + expect(onUpload).toHaveBeenCalledWith([{ file }]) + }) + + it('can upload multiple files', async () => { + const file1 = createMockFile('fileName1.png', 100, 'image/png') + const file2 = createMockFile('fileName2.png', 100, 'image/png') + + const onUpload = jest.fn() + + render() + + const inputElement = screen.queryByTestId('upload-file-input-input') + + await waitFor(() => + fireEvent.change(inputElement, { + target: { files: [file1, file2] }, + }) + ) + expect(onUpload).toHaveBeenCalledWith([ + { file: file1 }, + { file: file2 }, + ]) + }) + + it('returns the file size error message', async () => { + const file1 = createMockFile('fileName1.png', 100000000, 'image/png') + + const fileMaxSize = 1 + const errorMessage = 'error message %size' + const errorMessageFormatted = `error message ${fileMaxSize}` + + const onUpload = jest.fn() + + render( + + ) + + const inputElement = screen.queryByTestId('upload-file-input-input') + + await waitFor(() => + fireEvent.change(inputElement, { + target: { files: [file1] }, + }) + ) + expect(onUpload).toHaveBeenCalledWith([ + { file: file1, errorMessage: errorMessageFormatted }, + ]) + }) +}) diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/UploadFileListCell.test.tsx b/packages/dnb-eufemia/src/components/upload/__tests__/UploadFileListCell.test.tsx new file mode 100644 index 00000000000..2dc2dc4947b --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/__tests__/UploadFileListCell.test.tsx @@ -0,0 +1,356 @@ +import UploadFileListCell, { + UploadFileListCellProps, +} from '../UploadFileListCell' +import createMockFile from './testHelpers' +import { render, screen } from '@testing-library/react' +import React from 'react' + +global.URL.createObjectURL = jest.fn(() => 'url') + +const defaultProps: UploadFileListCellProps = { + deleteButtonText: 'delete', + onDelete: jest.fn(), + uploadFile: { file: createMockFile('file.png', 100, 'image/png') }, + uploadLoadingText: 'loading', +} + +describe('UploadFileListCell', () => { + it('renders the component', () => { + render() + + const element = screen.queryByTestId('upload-file-list-cell') + + expect(element).not.toBeNull() + }) + + it('renders the error styling', () => { + render( + + ) + + const element = screen.queryByTestId('upload-file-list-cell') + + expect(element.className).toMatch('dnb-upload__file-cell--error') + expect(element.className).not.toMatch( + 'dnb-upload__file-cell--no-error' + ) + }) + + it('renders the no error styling', () => { + render( + + ) + + const element = screen.queryByTestId('upload-file-list-cell') + + expect(element.className).not.toMatch('dnb-upload__file-cell--error') + expect(element.className).toMatch('dnb-upload__file-cell--no-error') + }) + + it('renders the subtitle', () => { + render( + + ) + + const element = screen.queryByTestId('upload-subtitle') + + expect(element).not.toBeNull() + expect(element.textContent).toMatch('PNG') + }) + + it('renders the form errorMessage warning', () => { + render( + + ) + + const element = screen.queryByTestId('upload-warning') + + expect(element).not.toBeNull() + }) + + it('renders the form errorMessage warning message', () => { + const errorMessage = 'error message' + + render( + + ) + + const element = screen.queryByTestId('upload-warning') + + expect(element.textContent).toMatch(errorMessage) + }) + + describe('Icons', () => { + it('renders the exclamation icon', () => { + render( + + ) + + expect( + screen.queryByTestId('exclamation medium icon') + ).not.toBeNull() + }) + + it('renders the pdf icon', () => { + render( + + ) + + expect(screen.queryByTestId('file pdf medium icon')).not.toBeNull() + }) + + it('renders the xls icon', () => { + render( + + ) + + expect(screen.queryByTestId('file xls medium icon')).not.toBeNull() + }) + + it('renders the ppt icon', () => { + render( + + ) + + expect(screen.queryByTestId('file ppt medium icon')).not.toBeNull() + }) + + it('renders the csv icon', () => { + render( + + ) + + expect(screen.queryByTestId('file csv medium icon')).not.toBeNull() + }) + + it('renders the txt icon', () => { + render( + + ) + + expect(screen.queryByTestId('file txt medium icon')).not.toBeNull() + }) + + it('renders the xml icon', () => { + render( + + ) + + expect(screen.queryByTestId('file xml medium icon')).not.toBeNull() + }) + + it('renders the file icon as default', () => { + render( + + ) + + expect(screen.queryByTestId('file medium icon')).not.toBeNull() + }) + }) + + describe('File Anchor', () => { + it('renders the anchor', () => { + render() + const anchorElement = screen.queryByTestId('upload-file-anchor') + + expect(anchorElement).not.toBeNull() + }) + + it('renders the anchor text', () => { + const fileName = 'file.png' + + render( + + ) + const anchorElement = screen.queryByTestId('upload-file-anchor') + expect(anchorElement.textContent).toMatch(fileName) + }) + + it('renders the anchor href', () => { + const mockUrl = 'mock-url' + + global.URL.createObjectURL = jest.fn().mockReturnValueOnce(mockUrl) + + render( + + ) + const anchorElement = screen.queryByTestId( + 'upload-file-anchor' + ) as HTMLAnchorElement + expect(anchorElement.href).toMatch(mockUrl) + }) + + it('renders with the error style', () => { + render( + + ) + + const anchorElement = screen.queryByTestId('upload-file-anchor') + + expect(anchorElement.className).toMatch( + 'dnb-upload__file-cell--error' + ) + }) + + it('renders without the error style', () => { + render( + + ) + + const anchorElement = screen.queryByTestId('upload-file-anchor') + + expect(anchorElement.className).not.toMatch( + 'dnb-upload__file-cell--error' + ) + }) + }) + + describe('Delete Button', () => { + it('renders the delete button', () => { + render() + + const element = screen.queryByTestId('upload-delete-button') + + expect(element).not.toBeNull() + }) + + it('renders the delete button text', () => { + const deleteButtonText = 'delete' + + render( + + ) + + const element = screen.queryByTestId('upload-delete-button') + + expect(element.textContent).toMatch(deleteButtonText) + }) + + it('renders button as tertiary', () => { + render() + + const element = screen.queryByTestId('upload-delete-button') + + expect(element.className).toMatch('dnb-button--tertiary') + }) + + it('renders the file cell loading state', () => { + render( + + ) + + const element = screen.queryByTestId('upload-progress-indicator') + + expect(element).not.toBeNull() + }) + + it('does not render the loading state when not loading', () => { + render( + + ) + + const element = screen.queryByTestId('upload-progress-indicator') + + expect(element).toBeNull() + }) + }) +}) diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/Upload.test.tsx.snap b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/Upload.test.tsx.snap new file mode 100644 index 00000000000..54d77522dab --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/Upload.test.tsx.snap @@ -0,0 +1,127 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Upload scss have to match snapshot 1`] = ` +"/* +* DNB Upload +* +*/ +/** + * This file is only used to make components independent + * so that they can get imported individually, without the core styles + * + */ +/* + * Utilities + */ +/* + * Scopes + * + */ +/* + * Document Reset + * + */ +.dnb-upload { + font-family: var(--font-family-default); + font-weight: var(--font-weight-basis); + font-size: var(--font-size-small); + font-style: normal; + line-height: var(--line-height-basis); + color: var(--color-black-80, #333); + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + /** + * Ensure consistency and use the same as HTML reset -> html {...} + * between base and code package + */ + -moz-tab-size: 4; + tab-size: 4; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + word-break: break-word; + /** + * 1. Remove repeating backgrounds in all browsers (opinionated). + * 2. Add border box sizing in all browsers (opinionated). + */ + /** + * 1. Add text decoration inheritance in all browsers (opinionated). + * 2. Add vertical alignment inheritance in all browsers (opinionated). + */ + margin: 0; + padding: 0; } + .dnb-upload *, + .dnb-upload ::before, + .dnb-upload ::after { + background-repeat: no-repeat; + /* 1 */ + box-sizing: border-box; + /* 2 */ } + .dnb-upload ::before, + .dnb-upload ::after { + text-decoration: inherit; + /* 1 */ + vertical-align: inherit; + /* 2 */ } + +/* +* Upload component +* +*/ +.dnb-upload { + display: flex; + flex-direction: column; + border: 0.09375rem dashed var(--color-sea-green); + border-radius: 0.5rem; + padding: var(--spacing-medium); + background: var(--color-white); } + .dnb-upload__condition-list { + display: grid; + grid-gap: 0.5rem 1rem; + grid-template-columns: minmax(1rem, auto) 1fr; + margin-top: var(--spacing-x-small); } + .dnb-upload__condition-list.dnb-dl > dt, + .dnb-upload__condition-list.dnb-dl > dd { + margin: 0; + font-size: var(--font-size-x-small); } + .dnb-upload__condition-list__label { + font-weight: var(--font-weight-medium); + margin-right: var(--spacing-x-small); } + .dnb-upload__text { + color: var(--color-black-55); + font-size: var(--font-size-x-small); + margin-top: var(--spacing-x-small); + margin-bottom: 0.75rem; } + .dnb-upload__file-input { + visibility: hidden; } + .dnb-upload__file-list { + margin-top: var(--spacing-medium); } + .dnb-upload__file-cell { + border-top: 0.0625rem solid var(--color-black-8); + border-bottom: 0.0625rem solid var(--color-black-8); + padding: var(--spacing-small) 0; } + .dnb-upload__file-cell__content { + display: flex; + flex-direction: row; + justify-content: space-between; } + .dnb-upload__file-cell__content__left { + display: flex; + flex-direction: row; + align-items: center; } + .dnb-upload__file-cell--no-error { + color: var(--color-sea-green); } + .dnb-upload__file-cell--error { + color: var(--color-fire-red); } + .dnb-upload__file-cell__text-container { + display: flex; + flex-direction: column; + margin-left: var(--spacing-small); } + .dnb-upload__file-cell__text-container--loading { + font-size: var(--font-size-basis); } + .dnb-upload__file-cell__title { + font-size: var(--font-size-basis); } + .dnb-upload__file-cell__subtitle { + font-size: var(--font-size-x-small); + color: var(--color-black-55); } +" +`; diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-avatar-screenshot-have-to-match-the-custom-accepted-formats-1-b90e0.snap.png b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-avatar-screenshot-have-to-match-the-custom-accepted-formats-1-b90e0.snap.png new file mode 100644 index 00000000000..1434468def2 Binary files /dev/null and b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-avatar-screenshot-have-to-match-the-custom-accepted-formats-1-b90e0.snap.png differ diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-avatar-screenshot-have-to-match-the-custom-text-1-e5172.snap.png b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-avatar-screenshot-have-to-match-the-custom-text-1-e5172.snap.png new file mode 100644 index 00000000000..94c0112edd6 Binary files /dev/null and b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-avatar-screenshot-have-to-match-the-custom-text-1-e5172.snap.png differ diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-avatar-screenshot-have-to-match-the-default-1-0bb95.snap.png b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-avatar-screenshot-have-to-match-the-default-1-0bb95.snap.png new file mode 100644 index 00000000000..8ee014b4ef5 Binary files /dev/null and b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-avatar-screenshot-have-to-match-the-default-1-0bb95.snap.png differ diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-avatar-screenshot-have-to-match-the-error-message-1-49b5c.snap.png b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-avatar-screenshot-have-to-match-the-error-message-1-49b5c.snap.png new file mode 100644 index 00000000000..d5cf9c4e0ec Binary files /dev/null and b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-avatar-screenshot-have-to-match-the-error-message-1-49b5c.snap.png differ diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-avatar-screenshot-have-to-match-the-loading-state-1-f9e4a.snap.png b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-avatar-screenshot-have-to-match-the-loading-state-1-f9e4a.snap.png new file mode 100644 index 00000000000..bc3dd3baffd Binary files /dev/null and b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-avatar-screenshot-have-to-match-the-loading-state-1-f9e4a.snap.png differ diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/testHelpers.ts b/packages/dnb-eufemia/src/components/upload/__tests__/testHelpers.ts new file mode 100644 index 00000000000..6210e3d8a5f --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/__tests__/testHelpers.ts @@ -0,0 +1,13 @@ +export default function createMockFile( + name: string, + size: number, + type: string +) { + const file = new File([], name, { type }) + Object.defineProperty(file, 'size', { + get() { + return size + }, + }) + return file +} diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/useUpload.test.tsx b/packages/dnb-eufemia/src/components/upload/__tests__/useUpload.test.tsx new file mode 100644 index 00000000000..3719a899a51 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/__tests__/useUpload.test.tsx @@ -0,0 +1,116 @@ +import { act, render } from '@testing-library/react' +import useUpload from './../useUpload' +import React, { useEffect } from 'react' +import createMockFile from './testHelpers' +import EventEmitter from '../../../../src/shared/helpers/EventEmitter' + +describe('useUpload', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('return an empty list', () => { + const validationFunction = jest.fn() + + const MockComponents = () => { + const { files } = useUpload('id') + + validationFunction(files) + + return
+ } + + render() + + expect(validationFunction).toHaveBeenCalledWith([]) + }) + + it('return the updateFiles function', () => { + const validationFunction = jest.fn() + + const MockComponents = () => { + const { setFiles } = useUpload('id') + + validationFunction(setFiles) + + return
+ } + + render() + + expect(validationFunction).toHaveBeenCalledWith(expect.any(Function)) + }) + + it('return the added files', () => { + const validationFunction = jest.fn() + + const mockFile = createMockFile('fileName.png', 100, 'image/png') + + const MockComponents = () => { + const { setFiles, files } = useUpload('id') + + useEffect(() => { + setFiles([{ file: mockFile }]) + }, []) + + validationFunction(files) + return
+ } + + render() + act(() => { + expect(validationFunction).toHaveBeenCalledWith([{ file: mockFile }]) + }) + }) + + it('use the event emitter to store a file', () => { + const mockFile = { + file: createMockFile('fileName.png', 100, 'image/png'), + } + const id = 'unique-id-1' + + const MockComponents = () => { + const { setFiles } = useUpload(id) + + useEffect(() => setFiles([mockFile]), []) + + return
+ } + + render() + + const emitter = EventEmitter.createInstance(id) + const emitterFiles = emitter.get().files + + expect(emitterFiles).toMatchObject([mockFile]) + }) + + it('use the event emitter to return a file', () => { + const mockFile = { + file: createMockFile('fileName.png', 100, 'image/png'), + } + const id = 'unique-id-2' + + const validationFunction = jest.fn() + + const MockComponents = () => { + const { files } = useUpload(id) + + useEffect(() => { + validationFunction(files) + }, []) + + return
+ } + + const emitter = EventEmitter.createInstance(id) + const emitterData = { files: [mockFile] } + emitter.update(emitterData) + + render() + + act(() => { + expect(validationFunction).toHaveBeenCalledWith([mockFile]) + }) + }) +}) diff --git a/packages/dnb-eufemia/src/components/upload/index.d.ts b/packages/dnb-eufemia/src/components/upload/index.d.ts new file mode 100644 index 00000000000..2a3ea8fe10d --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/index.d.ts @@ -0,0 +1,8 @@ +/** + * Component Entry + * + */ + +import Upload from './Upload'; +export default Upload; +export * from './Upload'; diff --git a/packages/dnb-eufemia/src/components/upload/index.js b/packages/dnb-eufemia/src/components/upload/index.js new file mode 100644 index 00000000000..67067079c7c --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/index.js @@ -0,0 +1,8 @@ +/** + * Component Entry + * + */ + +import Upload from './Upload' +export default Upload +export * from './Upload' diff --git a/packages/dnb-eufemia/src/components/upload/stories/Upload.stories.tsx b/packages/dnb-eufemia/src/components/upload/stories/Upload.stories.tsx new file mode 100644 index 00000000000..6b729d70759 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/stories/Upload.stories.tsx @@ -0,0 +1,78 @@ +/** + * @dnb/eufemia Component Story + * + */ + +import React, { useEffect } from 'react' +import { Wrapper, Box } from 'storybook-utils/helpers' +import { Upload } from '../..' +import { UploadFile } from '../types' + +export default { + title: 'Eufemia/Components/Upload', +} + +export const UploadSandbox = () => { + const { files: files1 } = Upload.useUpload('upload-example-1') + Upload.useUpload('upload-example-6') + + useEffect(() => { + console.log(files1) + }, [files1]) + + return ( + + + + + + + + + + + + + + + + + + Two Upload components can be controlled using the same id + + files.map((_file) => { + return { ..._file, isLoading: true } + }) + } + /> + + + + ) +} diff --git a/packages/dnb-eufemia/src/components/upload/style.js b/packages/dnb-eufemia/src/components/upload/style.js new file mode 100644 index 00000000000..a767e2f50a8 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/style.js @@ -0,0 +1,6 @@ +/** + * Web Style Import + * + */ + +import './style/dnb-upload.scss' diff --git a/packages/dnb-eufemia/src/components/upload/style/_upload.scss b/packages/dnb-eufemia/src/components/upload/style/_upload.scss new file mode 100644 index 00000000000..be13f9f36d0 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/style/_upload.scss @@ -0,0 +1,93 @@ +/* +* Upload component +* +*/ + +.dnb-upload { + display: flex; + flex-direction: column; + border: 0.09375rem dashed var(--color-sea-green); + border-radius: 0.5rem; + padding: var(--spacing-medium); + + background: var(--color-white); + + &__condition-list { + display: grid; + grid-gap: 0.5rem 1rem; + grid-template-columns: minmax(1rem, auto) 1fr; + + margin-top: var(--spacing-x-small); + + &.dnb-dl > dt, + &.dnb-dl > dd { + margin: 0; + font-size: var(--font-size-x-small); + } + + &__label { + font-weight: var(--font-weight-medium); + margin-right: var(--spacing-x-small); + } + } + + &__text { + color: var(--color-black-55); + font-size: var(--font-size-x-small); + margin-top: var(--spacing-x-small); + margin-bottom: 0.75rem; + } + + &__file-input { + visibility: hidden; + } + + &__file-list { + margin-top: var(--spacing-medium); + } + + &__file-cell { + border-top: 0.0625rem solid var(--color-black-8); + border-bottom: 0.0625rem solid var(--color-black-8); + padding: var(--spacing-small) 0; + + &__content { + display: flex; + flex-direction: row; + + justify-content: space-between; + + &__left { + display: flex; + flex-direction: row; + align-items: center; + } + } + + &--no-error { + color: var(--color-sea-green); + } + + &--error { + color: var(--color-fire-red); + } + + &__text-container { + display: flex; + flex-direction: column; + margin-left: var(--spacing-small); + &--loading { + font-size: var(--font-size-basis); + } + } + + &__title { + font-size: var(--font-size-basis); + } + + &__subtitle { + font-size: var(--font-size-x-small); + color: var(--color-black-55); + } + } +} diff --git a/packages/dnb-eufemia/src/components/upload/style/dnb-upload.scss b/packages/dnb-eufemia/src/components/upload/style/dnb-upload.scss new file mode 100644 index 00000000000..35fbe10272d --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/style/dnb-upload.scss @@ -0,0 +1,12 @@ +/* +* DNB Upload +* +*/ + +@import '../../../style/components/imports.scss'; + +.dnb-upload { + @include componentReset(); +} + +@import './_upload.scss'; diff --git a/packages/dnb-eufemia/src/components/upload/style/index.d.ts b/packages/dnb-eufemia/src/components/upload/style/index.d.ts new file mode 100644 index 00000000000..3fc8cc313a2 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/style/index.d.ts @@ -0,0 +1,6 @@ +/** + * Web Style Import + * + */ + +import './dnb-upload.scss'; diff --git a/packages/dnb-eufemia/src/components/upload/style/index.js b/packages/dnb-eufemia/src/components/upload/style/index.js new file mode 100644 index 00000000000..4ef8acea2ce --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/style/index.js @@ -0,0 +1,6 @@ +/** + * Web Style Import + * + */ + +import './dnb-upload.scss' diff --git a/packages/dnb-eufemia/src/components/upload/types.ts b/packages/dnb-eufemia/src/components/upload/types.ts new file mode 100644 index 00000000000..76bbe770aba --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/types.ts @@ -0,0 +1,55 @@ +import React from 'react' +import { SkeletonShow } from '../skeleton/Skeleton' + +export interface UploadProps { + /** + * unique id used with the useUpload hook to manage the files + */ + id: string + + /** + * list of accepted file types. + */ + acceptedFileTypes: string[] + + /** + * Custom className on the component root + * Default: null + */ + className?: string + + /** + * Skeleton should be applied when loading content + * Default: null + */ + skeleton?: SkeletonShow + + /** + * If set true, accepting multiple files is allowed + */ + multipleFiles?: boolean + + /** + * fileMaxSize is max size of each file in MB + */ + fileMaxSize?: number + + /** + * Custom text properties + */ + title?: React.ReactNode + text?: React.ReactNode + formatsDescription?: React.ReactNode + fileSizeDescription?: React.ReactNode + fileSizeContent?: React.ReactNode + uploadButtonText?: React.ReactNode + uploadErrorLargeFile?: React.ReactNode + uploadLoadingText?: React.ReactNode + deleteButton?: React.ReactNode +} + +export interface UploadFile { + file: File + errorMessage?: string | React.ReactNode + isLoading?: boolean +} diff --git a/packages/dnb-eufemia/src/components/upload/useUpload.ts b/packages/dnb-eufemia/src/components/upload/useUpload.ts new file mode 100644 index 00000000000..7b9ad643cf2 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/useUpload.ts @@ -0,0 +1,25 @@ +import { useEventEmitter } from '../../../src/shared/component-helper' +import type { UploadFile } from './types' + +/** + * Use together with Upload with the same id to manage the files from outside the component. + * @param id string, must match the id of the Upload component + * @returns { files: UploadFile[], setFiles: (file: UploadFile[]) => void } + */ +function useUpload(id: string): { + files: UploadFile[] + setFiles: (files: UploadFile[]) => void +} { + const { data, update } = useEventEmitter(id) + + function setFiles(files: UploadFile[]) { + update({ files }) + } + + return { + files: data?.files || [], + setFiles, + } +} + +export default useUpload diff --git a/packages/dnb-eufemia/src/index.js b/packages/dnb-eufemia/src/index.js index a9c8a5eddcd..6c912c8414c 100644 --- a/packages/dnb-eufemia/src/index.js +++ b/packages/dnb-eufemia/src/index.js @@ -82,6 +82,7 @@ import Textarea from './components/textarea/Textarea' import Timeline from './components/timeline/Timeline' import ToggleButton from './components/toggle-button/ToggleButton' import Tooltip from './components/tooltip/Tooltip' +import Upload from './components/upload/Upload' import VisuallyHidden from './components/visually-hidden/VisuallyHidden' // define / export all the available components @@ -158,6 +159,7 @@ export { Timeline, ToggleButton, Tooltip, + Upload, VisuallyHidden, } diff --git a/packages/dnb-eufemia/src/shared/Context.tsx b/packages/dnb-eufemia/src/shared/Context.tsx index f549ed4c13f..99ff855be68 100644 --- a/packages/dnb-eufemia/src/shared/Context.tsx +++ b/packages/dnb-eufemia/src/shared/Context.tsx @@ -26,6 +26,7 @@ import type { DrawerProps } from '../components/drawer/types' import type { DialogProps } from '../components/dialog/types' import type { TooltipProps } from '../components/tooltip/types' import type { SectionProps } from '../components/section/Section' +import { UploadProps } from '../components/upload/types' // All TypeScript based Eufemia elements import type { AnchorProps } from '../elements/Anchor' @@ -53,6 +54,7 @@ export type ContextProps = { Tooltip?: Partial Section?: Partial ScrollView?: Partial + Upload?: Partial // -- TODO: Not converted yet -- diff --git a/packages/dnb-eufemia/src/shared/locales/en-GB.js b/packages/dnb-eufemia/src/shared/locales/en-GB.js index d1710e5137c..698d789d545 100644 --- a/packages/dnb-eufemia/src/shared/locales/en-GB.js +++ b/packages/dnb-eufemia/src/shared/locales/en-GB.js @@ -122,5 +122,17 @@ export default { Tag: { removeIconTitle: 'Remove', }, + Upload: { + title: 'Upload documents', + text: 'Drag & drop your files or choose files to upload. Files are converted to PDF after upload.', + formatsDescription: 'Allowed formats:', + fileSizeDescription: 'Max. filesize:', + fileSizeContent: '%size MB', + uploadButtonText: 'Browse files', + uploadLoadingText: 'Uploading', + uploadErrorLargeFile: + 'The file you are trying to upload is too big, the maximum size supported is %size MB. Please try again.', + deleteButton: 'Delete', + }, }, } diff --git a/packages/dnb-eufemia/src/shared/locales/nb-NO.js b/packages/dnb-eufemia/src/shared/locales/nb-NO.js index 0f0ff46d371..3f7a2d66cf0 100644 --- a/packages/dnb-eufemia/src/shared/locales/nb-NO.js +++ b/packages/dnb-eufemia/src/shared/locales/nb-NO.js @@ -122,5 +122,17 @@ export default { Tag: { removeIconTitle: 'Fjern', }, + Upload: { + title: 'Last opp dokumenter', + text: 'Dra & slipp eller velg hvilke filer du vil laste opp. Filene konverteres til PDF etter opplasting', + formatsDescription: 'Tilatte formater:', + fileSizeDescription: 'Maks filstørrelse:', + fileSizeContent: '%size MB', + uploadButtonText: 'Utforsk filer', + uploadLoadingText: 'Laster opp', + uploadErrorLargeFile: + 'Filen du prøver å laste opp er for stor, vi støtter ikke filer større enn %size MB. Vennligst prøv igjen.', + deleteButton: 'Slett', + }, }, } diff --git a/packages/dnb-eufemia/src/style/dnb-ui-components.scss b/packages/dnb-eufemia/src/style/dnb-ui-components.scss index 6ca4f51e95d..faf8f352a94 100644 --- a/packages/dnb-eufemia/src/style/dnb-ui-components.scss +++ b/packages/dnb-eufemia/src/style/dnb-ui-components.scss @@ -49,4 +49,5 @@ @import '../components/timeline/style/_timeline.scss'; @import '../components/toggle-button/style/_toggle-button.scss'; @import '../components/tooltip/style/_tooltip.scss'; +@import '../components/upload/style/_upload.scss'; @import '../components/visually-hidden/style/_visually-hidden.scss';