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"