Files
maher 703f50a09d
Some checks failed
Build / run (push) Has been cancelled
first commit
2025-10-29 11:42:25 +01:00

285 lines
8.2 KiB
TypeScript
Executable File

import React, {
cloneElement,
ComponentPropsWithRef,
ReactElement,
ReactNode,
useCallback,
useId,
useRef,
} from 'react';
import clsx from 'clsx';
import {mergeProps} from '@react-aria/utils';
import {toast} from '@common/ui/toast/toast';
import {Field} from '@common/ui/forms/input-field/field';
import {
getInputFieldClassNames,
InputFieldStyle,
} from '@common/ui/forms/input-field/get-input-field-class-names';
import {FileEntry} from '@common/uploads/file-entry';
import {useAutoFocus} from '@common/ui/focus/use-auto-focus';
import {UploadStrategyConfig} from '@common/uploads/uploader/strategy/upload-strategy';
import {useActiveUpload} from '@common/uploads/uploader/use-active-upload';
import {Disk} from '@common/uploads/types/backend-metadata';
import {Button} from '@common/ui/buttons/button';
import {Trans} from '@common/i18n/trans';
import {ProgressBar} from '@common/ui/progress/progress-bar';
import {Input} from '@common/ui/forms/input-field/input';
import {useController} from 'react-hook-form';
import {useFileEntryModel} from '@common/uploads/requests/use-file-entry-model';
import {Skeleton} from '@common/ui/skeleton/skeleton';
import {AnimatePresence, m} from 'framer-motion';
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
import {validateUpload} from '@common/uploads/uploader/validate-upload';
import {UploadedFile} from '@common/uploads/uploaded-file';
interface Props {
className?: string;
label?: ReactNode;
description?: ReactNode;
invalid?: boolean;
errorMessage?: ReactNode;
required?: boolean;
disabled?: boolean;
value?: string;
onChange?: (newValue: string) => void;
allowedFileTypes?: string[];
maxFileSize?: number;
diskPrefix: string;
disk?: Disk;
showRemoveButton?: boolean;
autoFocus?: boolean;
}
export function FileEntryField({
className,
label,
description,
value,
onChange,
diskPrefix,
disk = Disk.uploads,
showRemoveButton,
invalid,
errorMessage,
required,
autoFocus,
disabled,
allowedFileTypes,
maxFileSize,
}: Props) {
const {
uploadFile,
entry,
uploadStatus,
deleteEntry,
isDeletingEntry,
percentage,
} = useActiveUpload();
const inputRef = useRef<HTMLInputElement>(null);
useAutoFocus({autoFocus}, inputRef);
const {data} = useFileEntryModel(value, {enabled: !entry && !!value});
const fieldId = useId();
const labelId = label ? `${fieldId}-label` : undefined;
const descriptionId = description ? `${fieldId}-description` : undefined;
const currentValue = value || entry?.url;
const currentEntry = entry || data?.fileEntry;
const uploadOptions: UploadStrategyConfig = {
showToastOnRestrictionFail: true,
restrictions: {
allowedFileTypes,
maxFileSize,
},
metadata: {
diskPrefix,
disk,
},
onSuccess: (entry: FileEntry) => onChange?.(entry.url),
onError: message => {
if (message) {
toast.danger(message);
}
},
};
const inputFieldClassNames = getInputFieldClassNames({
description,
descriptionPosition: 'top',
invalid,
disabled: disabled || uploadStatus === 'inProgress',
});
const removeButton = showRemoveButton ? (
<Button
variant="link"
color="danger"
size="xs"
disabled={isDeletingEntry || !currentValue || disabled}
onClick={() => {
deleteEntry({
onSuccess: () => onChange?.(''),
});
}}
>
<Trans message="Remove file" />
</Button>
) : null;
const handleUpload = useCallback(() => {
inputRef.current?.click();
}, []);
return (
<div className={clsx('text-sm', className)}>
{label && (
<div className="flex items-center justify-between gap-24">
<div id={labelId} className={inputFieldClassNames.label}>
{label}
</div>
{removeButton}
</div>
)}
{description && (
<div className={inputFieldClassNames.description}>{description}</div>
)}
<div aria-labelledby={labelId} aria-describedby={descriptionId}>
<Field
fieldClassNames={inputFieldClassNames}
errorMessage={errorMessage}
invalid={invalid}
>
<FileInputField
inputFieldClassNames={inputFieldClassNames}
currentValue={currentValue}
currentEntry={currentEntry}
handleUpload={handleUpload}
>
<input
ref={inputRef}
aria-labelledby={labelId}
aria-describedby={descriptionId}
// if file is already uploaded (from form or via props) set
// required to false, otherwise farm validation will always fail
required={currentValue ? false : required}
accept={allowedFileTypes?.join(',')}
type="file"
disabled={uploadStatus === 'inProgress'}
className="sr-only"
onChange={e => {
if (e.target.files?.length) {
// "uploadFile" will validate, but need to validate here as well
// because there's no easy way to listen for errors using "uploadFile"
const errorMessage = validateUpload(
new UploadedFile(e.target.files[0]),
uploadOptions.restrictions
);
if (errorMessage && inputRef.current) {
inputRef.current.value = '';
toast.danger(errorMessage);
} else {
uploadFile(e.target.files[0], uploadOptions);
}
}
}}
/>
</FileInputField>
{uploadStatus === 'inProgress' && (
<ProgressBar
className="absolute left-0 right-0 top-0"
size="xs"
value={percentage}
/>
)}
</Field>
</div>
</div>
);
}
interface FileInputFieldProps {
children: ReactElement<ComponentPropsWithRef<'input'>>;
inputFieldClassNames: InputFieldStyle;
currentValue?: string;
currentEntry?: FileEntry;
handleUpload: () => void;
}
function FileInputField({
children,
inputFieldClassNames,
currentValue,
currentEntry,
handleUpload,
}: FileInputFieldProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
if (currentValue) {
return (
<Field
wrapperProps={{
onClick: () => {
buttonRef.current?.focus();
buttonRef.current?.click();
},
}}
fieldClassNames={inputFieldClassNames}
>
<Input className={clsx(inputFieldClassNames.input, 'gap-10')}>
<button
ref={buttonRef}
type="button"
className="flex-shrink-0 rounded bg-primary px-10 py-2 text-sm font-semibold text-on-primary outline-none"
onClick={() => handleUpload()}
>
<Trans message="Replace file" />
</button>
<AnimatePresence initial={false} mode="wait">
<div className="min-w-0 overflow-hidden overflow-ellipsis whitespace-nowrap">
{currentEntry ? (
<m.div key="file-entry-name" {...opacityAnimation}>
{currentEntry.name}
</m.div>
) : (
<m.div key="skeleton" {...opacityAnimation}>
<Skeleton className="min-w-144" />
</m.div>
)}
</div>
</AnimatePresence>
{children}
</Input>
</Field>
);
}
return cloneElement(children, {
className: clsx(
inputFieldClassNames.input,
'py-8',
'file:bg-primary file:text-on-primary file:border-none file:rounded file:text-sm file:font-semibold file:px-10 file:h-24 file:mr-10'
),
});
}
interface FormFileEntryFieldProps extends Props {
name: string;
}
export function FormFileEntryField(props: FormFileEntryFieldProps) {
const {
field: {onChange, value = null},
fieldState: {error},
} = useController({
name: props.name,
});
const formProps: Partial<Props> = {
onChange,
value,
invalid: error != null,
errorMessage: error ? <Trans message="Please select a file." /> : null,
};
return <FileEntryField {...mergeProps(formProps, props)} />;
}