diff --git a/package.json b/package.json index 0771e51..3e4d29c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "4.2.24", + "version": "4.2.25-beta.2", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { @@ -22,6 +22,7 @@ "@babel/runtime": "^7.20.7", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", + "@mui/icons-material": "^7.3.9", "@mui/material": "^5.15.20", "@react-pdf/renderer": "^3.1.11", "awesome-bootstrap-checkbox": "^1.0.1", @@ -105,6 +106,7 @@ "peerDependencies": { "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", + "@mui/icons-material": "^5.15.20", "@mui/material": "^5.15.20", "@react-pdf/renderer": "^3.1.11", "awesome-bootstrap-checkbox": "^1.0.1", diff --git a/src/components/index.js b/src/components/index.js index c610b6a..f59a678 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -9,6 +9,7 @@ export {default as DateTimePicker} from './inputs/datetimepicker' export {default as GroupedDropdown} from './inputs/grouped-dropdown' export {default as UploadInput} from './inputs/upload-input' export {default as UploadInputV2} from './inputs/upload-input-v2' +export {default as UploadInputV3} from './inputs/upload-input-v3' export {default as CompanyInput} from './inputs/company-input' export {default as PromocodeInput} from './inputs/promocode-input' export {default as SponsorInput} from './inputs/sponsor-input' diff --git a/src/components/inputs/upload-input-v2/icon.js b/src/components/inputs/dropzone/icon.js similarity index 100% rename from src/components/inputs/upload-input-v2/icon.js rename to src/components/inputs/dropzone/icon.js diff --git a/src/components/inputs/upload-input-v2/dropzone.js b/src/components/inputs/dropzone/index.js similarity index 100% rename from src/components/inputs/upload-input-v2/dropzone.js rename to src/components/inputs/dropzone/index.js diff --git a/src/components/inputs/upload-input-v2/index.js b/src/components/inputs/upload-input-v2/index.js index 13cba97..c7f29b6 100644 --- a/src/components/inputs/upload-input-v2/index.js +++ b/src/components/inputs/upload-input-v2/index.js @@ -12,7 +12,7 @@ **/ import React from 'react' -import DropzoneJS from './dropzone' +import DropzoneJS from '../dropzone' import './index.less'; import file_icon from '../upload-input/file.png'; import ProgressiveImg from "../../progressive-img"; diff --git a/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js b/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js new file mode 100644 index 0000000..9381c47 --- /dev/null +++ b/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js @@ -0,0 +1,383 @@ +/** + * Copyright 2018 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + **/ + +import React from 'react'; +import Enzyme, { shallow, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import { act } from 'react-dom/test-utils'; +import UploadInputV3 from '../index'; +import { Box, Typography, IconButton, Alert } from '@mui/material'; + +Enzyme.configure({ adapter: new Adapter() }); + +// Capture the latest dropzone props so tests can trigger callbacks +let dropzoneCallbacks = {}; + +jest.mock('../dropzone-v3', () => ({ + __esModule: true, + DropzoneV3: function MockDropzoneV3(props) { + dropzoneCallbacks = props; + return
{props.children}
; + }, + default: function MockDropzoneV3(props) { + dropzoneCallbacks = props; + return
{props.children}
; + }, +})); + +describe('UploadInputV3', () => { + const defaultProps = { + value: [], + postUrl: 'https://example.com/upload', + id: 'test-upload', + mediaType: { + type: { + allowed_extensions: ['pdf', 'jpg', 'png'] + }, + max_size: 10485760 + } + }; + + beforeEach(() => { + dropzoneCallbacks = {}; + }); + + describe('Rendering', () => { + test('renders without crashing', () => { + const wrapper = shallow(); + expect(wrapper.find(Box).length).toBeGreaterThan(0); + }); + + test('renders label when provided', () => { + const wrapper = shallow(); + const label = wrapper.find(Typography).first(); + expect(label.children().text()).toBe('Upload File'); + }); + + test('renders helpText when provided', () => { + const helpText = 'Please upload PDF, JPG or PNG files'; + const wrapper = shallow(); + const helpTypography = wrapper.findWhere(node => + node.type() === Typography && node.prop('color') === 'text.secondary' + ); + expect(helpTypography.children().text()).toBe(helpText); + }); + + test('renders error message when error prop is provided', () => { + const errorMessage = 'File upload failed'; + const wrapper = shallow(); + const alert = wrapper.findWhere(node => + node.type() === Alert && node.prop('severity') === 'error' + ); + expect(alert.length).toBe(1); + expect(alert.children().text()).toBe(errorMessage); + }); + + test('does not render label when not provided', () => { + const wrapper = shallow(); + const labels = wrapper.findWhere(node => + node.type() === Typography && node.prop('fontWeight') === 600 + ); + expect(labels.length).toBe(0); + }); + + test('shows alert when postUrl is not provided', () => { + const wrapper = shallow(); + const alert = wrapper.findWhere(node => + node.type() === Alert && node.prop('severity') === 'error' + ); + expect(alert.length).toBe(1); + expect(alert.children().text()).toBe('No Post URL'); + }); + + test('shows alert when canAdd is false', () => { + const wrapper = shallow(); + const alert = wrapper.findWhere(node => + node.type() === Alert && node.prop('severity') === 'warning' + ); + expect(alert.length).toBe(1); + expect(alert.children().text()).toBe('Upload has been disabled by administrators.'); + }); + }); + + describe('File Display', () => { + test('displays uploaded file with filename', () => { + const files = [{ filename: 'document.pdf', size: 102400 }]; + const wrapper = mount(); + expect(wrapper.text()).toContain('document.pdf'); + wrapper.unmount(); + }); + + test('displays multiple uploaded files', () => { + const files = [ + { filename: 'document1.pdf', size: 102400 }, + { filename: 'document2.pdf', size: 204800 }, + ]; + const wrapper = mount(); + expect(wrapper.text()).toContain('document1.pdf'); + expect(wrapper.text()).toContain('document2.pdf'); + wrapper.unmount(); + }); + + test('shows Complete status for uploaded files', () => { + const files = [{ filename: 'document.pdf', size: 102400 }]; + const wrapper = mount(); + expect(wrapper.text()).toContain('Complete'); + wrapper.unmount(); + }); + + test('shows default size when size is not provided', () => { + const files = [{ filename: 'no-size.pdf' }]; + const wrapper = mount(); + expect(wrapper.text()).toContain('0 KB'); + wrapper.unmount(); + }); + + test('formats file size correctly', () => { + const files = [{ filename: 'large-file.pdf', size: 2048000 }]; + const wrapper = mount(); + expect(wrapper.text()).toContain('2 MB'); + wrapper.unmount(); + }); + }); + + describe('Delete Functionality', () => { + test('shows delete button when onRemove and canDelete are provided', () => { + const files = [{ filename: 'document.pdf', size: 102400 }]; + const wrapper = shallow( + + ); + expect(wrapper.find(IconButton).length).toBe(1); + }); + + test('calls onRemove when delete button is clicked', () => { + const files = [{ filename: 'document.pdf', size: 102400 }]; + const onRemoveMock = jest.fn(); + const wrapper = shallow( + + ); + wrapper.find(IconButton).first().simulate('click', { preventDefault: () => {} }); + expect(onRemoveMock).toHaveBeenCalledWith(files[0]); + }); + + test('does not show delete button when canDelete is false', () => { + const files = [{ filename: 'document.pdf', size: 102400 }]; + const wrapper = shallow( + + ); + expect(wrapper.find(IconButton).length).toBe(0); + }); + + test('does not show delete button when onRemove is not provided', () => { + const files = [{ filename: 'document.pdf', size: 102400 }]; + const wrapper = shallow(); + expect(wrapper.find(IconButton).length).toBe(0); + }); + }); + + describe('Upload States', () => { + test('shows dropzone when no file is uploading', () => { + const wrapper = mount(); + expect(wrapper.find('.dropzone-mock').length).toBe(1); + wrapper.unmount(); + }); + + test('hides dropzone while a file is uploading', () => { + const wrapper = mount(); + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'sample.png', size: 11264 }); + }); + wrapper.update(); + const dropzoneBox = wrapper.findWhere(n => + n.type() === Box && n.prop('sx') && n.prop('sx').display === 'none' + ); + expect(dropzoneBox.length).toBeGreaterThan(0); + wrapper.unmount(); + }); + + test('shows Loading status and progress bar while uploading', () => { + const wrapper = mount(); + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'sample.png', size: 11264 }); + }); + wrapper.update(); + expect(wrapper.text()).toContain('sample.png'); + expect(wrapper.text()).toContain('Loading'); + wrapper.unmount(); + }); + + test('shows Complete status and hides progress bar after upload finishes', () => { + const wrapper = mount(); + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'sample.png', size: 11264 }); + }); + wrapper.update(); + act(() => { + dropzoneCallbacks.onFileCompleted({ name: 'sample.png', size: 11264 }); + }); + wrapper.update(); + expect(wrapper.text()).toContain('Complete'); + expect(wrapper.text()).not.toContain('Loading'); + wrapper.unmount(); + }); + + test('hides dropzone when max files reached', () => { + const files = [{ filename: 'document.pdf', size: 102400 }]; + const wrapper = mount(); + expect(wrapper.find('.dropzone-mock').length).toBe(0); + wrapper.unmount(); + }); + + test('shows dropzone when below max files', () => { + const files = [{ filename: 'document.pdf', size: 102400 }]; + const wrapper = mount(); + expect(wrapper.find('.dropzone-mock').length).toBe(1); + wrapper.unmount(); + }); + }); + + describe('Error Handling', () => { + test('shows error row with filename and message when a file error occurs', () => { + const wrapper = mount(); + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'big-file.png', size: 9999999 }); + }); + wrapper.update(); + act(() => { + dropzoneCallbacks.onFileError( + { name: 'big-file.png', size: 9999999 }, + 'File is too big (9.54MiB). Max filesize: 5MiB.' + ); + }); + wrapper.update(); + expect(wrapper.text()).toContain('big-file.png'); + expect(wrapper.text()).toContain('File is too big (9.54MiB). Max filesize: 5MiB.'); + wrapper.unmount(); + }); + + test('removes the uploading row and shows error row when error occurs', () => { + const wrapper = mount(); + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'big-file.png', size: 9999999 }); + }); + wrapper.update(); + expect(wrapper.text()).toContain('Loading'); + + act(() => { + dropzoneCallbacks.onFileError( + { name: 'big-file.png', size: 9999999 }, + 'File is too big (9.54MiB). Max filesize: 5MiB.' + ); + }); + wrapper.update(); + expect(wrapper.text()).not.toContain('Loading'); + expect(wrapper.text()).toContain('File is too big'); + wrapper.unmount(); + }); + + test('dismissing an error removes it from the view and restores the dropzone', () => { + const wrapper = mount(); + act(() => { + dropzoneCallbacks.onFileError( + { name: 'big-file.png', size: 9999999 }, + 'File is too big (9.54MiB). Max filesize: 5MiB.' + ); + }); + wrapper.update(); + expect(wrapper.text()).toContain('File is too big'); + + const isDropzoneHidden = () => wrapper.findWhere(n => + n.type() === Box && n.prop('sx') && n.prop('sx').display === 'none' + ).length > 0; + expect(isDropzoneHidden()).toBe(true); + + const dismissButton = wrapper.findWhere(n => + n.type() === IconButton && n.prop('onClick') !== undefined + ).first(); + act(() => { + dismissButton.prop('onClick')(); + }); + wrapper.update(); + expect(wrapper.text()).not.toContain('File is too big'); + expect(isDropzoneHidden()).toBe(false); + wrapper.unmount(); + }); + + test('hides dropzone when an error is present', () => { + const wrapper = mount(); + act(() => { + dropzoneCallbacks.onFileError( + { name: 'big-file.png', size: 9999999 }, + 'File is too big (9.54MiB). Max filesize: 5MiB.' + ); + }); + wrapper.update(); + const dropzoneHidden = wrapper.findWhere(n => + n.type() === Box && n.prop('sx') && n.prop('sx').display === 'none' + ); + expect(dropzoneHidden.length).toBeGreaterThan(0); + wrapper.unmount(); + }); + }); + + describe('Configuration', () => { + test('uses custom getAllowedExtensions function when provided', () => { + const customGetExtensions = jest.fn(() => '.doc,.docx'); + shallow(); + expect(customGetExtensions).toHaveBeenCalled(); + }); + + test('uses custom getMaxSize function when provided', () => { + const customGetMaxSize = jest.fn(() => 500); + shallow(); + expect(customGetMaxSize).toHaveBeenCalled(); + }); + + test('shows dropzone and all files when below max files limit', () => { + const files = [ + { filename: 'doc1.pdf', size: 102400 }, + { filename: 'doc2.pdf', size: 102400 }, + ]; + const wrapper = mount(); + expect(wrapper.find('.dropzone-mock').length).toBe(1); + expect(wrapper.text()).toContain('doc1.pdf'); + expect(wrapper.text()).toContain('doc2.pdf'); + wrapper.unmount(); + }); + }); + + describe('Edge Cases', () => { + test('handles empty value array', () => { + const wrapper = mount(); + expect(wrapper.find('.dropzone-mock').length).toBe(1); + wrapper.unmount(); + }); + + test('handles mediaType without type property', () => { + const wrapper = mount(); + expect(wrapper.find('.dropzone-mock').length).toBe(1); + wrapper.unmount(); + }); + + test('handles missing mediaType', () => { + const wrapper = mount(); + expect(wrapper.find('.dropzone-mock').length).toBe(1); + wrapper.unmount(); + }); + + test('handles undefined value prop', () => { + const wrapper = shallow(); + expect(wrapper.find(Box).length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/components/inputs/upload-input-v3/dropzone-v3.js b/src/components/inputs/upload-input-v3/dropzone-v3.js new file mode 100644 index 0000000..e9e99ae --- /dev/null +++ b/src/components/inputs/upload-input-v3/dropzone-v3.js @@ -0,0 +1,67 @@ +/** + * Copyright 2018 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + **/ + +import React from 'react'; +import { DropzoneJS } from '../dropzone'; + +/** + * Thin wrapper around DropzoneJS that exposes file lifecycle callbacks + * without modifying the shared dropzone component. + */ +export const DropzoneV3 = ({ + onAddedFile, + onUploadProgress, + onFileRemoved, + onFileCompleted, + onFileError, + onDropzoneReady, + eventHandlers = {}, + children, + ...props +}) => { + const combinedEventHandlers = { + ...eventHandlers, + init: (dz) => { + if (onDropzoneReady) onDropzoneReady(dz); + if (eventHandlers.init) eventHandlers.init(dz); + }, + addedfile: (file) => { + if (onAddedFile) onAddedFile(file); + if (eventHandlers.addedfile) eventHandlers.addedfile(file); + }, + removedfile: (file) => { + if (onFileRemoved) onFileRemoved(file); + if (eventHandlers.removedfile) eventHandlers.removedfile(file); + }, + uploadprogress: (file, progress, bytesSent) => { + if (onUploadProgress) onUploadProgress(file, file.size > 0 ? bytesSent / file.size * 100 : 0); + if (eventHandlers.uploadprogress) eventHandlers.uploadprogress(file, progress, bytesSent); + }, + success: (file) => { + if (onFileCompleted) onFileCompleted(file); + if (eventHandlers.success) eventHandlers.success(file); + }, + error: (file, message) => { + if (onFileError) onFileError(file, message); + if (eventHandlers.error) eventHandlers.error(file, message); + }, + }; + + return ( + + {children} + + ); +}; + +export default DropzoneV3; diff --git a/src/components/inputs/upload-input-v3/index.js b/src/components/inputs/upload-input-v3/index.js new file mode 100644 index 0000000..51d9ed4 --- /dev/null +++ b/src/components/inputs/upload-input-v3/index.js @@ -0,0 +1,416 @@ +/** + * Copyright 2018 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + **/ + +import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react'; +import { + Box, + Typography, + IconButton, + Alert, + LinearProgress, +} from '@mui/material'; +import { + UploadFile as UploadFileIcon, + Delete as DeleteIcon, + CheckCircle as CheckCircleIcon, + ErrorOutline as ErrorOutlineIcon, + Close as CloseIcon, +} from '@mui/icons-material'; +import { DropzoneV3 } from './dropzone-v3'; +import './index.less'; + +const UploadInputV3 = ({ + value = [], + onRemove, + canAdd = true, + canDelete = true, + mediaType, + postUrl, + maxFiles = 1, + timeOut, + onUploadComplete, + djsConfig, + id, + parallelChunkUploads = false, + onError = () => {}, + getAllowedExtensions = null, + getMaxSize = null, + error, + label, + helpText +}) => { + const dropzoneInstanceRef = useRef(null); + const [uploadingFiles, setUploadingFiles] = useState([]); + const [errorFiles, setErrorFiles] = useState([]); + + const getDefaultAllowedExtensions = useCallback(() => { + return mediaType && mediaType.type + ? mediaType?.type?.allowed_extensions.map((ext) => `.${ext.toLowerCase()}`).join(",") + : ''; + }, [mediaType]); + + const getDefaultMaxSize = useCallback(() => { + return mediaType ? mediaType?.max_size / (1024 * 1024) : 100; + }, [mediaType]); + + const allowedExt = useMemo(() => + getAllowedExtensions ? getAllowedExtensions() : getDefaultAllowedExtensions(), + [getAllowedExtensions, getDefaultAllowedExtensions] + ); + + const maxSize = useMemo(() => + getMaxSize ? getMaxSize() : getDefaultMaxSize(), + [getMaxSize, getDefaultMaxSize] + ); + + const canUpload = useMemo(() => + !maxFiles || value.length < maxFiles, + [maxFiles, value.length] + ); + + const showDropzone = useMemo(() => + canUpload && uploadingFiles.length === 0 && errorFiles.length === 0, + [canUpload, uploadingFiles.length, errorFiles.length] + ); + + const eventHandlers = useMemo(() => { + return onRemove ? { removedfile: onRemove } : {}; + }, [onRemove]); + + const djsConfigSet = useMemo(() => ({ + paramName: "file", + maxFilesize: maxSize, + timeout: timeOut || (1000 * 60 * 10), + chunking: true, + retryChunks: true, + parallelChunkUploads: parallelChunkUploads, + addRemoveLinks: true, + maxFiles: maxFiles, + acceptedFiles: allowedExt, + dictDefaultMessage: '', + ...djsConfig + }), [maxSize, timeOut, parallelChunkUploads, maxFiles, allowedExt, djsConfig]); + + const componentConfig = useMemo(() => ({ + showFiletypeIcon: false, + postUrl: postUrl + }), [postUrl]); + + const data = useMemo(() => ({ + media_type: mediaType, + media_upload: value, + }), [mediaType, value]); + + const formatFileSize = useCallback((bytes) => { + if (!bytes) return '0 KB'; + if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`; + return `${Math.round(bytes / 1024)} KB`; + }, []); + + const formatExtensionsDisplay = useCallback(() => { + if (!allowedExt) return ''; + const exts = allowedExt.split(',') + .map(e => e.trim().replace('.', '').toUpperCase()) + .filter(Boolean); + if (exts.length === 0) return ''; + if (exts.length === 1) return exts[0]; + return `${exts.slice(0, -1).join(', ')} or ${exts[exts.length - 1]}`; + }, [allowedExt]); + + const handleRemove = useCallback((file) => (ev) => { + ev.preventDefault(); + onRemove(file); + }, [onRemove]); + + const handleDropzoneReady = useCallback((dz) => { + dropzoneInstanceRef.current = dz; + }, []); + + const handleAddedFile = useCallback((file) => { + setUploadingFiles(prev => [...prev, { name: file.name, size: file.size, progress: 0, complete: false }]); + }, []); + + const handleUploadProgress = useCallback((file, progress) => { + setUploadingFiles(prev => prev.map(f => + f.name === file.name && f.size === file.size ? { ...f, progress } : f + )); + }, []); + + const handleFileRemoved = useCallback((file) => { + setUploadingFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size))); + }, []); + + // Mark as complete instead of removing — keep it visible until value is updated by the parent + const handleFileCompleted = useCallback((file) => { + setUploadingFiles(prev => prev.map(f => + f.name === file.name && f.size === file.size ? { ...f, progress: 100, complete: true } : f + )); + }, []); + + // Once the parent updates value, remove the matching completed file from uploadingFiles + useEffect(() => { + if (uploadingFiles.length === 0 || value.length === 0) return; + setUploadingFiles(prev => prev.filter(f => { + if (!f.complete) return true; + return !value.some(v => v.filename === f.name); + })); + }, [value]); + + const handleFileError = useCallback((file, message) => { + setUploadingFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size))); + setErrorFiles(prev => [...prev, { name: file.name, size: file.size, message }]); + }, []); + + const handleDismissError = useCallback((file) => { + if (dropzoneInstanceRef.current) { + const dzFile = dropzoneInstanceRef.current.files?.find( + f => f.name === file.name && f.size === file.size + ); + if (dzFile) dropzoneInstanceRef.current.removeFile(dzFile); + } + setErrorFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size))); + }, []); + + const handleDeleteUploading = useCallback((file) => { + if (dropzoneInstanceRef.current) { + const dzFile = dropzoneInstanceRef.current.files?.find( + f => f.name === file.name && f.size === file.size + ); + if (dzFile) dropzoneInstanceRef.current.removeFile(dzFile); + } + setUploadingFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size))); + }, []); + + const wrappedOnUploadComplete = useCallback((response, dzId, dzData) => { + if (onUploadComplete) onUploadComplete(response, dzId, dzData); + }, [onUploadComplete]); + + const extDisplay = formatExtensionsDisplay(); + + const renderDropzone = () => { + if (!postUrl) { + return ( + + No Post URL + + ); + } + if (!canAdd) { + return ( + + Upload has been disabled by administrators. + + ); + } + + return ( + + + + + Click to upload or drag and drop + + {(extDisplay || maxSize) && ( + + {extDisplay ? `${extDisplay} files` : ''} + {maxSize ? ` (max. ${maxSize}MB)` : ''} + + )} + + + ); + }; + + const fileRowSx = { + display: 'flex', + alignItems: 'center', + py: 1.5, + mb: 1, + }; + + return ( + + {label && ( + + {label} + + )} + + {helpText && ( + + {helpText} + + )} + + {canUpload && ( + + {renderDropzone()} + + )} + + {error && ( + + {error} + + )} + + {uploadingFiles.length > 0 && ( + + {uploadingFiles.map((file, index) => ( + + + + + + + + {file.name} + + + {formatFileSize(file.size)} · {file.complete ? 'Complete' : 'Loading'} + + {!file.complete && ( + + )} + + + + handleDeleteUploading(file)} + sx={{ color: 'text.secondary', '&:hover': { color: 'error.main' } }} + > + + + {file.complete && ( + + )} + + + ))} + + )} + + {errorFiles.length > 0 && ( + + {errorFiles.map((file, index) => ( + + + + + + + + {file.name} + + + {file.message} + + + + handleDismissError(file)} + sx={{ color: 'error.main' }} + > + + + + ))} + + )} + + {value.length > 0 && ( + + {value.map((file, index) => { + const filename = file.filename; + const fileSize = formatFileSize(file.size); + + return ( + + + + + + + + {filename} + + + {fileSize} · Complete + + + + + {onRemove && canDelete && ( + + + + )} + + + + ); + })} + + )} + + + ); +}; + +export default UploadInputV3; diff --git a/src/components/inputs/upload-input-v3/index.less b/src/components/inputs/upload-input-v3/index.less new file mode 100644 index 0000000..6b6d0c8 --- /dev/null +++ b/src/components/inputs/upload-input-v3/index.less @@ -0,0 +1,50 @@ +/* Dropzone styles for v3 with MUI */ +.upload-input-v3 { + .dropzone { + border: 1px dashed #d0d5dd; + border-radius: 8px; + background-color: #ffffff; + padding: 40px 20px; + min-height: 160px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s ease, border-color 0.2s ease; + + &.dz-drag-hover { + border-color: #1976d2; + background-color: #e8f0fe; + } + + .dz-message, + .dz-preview { + display: none; + } + + .dz-custom-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + pointer-events: none; + + .dz-custom-icon { + font-size: 40px !important; + color: #1976d2; + margin-bottom: 8px; + } + + .dz-click-text { + color: #1976d2; + text-decoration: underline; + } + + .dz-custom-hint { + color: #9e9e9e; + margin-top: 4px; + } + } + } +} diff --git a/webpack.common.js b/webpack.common.js index 49847dd..6fc5e8c 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -38,6 +38,7 @@ module.exports = { 'components/inputs/grouped-dropdown': './src/components/inputs/grouped-dropdown/index.js', 'components/inputs/upload-input': './src/components/inputs/upload-input/index.js', 'components/inputs/upload-input-v2': './src/components/inputs/upload-input-v2/index.js', + 'components/inputs/upload-input-v3': './src/components/inputs/upload-input-v3/index.js', 'components/inputs/access-levels-input': './src/components/inputs/access-levels-input.js', 'components/inputs/checkbox-list': './src/components/inputs/checkbox-list.js', 'components/inputs/company-input': './src/components/inputs/company-input.js', diff --git a/yarn.lock b/yarn.lock index 2c2393f..09f736a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1039,6 +1039,11 @@ dependencies: regenerator-runtime "^0.13.11" +"@babel/runtime@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.6.tgz#d267a43cb1836dc4d182cce93ae75ba954ef6d2b" + integrity sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA== + "@babel/template@^7.16.7", "@babel/template@^7.3.3": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.7.tgz#8d126c8701fde4d66b264b3eba3d96f07666d155" @@ -1607,6 +1612,13 @@ resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.17.1.tgz#49b88ecb68b800431b5c2f2bfb71372d1f1478fa" integrity sha512-OcZj+cs6EfUD39IoPBOgN61zf1XFVY+imsGoBDwXeSq2UHJZE3N59zzBOVjclck91Ne3e9gudONOeILvHCIhUA== +"@mui/icons-material@^7.3.9": + version "7.3.9" + resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-7.3.9.tgz#4f6dc62bfe8954f3848b0eecb3650cff10f6a7ec" + integrity sha512-BT+zPJXss8Hg/oEMRmHl17Q97bPACG4ufFSfGEdhiE96jOyR5Dz1ty7ZWt1fVGR0y1p+sSgEwQT/MNZQmoWDCw== + dependencies: + "@babel/runtime" "^7.28.6" + "@mui/material@^5.15.20": version "5.17.1" resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.17.1.tgz#596f542a51fc74db75da2df66565b4874ce4049d"