import type { ChangeEvent, DragEvent, HTMLAttributes } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { Button, Card, Icon, Input } from '../..'
import { useOnClickOutside } from '../../hooks'
import FileUploadStatus, {
FileUploadErrorType,
FileUploadState,
} from '../FileUploadStatus/FileUploadStatus'
export interface FileUploadCardProps
extends Omit, 'onDrop'> {
/**
* ID to find this component in testing tools (e.g.: cypress, testing library, and jest).
*/
testId?: string
/**
* Controls whether the component is visible.
*/
isOpen: boolean
/**
* Callback function when the component should be closed.
*/
onDismiss?: () => void
/**
* Callback function when a file is selected.
*/
onFileSelect?: (files: File[]) => void
/**
* Callback function when download template is clicked.
*/
onDownloadTemplate?: () => void
/**
* Callback function when search button is clicked after file upload.
*/
onSearch?: (file: File) => void
/**
* Accepted file types.
* @default '.csv'
*/
accept?: string
/**
* Allow multiple file selection.
* @default false
*/
multiple?: boolean
/**
* Card title (e.g. from CMS).
*/
title: string
/**
* Aria-label for the file input (e.g. from CMS).
*/
fileInputAriaLabel: string
/**
* Aria-label for the dropzone region (e.g. from CMS).
*/
dropzoneAriaLabel: string
/**
* Dropzone title text (e.g. from CMS).
*/
dropzoneTitle: string
/**
* Label for the select file button (e.g. from CMS).
*/
selectFileButtonLabel: string
/**
* Label for the download template button (e.g. from CMS).
*/
downloadTemplateButtonLabel: string
/**
* Aria-label for the remove button in FileUploadStatus (e.g. from CMS).
*/
removeButtonAriaLabel: string
/**
* Label for the search button in FileUploadStatus (e.g. from CMS).
*/
searchButtonLabel: string
/**
* Status text when uploading in FileUploadStatus (e.g. from CMS).
*/
uploadingStatusText: string
/**
* Status text when processing/polling in FileUploadStatus (e.g. from CMS).
* @default 'Importing...'
*/
processingStatusText?: string
/**
* Status text when completed in FileUploadStatus (e.g. from CMS). Receives file size in bytes.
*/
getCompletedStatusText: (fileSize: number) => string
/**
* Error messages per error type for FileUploadStatus (e.g. from CMS).
*/
errorMessages: Partial<
Record
>
/**
* Formatter for file size display.
*/
formatterFileSize?: (size: number) => string
/**
* Formatter for file name display.
*/
formatterFileName?: (name: string) => string
/**
* Indicates if the file is being uploaded.
*/
isUploading?: boolean
/**
* Indicates if the OES operation is polling/processing after upload.
*/
isProcessing?: boolean
/**
* Indicates if there was an error during file upload.
*/
hasError?: boolean
/**
* Type of error when hasError is true.
*/
errorType?: FileUploadErrorType
/**
* Custom error message to display when hasError is true.
*/
errorMessage?: string
}
const FileUploadCard = ({
testId = 'fs-file-upload-card',
isOpen,
onDismiss,
onFileSelect,
onDownloadTemplate,
onSearch,
accept = '.csv',
multiple = false,
title,
fileInputAriaLabel,
dropzoneAriaLabel,
dropzoneTitle,
selectFileButtonLabel,
downloadTemplateButtonLabel,
removeButtonAriaLabel,
searchButtonLabel,
uploadingStatusText,
processingStatusText,
getCompletedStatusText,
errorMessages,
formatterFileSize,
formatterFileName,
isUploading = false,
isProcessing = false,
hasError = false,
errorType: errorTypeProp,
errorMessage,
...otherProps
}: FileUploadCardProps) => {
const fileInputRef = useRef(null)
const containerRef = useRef(null)
const fileTypeErrorRef = useRef(undefined)
const [dragActive, setDragActive] = useState(false)
const [selectedFile, setSelectedFile] = useState(null)
const [uploadState, setUploadState] = useState(
FileUploadState.Uploading
)
const [errorType, setErrorType] = useState(
undefined
)
useOnClickOutside(isOpen ? containerRef : undefined, () => {
if (isOpen) {
onDismiss?.()
}
})
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onDismiss?.()
}
}
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [isOpen, onDismiss])
useEffect(() => {
if (!selectedFile) return
if (fileTypeErrorRef.current) return
if (hasError) {
setUploadState(FileUploadState.Error)
setErrorType(errorTypeProp ?? FileUploadErrorType.InvalidStructure)
return
}
if (isUploading) {
setUploadState(FileUploadState.Uploading)
setErrorType(undefined)
return
}
if (isProcessing) {
setUploadState(FileUploadState.Processing)
setErrorType(undefined)
return
}
setUploadState(FileUploadState.Completed)
setErrorType(undefined)
}, [hasError, selectedFile, isUploading, isProcessing, errorTypeProp])
const isValidFileType = (file: File): boolean => {
const fileName = file.name.toLowerCase()
const acceptedTypes = accept
.split(',')
.map((value) => value.trim().toLowerCase())
.filter(Boolean)
if (acceptedTypes.length === 0) {
return fileName.endsWith('.csv')
}
return acceptedTypes.some((value) =>
value.startsWith('.')
? fileName.endsWith(value)
: file.type.toLowerCase() === value
)
}
const handleFileChange = (e: ChangeEvent) => {
const files = Array.from(e.target.files || [])
if (files.length > 0) {
const file = files[0]
setSelectedFile(file)
if (!isValidFileType(file)) {
fileTypeErrorRef.current = FileUploadErrorType.Unsupported
setUploadState(FileUploadState.Error)
setErrorType(FileUploadErrorType.Unsupported)
return
}
fileTypeErrorRef.current = undefined
setErrorType(undefined)
setUploadState(FileUploadState.Uploading)
if (onFileSelect) {
onFileSelect(files)
}
}
}
const handleDrag = (e: DragEvent) => {
if (!isOpen) return
e.preventDefault()
e.stopPropagation()
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true)
} else if (e.type === 'dragleave') {
setDragActive(false)
}
}
const handleDrop = (e: DragEvent) => {
if (!isOpen) return
e.preventDefault()
e.stopPropagation()
setDragActive(false)
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const files = Array.from(e.dataTransfer.files)
const file = files[0]
setSelectedFile(file)
if (!isValidFileType(file)) {
fileTypeErrorRef.current = FileUploadErrorType.Unsupported
setUploadState(FileUploadState.Error)
setErrorType(FileUploadErrorType.Unsupported)
return
}
fileTypeErrorRef.current = undefined
setErrorType(undefined)
setUploadState(FileUploadState.Uploading)
if (onFileSelect) {
onFileSelect(files)
}
}
}
const triggerFileInput = () => {
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
fileInputRef.current?.click()
}
const handleDownloadTemplate = () => {
if (onDownloadTemplate) {
onDownloadTemplate()
} else {
const csvContent = 'SKU,Quantity\nAB001,2\nAB100,5\nAB999,49'
const blob = new Blob([csvContent], { type: 'text/csv' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'template.csv'
a.click()
window.URL.revokeObjectURL(url)
}
}
const handleRemoveFile = () => {
fileTypeErrorRef.current = undefined
setSelectedFile(null)
setUploadState(FileUploadState.Uploading)
setErrorType(undefined)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleSearch = () => {
if (selectedFile && onSearch) {
onSearch(selectedFile)
}
}
return (
{selectedFile ? (
) : (
{dropzoneTitle}
)}
)
}
export default FileUploadCard