Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -311,4 +311,107 @@ describe("File Upload", () => {
expect(input).toHaveLength(1);
expect(input[0]).toHaveAttribute('aria-describedby', `${f.id}__longdescription ${f.id}__shortdescription`)
})
});

test("should allow re-uploading same file after removal", async () => {
const f = {
...field,
type: "file",
};
const { renderResponse } = await helper(f);

// Get the file input element directly (like other tests do)
const input = renderResponse.container.getElementsByClassName("cmp-adaptiveform-fileinput__widget");

// 1. Upload initial file
const file = new File(["(⌐□_□)"], "test-document.pdf", { type: "application/pdf" });
userEvent.upload(input[0] as HTMLInputElement, file);

// Verify file was uploaded
expect(renderResponse.queryByText("test-document.pdf")).toBeTruthy();

// 2. Remove the file using the remove button
const removeButton = renderResponse.getByLabelText("Remove file");
userEvent.click(removeButton);

// Verify file was removed
expect(renderResponse.queryByText("test-document.pdf")).toBeFalsy();

// 3. Re-upload the same file (this tests the bug fix)
const newFile = new File(["(⌐□_□)"], "test-document.pdf", { type: "application/pdf" });
userEvent.upload(input[0] as HTMLInputElement, newFile);

// 4. Verify the same file appears correctly after re-upload
expect(renderResponse.queryByText("test-document.pdf")).toBeTruthy();
});

test("should handle duplicate filenames correctly without removing wrong file", async () => {
const f = {
...field,
type: "file[]",
};
const { renderResponse } = await helper(f);

const input = renderResponse.container.getElementsByClassName("cmp-adaptiveform-fileinput__widget");

// Upload first document
const largerContent = Array(100).fill('(⌐□_□)').join('');
const document1 = new File([largerContent], "document.pdf", { type: "application/pdf" });
userEvent.upload(input[0] as HTMLInputElement, document1);

// Upload image file to create separation between duplicate names
const imageFile = new File(["image data"], "image.jpg", { type: "image/jpeg" });
userEvent.upload(input[0] as HTMLInputElement, imageFile);

// Upload second document with same filename but different content
const smallerContent = '(⌐□_□)';
const document2 = new File([smallerContent], "document.pdf", { type: "application/pdf" });
userEvent.upload(input[0] as HTMLInputElement, document2);

// Verify all files are present
const fileItems = renderResponse.container.getElementsByClassName("cmp-adaptiveform-fileinput__fileitem");
expect(fileItems).toHaveLength(3);

const fileSizes = renderResponse.container.getElementsByClassName("cmp-adaptiveform-fileinput__filesize");
expect(fileSizes).toHaveLength(3);
const sizeTexts = Array.from(fileSizes).map(el => el.textContent);
expect(sizeTexts.filter(size => size !== sizeTexts[0])).toHaveLength(2);

expect(renderResponse.queryByText("image.jpg")).toBeTruthy();

const removeButtons = renderResponse.container.getElementsByClassName("cmp-adaptiveform-fileinput__filedelete");
expect(removeButtons).toHaveLength(3);

// Remove the first uploaded document
userEvent.click(removeButtons[0] as HTMLButtonElement);

// Verify exactly 2 files remain
const remainingFileItems = renderResponse.container.getElementsByClassName("cmp-adaptiveform-fileinput__fileitem");
expect(remainingFileItems).toHaveLength(2);

const remainingFileSizes = renderResponse.container.getElementsByClassName("cmp-adaptiveform-fileinput__filesize");
const remainingSizeTexts = Array.from(remainingFileSizes).map(el => el.textContent);
expect(remainingSizeTexts).toHaveLength(2);

expect(renderResponse.queryByText("image.jpg")).toBeTruthy();
expect(renderResponse.queryByText("document.pdf")).toBeTruthy();

// Remove the image file to verify the second document remains
const updatedRemoveButtons = renderResponse.container.getElementsByClassName("cmp-adaptiveform-fileinput__filedelete");
expect(updatedRemoveButtons).toHaveLength(2);

const imageRemoveButton = Array.from(updatedRemoveButtons).find((button) => {
const fileItem = button.closest('.cmp-adaptiveform-fileinput__fileitem');
const fileName = fileItem?.querySelector('.cmp-adaptiveform-fileinput__filename')?.textContent;
return fileName === 'image.jpg';
});
userEvent.click(imageRemoveButton as HTMLButtonElement);

// Verify only the second document remains
const finalFileItems = renderResponse.container.getElementsByClassName("cmp-adaptiveform-fileinput__fileitem");
expect(finalFileItems).toHaveLength(1);

const finalFileSizes = renderResponse.container.getElementsByClassName("cmp-adaptiveform-fileinput__filesize");
expect(finalFileSizes).toHaveLength(1);
expect(renderResponse.queryByText("image.jpg")).toBeFalsy();
});
});
97 changes: 80 additions & 17 deletions packages/react-vanilla-components/src/components/FileUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
// * LINK- https://github.com/adobe/aem-core-forms-components/blob/master/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/fileinput/v1/fileinput/fileinput.html
// ******************************************************************************

import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useRef, useState, useEffect } from 'react';
import { FileObject } from '@aemforms/af-core';
import { getFileSizeInBytes } from '@aemforms/af-core';
import { withRuleEngine } from '../utils/withRuleEngine';
Expand All @@ -41,19 +41,52 @@ const FileUpload = (props: PROPS) => {
properties,
valid
} = props;
type LocalFile = { uid: string, file: File | FileObject };

const generateUid = (seed?: string) => `${Date.now()}-${Math.random().toString(36).slice(2)}${seed ? `-${seed}` : ''}`;

