Skip to content
Merged
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();
});
});
55 changes: 42 additions & 13 deletions packages/react-vanilla-components/src/components/FileUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,29 @@ const FileUpload = (props: PROPS) => {
properties,
valid
} = props;
type LocalFile = { uid: string, file: File | FileObject };
// Generate a simple unique id for each file instance
const generateUid = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`;

const wrapWithUid = (items: Array<File | FileObject> | null | undefined): LocalFile[] => {
const list = items && (items instanceof Array ? items : [items]);
return (list || []).map((f) => ({
uid: generateUid(),
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);

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 files = localFiles.map(({ file }) => file);
if (multiple) {
props.dispatchChange(files);
} else {
Expand All @@ -69,30 +83,44 @@ 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') {
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[]);
// Create new file entries with unique UIDs
const wrappedNew = validFiles.map((f) => ({ uid: generateUid(), 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) => {
const fileList = [...files];
(uid: string) => {
const fileList = [...files];
const index = files.findIndex((f) => f.uid === uid);
if (index === -1) {return;}
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 +184,26 @@ 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)?.name}
>
{item?.name}
{(file)?.name}
</span>
<span className="cmp-adaptiveform-fileinput__fileendcontainer">
<span className="cmp-adaptiveform-fileinput__filesize">
{formatBytes(item?.size)}
{formatBytes((file)?.size)}
</span>
<button
onClick={() => removeFile(index)}
type="button"
onClick={() => removeFile(uid)}
className="cmp-adaptiveform-fileinput__filedelete"
role="button"
aria-label="Remove file"
>
x
</button>
Expand Down