import { isArray, isString, last } from 'lodash'; import React, { useRef, useState } from 'react'; import { useDropZone, useFormField } from '../../hooks'; import { useSilkeContext } from '../../silke-theme-provider'; import { BoxLabelProps, SilkeBox } from '../silke-box'; import { SilkeButton } from '../silke-button'; import { FormFieldProps, FormValidator } from '../silke-form'; import { SilkeSkeleton } from '../silke-skeleton'; import { SilkeText, SilkeTitle } from '../silke-text'; import { SilkeIcon } from '../silke-icon'; import styles from './silke-upload-field.scss'; import { SilkePopover } from '../silke-popover'; import { SilkeTooltip } from '../silke-tooltip'; export type FileModel = { size?: number; name?: string; url: string; type?: string; }; type PendingFile = Omit & { url?: string; error?: string; task: Promise; stopped?: boolean; }; type BaseProps = { accept?: string; help?: React.ReactNode; /** Max file size in KB */ maxSize?: number; disabled?: boolean; required?: boolean; useFileModel?: boolean; multiple?: boolean; autoFocus?: boolean; onSelectFiles?: (value: any) => void; } & Omit; type SingleField = { multiple?: false | undefined } & BaseProps & FormFieldProps; type MultipleField = { multiple: true } & BaseProps & FormFieldProps<(FileModel | string)[]>; type SilkeUploadFieldProps = (SingleField | MultipleField) & { dropzoneStyle?: React.CSSProperties; fileItemStyle?: React.CSSProperties; }; const validate: FormValidator = (props, value) => { if (props.required && value === undefined) return 'Required'; }; export function SilkeUploadField(props: SilkeUploadFieldProps) { const { onFileUpload } = useSilkeContext(); const [pending, setPending] = useState([]); const size = props.size || 'base'; const { useFileModel, accept, maxSize, disabled, autoFocus, name, help, multiple, readOnly, value, label, error, onFocus, onBlur, onChange, dropzoneStyle, fileItemStyle, } = useFormField(props, validate); const ref = useRef(null); const pendingRef = useRef([]); const valueRef = useRef(); const [dropState, setDropState] = useState(null); pendingRef.current = pending; valueRef.current = value; const handleFileUploaded = (model: PendingFile, url: string) => { // Do not continue if upload stopped if (model.stopped) return; const file = useFileModel ? ({ name: model.name, size: model.size, type: model.type, url } as FileModel) : url; if (props.multiple) { const value = valueRef.current; onChange(isArray(value) ? [...value, file] : [file]); } else { onChange(file); } const pending = pendingRef.current || []; setPending(pending.filter((m) => m !== model)); }; const handleFiles = (files?: FileList | null) => { if (!files) return; const fileLen = multiple ? files.length : 1; const uploadFiles: PendingFile[] = []; // takes the acceptable file extensions (string), cleans them, and formats them into an array. const regParts = accept?.split(',').map((r) => r.trim().replace('/*', '/.*')); // regex explained: // ^: assert position at start of line. // .: matches any character. // *: matches previous token between zero and unlimited times. // (regParts.join('|')): matches any of the string values in regParts[]. '|' acts like an "or". // $: asserts position at the end of a line. const regToMatchAcceptableFileTypes = regParts && `^.*(${regParts.join('|')})$`; const acceptReg = regToMatchAcceptableFileTypes && new RegExp(regToMatchAcceptableFileTypes); for (let i = 0; i < fileLen; i++) { const file = files.item(i); if (file) { const tooLarge = maxSize && file.size / 1024 > maxSize; const invalidFormat = acceptReg && !acceptReg.test(file.type) && !acceptReg.test(file.name); const model: PendingFile = { name: file.name, size: file.size, type: file.type, task: tooLarge || invalidFormat ? Promise.resolve() : onFileUpload(file) .then((url) => handleFileUploaded(model, url)) .catch((e) => { console.error('Failed to upload file', file.name, e); const pending = (pendingRef.current || []).slice(); const index = pending.indexOf(model); if (index !== -1) { pending[index] = { ...pending[index], error: 'Failed to upload' }; setPending(pending); } }), }; if (invalidFormat) model.error = 'Not a valid format'; else if (tooLarge) model.error = `The file is too large, max size is ${maxSize / 1024}MB`; uploadFiles.push(model); } } // if (!props.multiple) onChange(null); setPending([...pending, ...uploadFiles]); }; const handleDragOver = () => { setDropState('over'); }; const handleDragLeave = () => { setDropState(null); }; const handleDrop = (e: DragEvent) => { handleFiles(e.dataTransfer?.files); setDropState(null); }; useDropZone(ref, handleDrop, handleDragOver, handleDragLeave); let cl = styles.root; if (dropState) cl += ' ' + styles[dropState]; return ( {label} { if (props.onSelectFiles) return props.onSelectFiles(e.currentTarget.files); handleFiles(e.currentTarget.files); }} /> Select a file drag and drop here {help && !error && ( {help} )} {error && ( {error} )} {value && (multiple || !pending.length) && (isArray(value) ? ( (value as (string | FileModel)[]).map((file: FileModel | string, index) => ( onChange((value as (string | FileModel)[]).filter((v) => v !== file))} /> )) ) : ( onChange(null)} style={fileItemStyle} /> ))} {pending.map((p, key) => ( { // Settings this to tell promise that it should not complete this item p.stopped = true; setPending(pending.filter((m) => m !== p)); }} /> ))} ); } type FileItemProps = { loading?: boolean; error?: string; file: string | FileModel | PendingFile; onRemove: () => void; style?: React.CSSProperties; }; function SilkeFileItem({ file, error, loading, onRemove, style }: FileItemProps) { const ref = useRef(null); const [hover, setHover] = useState(false); if (isString(file)) { file = { name: last(file.split('/')) || 'Untitled', size: 0, type: 'test', url: file }; } let cl = styles.item; if (error) cl += ' ' + styles.error; if (!error && !loading) cl += ' ' + styles.complete; return ( setHover(true)} onMouseLeave={() => setHover(false)} > {file.name} {!error && loading && } {error && ( {error} )} ); }