const getIdentity = (f: any) => `${f?.name || ''}|${f?.size || ''}|${f?.lastModified || ''}|${f?.type || ''}`;

const uidMapRef = React.useRef<Map<string, string>>(new Map());

const wrapWithUid = (items: Array<File | FileObject> | null | undefined): LocalFile[] => {
const list = items && (items instanceof Array ? items : [items]);
return (list || []).map((f) => {
const identity = getIdentity(f as any);
let uid = uidMapRef.current.get(identity);
if (!uid) {
uid = generateUid(identity);
uidMapRef.current.set(identity, uid);
}
return { uid, file: f };
});
};

let val = value && (value instanceof Array ? value : [value]);
const [files, setFiles] = useState<FileObject[]>(val || []);
const [files, setFiles] = useState<LocalFile[]>(wrapWithUid(val as Array<File | FileObject>) || []);
const [ dragOver, setDragOver ] = useState(false);

// Sync internal state with external value prop only once (initial mount)
const didInitFromPropsRef = useRef(false);
useEffect(() => {
if (!didInitFromPropsRef.current) {
const newVal = value && (value instanceof Array ? value : [value]);
setFiles(wrapWithUid(newVal as Array<File | FileObject>));
didInitFromPropsRef.current = true;
}
}, [value]);

const maxFileSizeInBytes = getFileSizeInBytes(maxFileSize);
let multiple = props.type?.endsWith('[]') ? { multiple: true } : {};

// Dispatch value to the model. When field supports multiple values, send array; otherwise a single item
const fileChangeHandler = useCallback(
(files: Array<File | FileObject>) => {
(localFiles: Array<LocalFile>) => {
const plainFiles = localFiles.map(({ file }) => file);
if (multiple) {
props.dispatchChange(files);
props.dispatchChange(plainFiles);
} else {
props.dispatchChange(files.length > 0 ? files[0] : null);
props.dispatchChange(plainFiles.length > 0 ? plainFiles[0] : null);
}
},
[multiple, props.dispatchChange]
Expand All @@ -69,30 +102,54 @@ const FileUpload = (props: PROPS) => {
setDragOver(false);
};

// Handles file selection via input, drag/drop, or paste
const fileUploadHandler = useCallback((e) => {
e.preventDefault();
const newFiles = Array.from<File>(e.dataTransfer?.files || e?.target?.files || e.clipboardData?.files || []);

// Clear the input value to allow re-uploading the same file again
if (e.target && e.target.type === 'file') {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can upload multiple files at once as well, I don't think that use-case is handled here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sir multiple files upload use case was already handled before, the 2 main issues which we were experiencing was re-uploading the removed file and uploading 2 files having same name.

the use case achieved.

Screenshot 2025-08-27 at 2 42 12 PM

e.target.value = '';
}

if (newFiles?.length) {
const validFiles = newFiles.filter((file: File) => file.size <= maxFileSizeInBytes);
if (validFiles.length < newFiles.length) {
// Show constraint message for files with size exceeding the limit
alert(`${props.constraintMessages?.maxFileSize}`);
}
const updatedFiles = [...files, ...validFiles];
setFiles(updatedFiles as FileObject[]);

// Avoid collapsing same-named files: append new entries without deduping
const wrappedNew = validFiles.map((f) => ({ uid: generateUid(`${f.name}-${f.size}-${f.lastModified}`), file: f }));
const updatedFiles: LocalFile[] = [...files, ...wrappedNew];
setFiles(updatedFiles);
fileChangeHandler(updatedFiles);
}

setDragOver(false);
},
[files, fileChangeHandler, maxFileSizeInBytes, props?.constraintMessages]
);

// Removes one file by its unique id and clears the input to allow re-uploading the same file
const removeFile = useCallback(
(index: number) => {
(uid: string) => {
const index = files.findIndex((f) => f.uid === uid);
if (index === -1) {return;}
// remove identity mapping as well to avoid leaks
const toRemove = files[index];
const identity = getIdentity((toRemove?.file as any));
if (identity) {
uidMapRef.current.delete(identity);
}
const fileList = [...files];
fileList.splice(index,1);
fileList.splice(index, 1);
setFiles(fileList);
fileChangeHandler(fileList);
// Clear the input value so the same file can be selected again
if (fileInputField.current) {
(fileInputField.current as HTMLInputElement).value = '';
}
},
[files, fileChangeHandler]
);
Expand Down Expand Up @@ -156,25 +213,31 @@ const FileUpload = (props: PROPS) => {
</div>
<ul className="cmp-adaptiveform-fileinput__filelist">
{files &&
files?.map((item: FileObject, index) => (
files?.map(({ file, uid }) => (
<li
className="cmp-adaptiveform-fileinput__fileitem"
key={item?.name}
key={uid}
>
<span
className="cmp-adaptiveform-fileinput__filename"
aria-label={item?.name}
aria-label={(file as any)?.name}
>
{item?.name}
{(file as any)?.name}
</span>
<span className="cmp-adaptiveform-fileinput__fileendcontainer">
<span className="cmp-adaptiveform-fileinput__filesize">
{formatBytes(item?.size)}
{formatBytes((file as any)?.size)}
</span>
<button
onClick={() => removeFile(index)}
type="button"
onClick={(e) => {
// Prevent form submit bubbling when used inside a <form>
e.preventDefault();
e.stopPropagation();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be prevented at form level

removeFile(uid);
}}
className="cmp-adaptiveform-fileinput__filedelete"
role="button"
aria-label="Remove file"
>
x
</button>
Expand All @@ -188,4 +251,4 @@ const FileUpload = (props: PROPS) => {
);
};

export default withRuleEngine(FileUpload);
export default withRuleEngine(FileUpload);