From 06cbcb480bc5411e4bb46c168b511680a972d958 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 11 Mar 2026 11:35:35 -0300 Subject: [PATCH 1/6] feat: build upload input v3 and tests --- package.json | 2 + .../__tests__/upload-input-v3.test.js | 407 ++++++++++++++++++ .../inputs/upload-input-v3/index.js | 260 +++++++++++ .../inputs/upload-input-v3/index.less | 45 ++ yarn.lock | 12 + 5 files changed, 726 insertions(+) create mode 100644 src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js create mode 100644 src/components/inputs/upload-input-v3/index.js create mode 100644 src/components/inputs/upload-input-v3/index.less diff --git a/package.json b/package.json index 0771e51..6b54f66 100644 --- a/package.json +++ b/package.json @@ -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": "^7.3.9", "@mui/material": "^5.15.20", "@react-pdf/renderer": "^3.1.11", "awesome-bootstrap-checkbox": "^1.0.1", 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..f236635 --- /dev/null +++ b/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js @@ -0,0 +1,407 @@ +/** + * 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 UploadInputV3 from '../index'; +import { Box, Typography, Paper, IconButton, Button, Alert } from '@mui/material'; + +Enzyme.configure({ adapter: new Adapter() }); + +// Mock DropzoneJS component +jest.mock('../../upload-input-v2/dropzone', () => { + return function MockDropzoneJS(props) { + return
Dropzone Component
; + }; +}); + +describe('UploadInputV3', () => { + const defaultProps = { + value: [], + postUrl: 'https://example.com/upload', + mediaType: { + type: { + allowed_extensions: ['pdf', 'jpg', 'png'] + }, + max_size: 1024000 + } + }; + + 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); + }); + }); + + describe('File Display', () => { + test('displays uploaded files', () => { + const files = [ + { + filename: 'document.pdf', + size: 102400, + public_url: 'https://example.com/document.pdf' + } + ]; + const wrapper = shallow(); + const papers = wrapper.find(Paper); + expect(papers.length).toBe(1); + + const fileTypography = papers.first().findWhere(node => + node.type() === Typography && node.prop('fontWeight') === 500 + ); + expect(fileTypography.children().text()).toBe('document.pdf'); + }); + + test('displays multiple uploaded files', () => { + const files = [ + { + filename: 'document1.pdf', + size: 102400, + public_url: 'https://example.com/document1.pdf' + }, + { + filename: 'document2.pdf', + size: 204800, + public_url: 'https://example.com/document2.pdf' + } + ]; + const wrapper = shallow(); + const papers = wrapper.find(Paper); + expect(papers.length).toBe(2); + }); + + test('formats file size correctly', () => { + const files = [ + { + filename: 'large-file.pdf', + size: 2048000, + public_url: 'https://example.com/large-file.pdf' + } + ]; + const wrapper = shallow(); + const papers = wrapper.find(Paper); + expect(papers.length).toBe(1); + const sizeTypography = papers.first().find(Typography).filterWhere(n => n.prop('variant') === 'caption'); + expect(sizeTypography.length).toBe(1); + }); + + test('shows default size when size is not provided', () => { + const files = [ + { + filename: 'no-size.pdf', + public_url: 'https://example.com/no-size.pdf' + } + ]; + const wrapper = shallow(); + const papers = wrapper.find(Paper); + expect(papers.length).toBe(1); + const sizeTypography = papers.first().find(Typography).filterWhere(n => n.prop('variant') === 'caption'); + expect(sizeTypography.length).toBe(1); + }); + }); + + describe('Delete Functionality', () => { + test('shows delete button when onRemove is provided and canDelete is true', () => { + const files = [ + { + filename: 'document.pdf', + size: 102400, + public_url: 'https://example.com/document.pdf' + } + ]; + const onRemoveMock = jest.fn(); + const wrapper = shallow( + + ); + + const deleteButtons = wrapper.find(IconButton); + expect(deleteButtons.length).toBe(1); + }); + + test('calls onRemove when delete button is clicked', () => { + const files = [ + { + filename: 'document.pdf', + size: 102400, + public_url: 'https://example.com/document.pdf' + } + ]; + const onRemoveMock = jest.fn(); + const wrapper = shallow( + + ); + + const deleteButton = wrapper.find(IconButton).first(); + deleteButton.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, + public_url: 'https://example.com/document.pdf' + } + ]; + const onRemoveMock = jest.fn(); + const wrapper = shallow( + + ); + + const deleteButtons = wrapper.find(IconButton); + expect(deleteButtons.length).toBe(0); + }); + + test('does not show delete button when onRemove is not provided', () => { + const files = [ + { + filename: 'document.pdf', + size: 102400, + public_url: 'https://example.com/document.pdf' + } + ]; + const wrapper = shallow( + + ); + + const deleteButtons = wrapper.find(IconButton); + expect(deleteButtons.length).toBe(0); + }); + }); + + describe('Upload States', () => { + test('shows dropzone when can upload', () => { + const wrapper = mount(); + const dropzone = wrapper.find('.dropzone-mock'); + expect(dropzone.length).toBe(1); + wrapper.unmount(); + }); + + 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'); + + const dropzone = wrapper.find('.dropzone-mock'); + expect(dropzone.length).toBe(0); + }); + + 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.'); + + const dropzone = wrapper.find('.dropzone-mock'); + expect(dropzone.length).toBe(0); + }); + + test('shows button and files when max files reached', () => { + const files = [ + { + filename: 'document.pdf', + size: 102400, + public_url: 'https://example.com/document.pdf' + } + ]; + const wrapper = mount(); + + // Should show disabled button when max files reached + const uploadButton = wrapper.find(Button); + expect(uploadButton.length).toBe(1); + expect(uploadButton.prop('disabled')).toBe(true); + + // Should not show dropzone + const dropzone = wrapper.find('.dropzone-mock'); + expect(dropzone.length).toBe(0); + + // Should show the uploaded file + const papers = wrapper.find(Paper); + expect(papers.length).toBe(1); + + wrapper.unmount(); + }); + + test('shows disabled upload button when cannot upload', () => { + const files = [ + { + filename: 'document.pdf', + size: 102400, + public_url: 'https://example.com/document.pdf' + } + ]; + const wrapper = shallow(); + const uploadButton = wrapper.find(Button); + expect(uploadButton.length).toBe(1); + expect(uploadButton.prop('disabled')).toBe(true); + }); + }); + + 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('handles multiple files configuration', () => { + const files = [ + { + filename: 'doc1.pdf', + size: 102400, + public_url: 'https://example.com/doc1.pdf' + }, + { + filename: 'doc2.pdf', + size: 102400, + public_url: 'https://example.com/doc2.pdf' + } + ]; + + const wrapper = mount( + + ); + + const dropzone = wrapper.find('.dropzone-mock'); + expect(dropzone.length).toBe(1); + + const papers = wrapper.find(Paper); + expect(papers.length).toBe(2); + wrapper.unmount(); + }); + }); + + describe('Edge Cases', () => { + test('handles empty value array', () => { + const wrapper = mount(); + const dropzone = wrapper.find('.dropzone-mock'); + expect(dropzone.length).toBe(1); + + const papers = wrapper.find(Paper); + expect(papers.length).toBe(0); + wrapper.unmount(); + }); + + test('handles mediaType without type property', () => { + const propsWithoutType = { + ...defaultProps, + mediaType: { max_size: 1024000 } + }; + const wrapper = mount(); + const dropzone = wrapper.find('.dropzone-mock'); + expect(dropzone.length).toBe(1); + wrapper.unmount(); + }); + + test('handles missing mediaType', () => { + const propsWithoutMediaType = { + value: [], + postUrl: 'https://example.com/upload' + }; + const wrapper = mount(); + const dropzone = wrapper.find('.dropzone-mock'); + expect(dropzone.length).toBe(1); + wrapper.unmount(); + }); + + test('handles undefined value prop', () => { + const propsWithUndefinedValue = { + postUrl: 'https://example.com/upload', + mediaType: defaultProps.mediaType + }; + const wrapper = shallow(); + expect(wrapper.find(Box).length).toBeGreaterThan(0); + }); + }); +}); 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..66478f6 --- /dev/null +++ b/src/components/inputs/upload-input-v3/index.js @@ -0,0 +1,260 @@ +/** + * 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, { useMemo, useCallback } from 'react'; +import { + Box, + Typography, + Paper, + IconButton, + Button, + Alert +} from '@mui/material'; +import { + InsertDriveFile as FileIcon, + Delete as DeleteIcon, + CheckCircle as CheckCircleIcon +} from '@mui/icons-material'; +import DropzoneJS from '../upload-input-v2/dropzone'; +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 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 : 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 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, + ...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 '100kb'; + return `${Math.round(bytes / 1024)}kb`; + }, []); + + const handleRemove = useCallback((file) => (ev) => { + ev.preventDefault(); + onRemove(file); + }, [onRemove]); + + const renderDropzone = () => { + if (!postUrl) { + return ( + + No Post URL + + ); + } + if (!canAdd) { + return ( + + Upload has been disabled by administrators. + + ); + } + if (!canUpload) { + return ( + + Max number of files uploaded for this type - Remove uploaded file to add new file. + + ); + } + + return ( + + ); + }; + + return ( + + {label && ( + + {label} + + )} + + {helpText && ( + + {helpText} + + )} + + + {canUpload && renderDropzone()} + + + {error && ( + + {error} + + )} + + {value.length > 0 && ( + + {value.map((file, index) => { + const filename = file.filename; + const fileSize = formatFileSize(file.size); + + return ( + + + + + + + + {filename} + + + {fileSize} · Complete + + + + + {onRemove && canDelete && ( + + + + )} + + + + ); + })} + + )} + + {!canUpload && ( + + )} + + ); +}; + +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..05a7754 --- /dev/null +++ b/src/components/inputs/upload-input-v3/index.less @@ -0,0 +1,45 @@ +/* Dropzone styles for v3 with MUI */ +.dropzone { + border: 2px dashed #1976d2; + border-radius: 8px; + background-color: #f8f9fa; + padding: 40px 20px; + min-height: 200px; + transition: all 0.3s ease; + cursor: pointer; + + &:hover { + border-color: #1565c0; + background-color: #e3f2fd; + } + + .dz-message { + text-align: center; + margin: 0; + + i, svg { + display: block; + font-size: 48px; + color: #1976d2; + margin: 0 auto 16px; + } + + span { + display: block; + font-size: 14px; + color: #666; + } + } + + .dz-preview { + display: none; + } +} + +/* Custom styles for the dropzone message */ +.dz-default.dz-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} 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" From f9ff351f5dd8eecbe8ea5abe39aa2d5857ec875c Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Thu, 12 Mar 2026 11:03:54 -0300 Subject: [PATCH 2/6] feat: new mui version of upload input V3 --- src/components/index.js | 1 + .../{upload-input-v2 => dropzone}/icon.js | 0 .../dropzone.js => dropzone/index.js} | 0 .../inputs/upload-input-v2/index.js | 2 +- .../__tests__/upload-input-v3.test.js | 458 +++++++++--------- .../inputs/upload-input-v3/dropzone-v3.js | 67 +++ .../inputs/upload-input-v3/index.js | 280 ++++++++--- .../inputs/upload-input-v3/index.less | 79 +-- webpack.common.js | 1 + 9 files changed, 547 insertions(+), 341 deletions(-) rename src/components/inputs/{upload-input-v2 => dropzone}/icon.js (100%) rename src/components/inputs/{upload-input-v2/dropzone.js => dropzone/index.js} (100%) create mode 100644 src/components/inputs/upload-input-v3/dropzone-v3.js 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 index f236635..dc1a63a 100644 --- 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 @@ -14,22 +14,32 @@ 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, Paper, IconButton, Button, Alert } from '@mui/material'; +import { Box, Typography, IconButton, Alert } from '@mui/material'; Enzyme.configure({ adapter: new Adapter() }); -// Mock DropzoneJS component -jest.mock('../../upload-input-v2/dropzone', () => { - return function MockDropzoneJS(props) { - return
Dropzone Component
; - }; -}); +// 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'] @@ -38,6 +48,10 @@ describe('UploadInputV3', () => { } }; + beforeEach(() => { + dropzoneCallbacks = {}; + }); + describe('Rendering', () => { test('renders without crashing', () => { const wrapper = shallow(); @@ -76,288 +90,268 @@ describe('UploadInputV3', () => { ); expect(labels.length).toBe(0); }); - }); - describe('File Display', () => { - test('displays uploaded files', () => { - const files = [ - { - filename: 'document.pdf', - size: 102400, - public_url: 'https://example.com/document.pdf' - } - ]; - const wrapper = shallow(); - const papers = wrapper.find(Paper); - expect(papers.length).toBe(1); + 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'); + }); - const fileTypography = papers.first().findWhere(node => - node.type() === Typography && node.prop('fontWeight') === 500 + test('shows alert when canAdd is false', () => { + const wrapper = shallow(); + const alert = wrapper.findWhere(node => + node.type() === Alert && node.prop('severity') === 'warning' ); - expect(fileTypography.children().text()).toBe('document.pdf'); + 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, - public_url: 'https://example.com/document1.pdf' - }, - { - filename: 'document2.pdf', - size: 204800, - public_url: 'https://example.com/document2.pdf' - } + { filename: 'document1.pdf', size: 102400 }, + { filename: 'document2.pdf', size: 204800 }, ]; - const wrapper = shallow(); - const papers = wrapper.find(Paper); - expect(papers.length).toBe(2); + const wrapper = mount(); + expect(wrapper.text()).toContain('document1.pdf'); + expect(wrapper.text()).toContain('document2.pdf'); + wrapper.unmount(); }); - test('formats file size correctly', () => { - const files = [ - { - filename: 'large-file.pdf', - size: 2048000, - public_url: 'https://example.com/large-file.pdf' - } - ]; - const wrapper = shallow(); - const papers = wrapper.find(Paper); - expect(papers.length).toBe(1); - const sizeTypography = papers.first().find(Typography).filterWhere(n => n.prop('variant') === 'caption'); - expect(sizeTypography.length).toBe(1); + 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', - public_url: 'https://example.com/no-size.pdf' - } - ]; - const wrapper = shallow(); - const papers = wrapper.find(Paper); - expect(papers.length).toBe(1); - const sizeTypography = papers.first().find(Typography).filterWhere(n => n.prop('variant') === 'caption'); - expect(sizeTypography.length).toBe(1); + const files = [{ filename: 'no-size.pdf' }]; + const wrapper = mount(); + expect(wrapper.text()).toContain('100kb'); + wrapper.unmount(); + }); + + test('formats file size correctly', () => { + const files = [{ filename: 'large-file.pdf', size: 2048000 }]; + const wrapper = mount(); + expect(wrapper.text()).toContain('2000kb'); + wrapper.unmount(); }); }); describe('Delete Functionality', () => { - test('shows delete button when onRemove is provided and canDelete is true', () => { - const files = [ - { - filename: 'document.pdf', - size: 102400, - public_url: 'https://example.com/document.pdf' - } - ]; - const onRemoveMock = jest.fn(); + test('shows delete button when onRemove and canDelete are provided', () => { + const files = [{ filename: 'document.pdf', size: 102400 }]; const wrapper = shallow( - + ); - - const deleteButtons = wrapper.find(IconButton); - expect(deleteButtons.length).toBe(1); + expect(wrapper.find(IconButton).length).toBe(1); }); test('calls onRemove when delete button is clicked', () => { - const files = [ - { - filename: 'document.pdf', - size: 102400, - public_url: 'https://example.com/document.pdf' - } - ]; + const files = [{ filename: 'document.pdf', size: 102400 }]; const onRemoveMock = jest.fn(); const wrapper = shallow( - + ); - - const deleteButton = wrapper.find(IconButton).first(); - deleteButton.simulate('click', { preventDefault: () => {} }); - + 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, - public_url: 'https://example.com/document.pdf' - } - ]; - const onRemoveMock = jest.fn(); + const files = [{ filename: 'document.pdf', size: 102400 }]; const wrapper = shallow( - + ); - - const deleteButtons = wrapper.find(IconButton); - expect(deleteButtons.length).toBe(0); + 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, - public_url: 'https://example.com/document.pdf' - } - ]; - const wrapper = shallow( - - ); - - const deleteButtons = wrapper.find(IconButton); - expect(deleteButtons.length).toBe(0); + const files = [{ filename: 'document.pdf', size: 102400 }]; + const wrapper = shallow(); + expect(wrapper.find(IconButton).length).toBe(0); }); }); describe('Upload States', () => { - test('shows dropzone when can upload', () => { + test('shows dropzone when no file is uploading', () => { const wrapper = mount(); - const dropzone = wrapper.find('.dropzone-mock'); - expect(dropzone.length).toBe(1); + expect(wrapper.find('.dropzone-mock').length).toBe(1); wrapper.unmount(); }); - test('shows alert when postUrl is not provided', () => { - const wrapper = shallow(); - const alert = wrapper.findWhere(node => - node.type() === Alert && node.prop('severity') === 'error' + 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(alert.length).toBe(1); - expect(alert.children().text()).toBe('No Post URL'); - - const dropzone = wrapper.find('.dropzone-mock'); - expect(dropzone.length).toBe(0); + expect(dropzoneBox.length).toBeGreaterThan(0); + wrapper.unmount(); }); - 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.'); + 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(); + }); - const dropzone = wrapper.find('.dropzone-mock'); - expect(dropzone.length).toBe(0); + 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('shows button and files when max files reached', () => { - const files = [ - { - filename: 'document.pdf', - size: 102400, - public_url: 'https://example.com/document.pdf' - } - ]; + 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(); + }); - // Should show disabled button when max files reached - const uploadButton = wrapper.find(Button); - expect(uploadButton.length).toBe(1); - expect(uploadButton.prop('disabled')).toBe(true); + 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(); + }); + }); - // Should not show dropzone - const dropzone = wrapper.find('.dropzone-mock'); - expect(dropzone.length).toBe(0); + 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(); + }); - // Should show the uploaded file - const papers = wrapper.find(Paper); - expect(papers.length).toBe(1); + 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('shows disabled upload button when cannot upload', () => { - const files = [ - { - filename: 'document.pdf', - size: 102400, - public_url: 'https://example.com/document.pdf' - } - ]; - const wrapper = shallow(); - const uploadButton = wrapper.find(Button); - expect(uploadButton.length).toBe(1); - expect(uploadButton.prop('disabled')).toBe(true); + 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( - - ); + shallow(); expect(customGetExtensions).toHaveBeenCalled(); }); test('uses custom getMaxSize function when provided', () => { const customGetMaxSize = jest.fn(() => 500); - shallow( - - ); + shallow(); expect(customGetMaxSize).toHaveBeenCalled(); }); - test('handles multiple files configuration', () => { + test('shows dropzone and all files when below max files limit', () => { const files = [ - { - filename: 'doc1.pdf', - size: 102400, - public_url: 'https://example.com/doc1.pdf' - }, - { - filename: 'doc2.pdf', - size: 102400, - public_url: 'https://example.com/doc2.pdf' - } + { filename: 'doc1.pdf', size: 102400 }, + { filename: 'doc2.pdf', size: 102400 }, ]; - - const wrapper = mount( - - ); - - const dropzone = wrapper.find('.dropzone-mock'); - expect(dropzone.length).toBe(1); - - const papers = wrapper.find(Paper); - expect(papers.length).toBe(2); + 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(); }); }); @@ -365,42 +359,24 @@ describe('UploadInputV3', () => { describe('Edge Cases', () => { test('handles empty value array', () => { const wrapper = mount(); - const dropzone = wrapper.find('.dropzone-mock'); - expect(dropzone.length).toBe(1); - - const papers = wrapper.find(Paper); - expect(papers.length).toBe(0); + expect(wrapper.find('.dropzone-mock').length).toBe(1); wrapper.unmount(); }); test('handles mediaType without type property', () => { - const propsWithoutType = { - ...defaultProps, - mediaType: { max_size: 1024000 } - }; - const wrapper = mount(); - const dropzone = wrapper.find('.dropzone-mock'); - expect(dropzone.length).toBe(1); + const wrapper = mount(); + expect(wrapper.find('.dropzone-mock').length).toBe(1); wrapper.unmount(); }); test('handles missing mediaType', () => { - const propsWithoutMediaType = { - value: [], - postUrl: 'https://example.com/upload' - }; - const wrapper = mount(); - const dropzone = wrapper.find('.dropzone-mock'); - expect(dropzone.length).toBe(1); + const wrapper = mount(); + expect(wrapper.find('.dropzone-mock').length).toBe(1); wrapper.unmount(); }); test('handles undefined value prop', () => { - const propsWithUndefinedValue = { - postUrl: 'https://example.com/upload', - mediaType: defaultProps.mediaType - }; - const wrapper = shallow(); + 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..183a4e0 --- /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, bytesSent / file.size * 100); + 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 index 66478f6..33bad02 100644 --- a/src/components/inputs/upload-input-v3/index.js +++ b/src/components/inputs/upload-input-v3/index.js @@ -11,21 +11,22 @@ * limitations under the License. **/ -import React, { useMemo, useCallback } from 'react'; +import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react'; import { Box, Typography, - Paper, IconButton, - Button, - Alert + Alert, + LinearProgress, } from '@mui/material'; import { - InsertDriveFile as FileIcon, + UploadFile as UploadFileIcon, Delete as DeleteIcon, - CheckCircle as CheckCircleIcon + CheckCircle as CheckCircleIcon, + ErrorOutline as ErrorOutlineIcon, + Close as CloseIcon, } from '@mui/icons-material'; -import DropzoneJS from '../upload-input-v2/dropzone'; +import { DropzoneV3 } from './dropzone-v3'; import './index.less'; const UploadInputV3 = ({ @@ -48,6 +49,9 @@ const UploadInputV3 = ({ label, helpText }) => { + const dropzoneInstanceRef = useRef(null); + const [uploadingFiles, setUploadingFiles] = useState([]); + const [errorFiles, setErrorFiles] = useState([]); const getDefaultAllowedExtensions = useCallback(() => { return mediaType && mediaType.type @@ -74,6 +78,11 @@ const UploadInputV3 = ({ [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]); @@ -88,6 +97,7 @@ const UploadInputV3 = ({ addRemoveLinks: true, maxFiles: maxFiles, acceptedFiles: allowedExt, + dictDefaultMessage: '', ...djsConfig }), [maxSize, timeOut, parallelChunkUploads, maxFiles, allowedExt, djsConfig]); @@ -106,11 +116,86 @@ const UploadInputV3 = ({ 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 ( @@ -126,30 +211,49 @@ const UploadInputV3 = ({ ); } - if (!canUpload) { - return ( - - Max number of files uploaded for this type - Remove uploaded file to add new file. - - ); - } return ( - + onDropzoneReady={handleDropzoneReady} + onAddedFile={handleAddedFile} + onUploadProgress={handleUploadProgress} + onFileRemoved={handleFileRemoved} + onFileCompleted={handleFileCompleted} + onFileError={handleFileError} + > + + + + 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} @@ -162,9 +266,11 @@ const UploadInputV3 = ({ )} - - {canUpload && renderDropzone()} - + {canUpload && ( + + {renderDropzone()} + + )} {error && ( @@ -172,6 +278,90 @@ const UploadInputV3 = ({ )} + {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) => { @@ -179,41 +369,20 @@ const UploadInputV3 = ({ const fileSize = formatFileSize(file.size); return ( - - - + + {filename} @@ -227,32 +396,19 @@ const UploadInputV3 = ({ )} - + ); })} )} - {!canUpload && ( - - )} ); }; diff --git a/src/components/inputs/upload-input-v3/index.less b/src/components/inputs/upload-input-v3/index.less index 05a7754..6b6d0c8 100644 --- a/src/components/inputs/upload-input-v3/index.less +++ b/src/components/inputs/upload-input-v3/index.less @@ -1,45 +1,50 @@ /* Dropzone styles for v3 with MUI */ -.dropzone { - border: 2px dashed #1976d2; - border-radius: 8px; - background-color: #f8f9fa; - padding: 40px 20px; - min-height: 200px; - transition: all 0.3s ease; - cursor: pointer; +.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; - &:hover { - border-color: #1565c0; - background-color: #e3f2fd; - } - - .dz-message { - text-align: center; - margin: 0; - - i, svg { - display: block; - font-size: 48px; - color: #1976d2; - margin: 0 auto 16px; + &.dz-drag-hover { + border-color: #1976d2; + background-color: #e8f0fe; } - span { - display: block; - font-size: 14px; - color: #666; + .dz-message, + .dz-preview { + display: none; } - } - .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; + } -/* Custom styles for the dropzone message */ -.dz-default.dz-message { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; + .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', From f948cfac23b784c99a9eb2b42cf6dcc69cdd6fcb Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Thu, 12 Mar 2026 11:06:43 -0300 Subject: [PATCH 3/6] v4.2.25-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6b54f66..3aadc8f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "4.2.24", + "version": "4.2.25-beta.1", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From 79ff728c5fd8444854f99bafa6b0e575ec4b6363 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Thu, 12 Mar 2026 16:05:09 -0300 Subject: [PATCH 4/6] fix: copilot feedback --- package.json | 2 +- src/components/inputs/upload-input-v3/index.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index 3aadc8f..a35f441 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "peerDependencies": { "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", - "@mui/icons-material": "^7.3.9", + "@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/inputs/upload-input-v3/index.js b/src/components/inputs/upload-input-v3/index.js index 33bad02..73e2f46 100644 --- a/src/components/inputs/upload-input-v3/index.js +++ b/src/components/inputs/upload-input-v3/index.js @@ -371,7 +371,6 @@ const UploadInputV3 = ({ return ( From c9cd278d7f510a993c4cdc7c2532a9a9766d2fcf Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 18 Mar 2026 12:42:01 -0300 Subject: [PATCH 5/6] fix: few tweaks --- .../upload-input-v3/__tests__/upload-input-v3.test.js | 6 +++--- src/components/inputs/upload-input-v3/dropzone-v3.js | 2 +- src/components/inputs/upload-input-v3/index.js | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) 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 index dc1a63a..9381c47 100644 --- 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 @@ -44,7 +44,7 @@ describe('UploadInputV3', () => { type: { allowed_extensions: ['pdf', 'jpg', 'png'] }, - max_size: 1024000 + max_size: 10485760 } }; @@ -139,14 +139,14 @@ describe('UploadInputV3', () => { test('shows default size when size is not provided', () => { const files = [{ filename: 'no-size.pdf' }]; const wrapper = mount(); - expect(wrapper.text()).toContain('100kb'); + 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('2000kb'); + expect(wrapper.text()).toContain('2 MB'); wrapper.unmount(); }); }); diff --git a/src/components/inputs/upload-input-v3/dropzone-v3.js b/src/components/inputs/upload-input-v3/dropzone-v3.js index 183a4e0..e9e99ae 100644 --- a/src/components/inputs/upload-input-v3/dropzone-v3.js +++ b/src/components/inputs/upload-input-v3/dropzone-v3.js @@ -44,7 +44,7 @@ export const DropzoneV3 = ({ if (eventHandlers.removedfile) eventHandlers.removedfile(file); }, uploadprogress: (file, progress, bytesSent) => { - if (onUploadProgress) onUploadProgress(file, bytesSent / file.size * 100); + if (onUploadProgress) onUploadProgress(file, file.size > 0 ? bytesSent / file.size * 100 : 0); if (eventHandlers.uploadprogress) eventHandlers.uploadprogress(file, progress, bytesSent); }, success: (file) => { diff --git a/src/components/inputs/upload-input-v3/index.js b/src/components/inputs/upload-input-v3/index.js index 73e2f46..51d9ed4 100644 --- a/src/components/inputs/upload-input-v3/index.js +++ b/src/components/inputs/upload-input-v3/index.js @@ -60,7 +60,7 @@ const UploadInputV3 = ({ }, [mediaType]); const getDefaultMaxSize = useCallback(() => { - return mediaType ? mediaType?.max_size / 1024 : 100; + return mediaType ? mediaType?.max_size / (1024 * 1024) : 100; }, [mediaType]); const allowedExt = useMemo(() => @@ -112,8 +112,9 @@ const UploadInputV3 = ({ }), [mediaType, value]); const formatFileSize = useCallback((bytes) => { - if (!bytes) return '100kb'; - return `${Math.round(bytes / 1024)}kb`; + 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(() => { From 90f945e64f7d245a038fee199d239b073a40e201 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 18 Mar 2026 15:33:20 -0300 Subject: [PATCH 6/6] v4.2.25-beta.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a35f441..3e4d29c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "4.2.25-beta.1", + "version": "4.2.25-beta.2", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": {