I have an Uploader
component that can be both controlled and uncontrolled. The instantUpload
prop allows for immidiate update once the files are chosen. Unfortunately, for some reason the files
variable is not updated properly in onUploadProgress
and onSuccess
callback (i.e. files
is an empty array instead of a chosen file
). Any reason why is that a case?
'use client';
import { ChangeEvent, RefObject, createContext, useRef, useState } from 'react';
import { useSaveFile } from '@/api/file/hooks';
import { useToast } from '@/components/ui/use-toast';
import { DefaultUploadContainer } from '@/components/uploader/default-upload-container';
import { DefaultUploadTrigger } from '@/components/uploader/default-upload-trigger';
import { formatFileSize, getFileExtension } from '@/lib/utils';
import { UploadedFile } from '@/types';
import { DefaultExtensionType } from 'react-file-icon';
export type UploaderFile = {
file?: globalThis.File;
src: string;
extension: DefaultExtensionType;
progress?: number;
uploading?: boolean;
name: string;
size: number;
id?: string;
uploadedFile?: UploadedFile;
};
interface UploaderContext {
files: UploaderFile[];
onChange: (files: UploaderFile[]) => void;
handleUpload: (file: UploaderFile) => void;
handleRemoveFile: (file: UploaderFile) => void;
fileInputRef: RefObject<HTMLInputElement>;
}
export const UploaderContext = createContext<UploaderContext>({} as any);
export interface UploaderProps {
allowedExtensions?: DefaultExtensionType[];
multiple?: boolean;
instantUpload?: boolean;
files?: UploaderFile[];
defaultFiles?: UploaderFile[];
maxSize?: number;
onChange?: (files: UploaderFile[]) => void;
onClick?: (file: UploaderFile) => void;
triggerRenderer?: () => JSX.Element;
containerRenderer?: () => JSX.Element;
Trigger?: React.ComponentType;
Container?: React.ComponentType;
}
export function Uploader({
multiple = true,
allowedExtensions,
instantUpload = true,
maxSize,
files: valueFromProps,
defaultFiles: defaultValue,
Trigger,
Container,
onChange: onChangeFromProps,
}: UploaderProps) {
// A component can be considered controlled when its value prop is
// not undefined.
const isControlled = typeof valueFromProps != 'undefined';
// When a component is not controlled, it can have a defaultValue.
const hasDefaultValue = typeof defaultValue != 'undefined';
// If a defaultValue is specified, we will use it as our initial
// state. Otherwise, we will simply use an empty array.
const [internalValue, setInternalValue] = useState<UploaderFile[]>(
hasDefaultValue ? defaultValue : []
);
// Internally, we need to deal with some value. Depending on whether
// the component is controlled or not, that value comes from its
// props or from its internal state.
let files: UploaderFile[];
if (isControlled) {
files = valueFromProps;
} else files = internalValue;
const onChange = (value: UploaderFile[]) => {
// If exists, we always call onChange callback passed in props
// We do this even if there is no props.value (and the component
// is uncontrolled.)
if (onChangeFromProps) {
onChangeFromProps(value);
}
// If the component is uncontrolled, we need to update our
// internal value here.
if (!isControlled) {
setInternalValue(value);
}
};
const fileInputRef = useRef<HTMLInputElement>(null);
const mutationUploadFile = useSaveFile({});
const { toast } = useToast();
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
let inputFiles: UploaderFile[] = Array.from(event.target.files || []).map(
(file) => ({
file: file,
name: file.name,
size: file.size,
src: URL.createObjectURL(file),
extension: getFileExtension(file.name),
})
);
if (!inputFiles.length) return;
if (!multiple && files.length !== 0) {
toast({
title: 'Zbyt wiele plików!',
description: <div>Możesz przesłać maksymalnie jeden plik!</div>,
variant: 'destructive',
});
return;
}
for (let file of inputFiles) {
if (!file.file) continue;
if (allowedExtensions && !allowedExtensions.includes(file.extension)) {
toast({
title: 'Nieprawidłowy format pliku!',
description: (
<div className='w-full max-w-full'>
<p>
Dozwolone są jedynie pliki w nastepujących formatach:{' '}
<span className='font-bold'>
{allowedExtensions.map((x) => x.toUpperCase()).join(', ')}.
</span>
</p>
<p className='pt-2'>
Pominięto plik: <span className='italic'>{file.file.name}</span>
</p>
</div>
),
variant: 'destructive',
});
inputFiles = inputFiles.filter((item) => item.name !== file.name);
}
if (maxSize && file.file.size > maxSize) {
toast({
title: 'Zbyt duży rozmiar pliku!',
description: (
<div>
Rozmiar pliku <span className='italic'>{file.file.name}</span> (
{formatFileSize(file.file.size)}) przekracza maksymalny dozwolony
rozmiar{' '}
<span className='font-bold'>({formatFileSize(maxSize)})</span>
</div>
),
variant: 'destructive',
});
inputFiles = inputFiles.filter((x) => x !== file);
}
if (files.some((x) => x.file?.name === file.file?.name)) {
inputFiles = inputFiles.filter((x) => x !== file);
toast({
title: 'Plik już istnieje!',
description: (
<div>
Plik <span className='italic'>{file.file.name}</span> (
{formatFileSize(file.file.size)}) został już dodany.
</div>
),
variant: 'destructive',
});
}
}
onChange([...files, ...inputFiles]);
if (instantUpload) {
for (let file of inputFiles) {
handleUpload(file);
}
}
fileInputRef.current!.value = '';
};
const handleUpload = (file: UploaderFile) => {
mutationUploadFile.mutate(
{
file: file.file!,
onUploadProgress(progress) {
onChange(
files.map((item) => {
if (item.name === file.name)
return {
...file,
uploading: true,
progress: Math.floor(
(progress.loaded / progress.total!) * 100
),
};
return item;
})
);
},
},
{
onSuccess(res) {
onChange(
files.map((item) => {
if (item.name === file.name)
return {
...item,
id: res.id,
src: res.url,
uploadedFile: res,
uploading: false,
};
return item;
})
);
},
onError(error, variables, context) {},
}
);
};
const handleRemoveFile = (file: UploaderFile) => {
if (file.uploadedFile) {
onChange(files.filter((item) => item.name !== file.name));
} else {
onChange(files.filter((item) => item.name !== file.name));
}
};
return (
<UploaderContext.Provider
value={{
files,
onChange,
handleUpload,
handleRemoveFile,
fileInputRef,
}}
>
<div className='w-full'>
<input
type='file'
name='file'
hidden
multiple={multiple}
ref={fileInputRef}
onChange={handleChange}
/>
{Trigger ? <Trigger /> : <DefaultUploadTrigger />}
{Container ? <Container /> : <DefaultUploadContainer />}
</div>
</UploaderContext.Provider>
);
}