-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(react): useFileReader 추가 (#111)
* feat(react): useFileReader 추가 * docs: useFileReader 문서 추가 * docs: useFileReader 문서 수정
- Loading branch information
Showing
6 changed files
with
376 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@modern-kit/react': minor | ||
--- | ||
|
||
feat(react): useFileReader 훅 추가 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
# useFileReader | ||
|
||
`File` 객체를 원하는 읽기 메서드(`readAsText`,`readAsDataURL`,`readAsArrayBuffer`)로 읽고, 읽은 파일 컨텐츠를 반환하는 커스텀 훅입니다. | ||
|
||
<br /> | ||
|
||
## Interface | ||
```tsx | ||
type ReadType = 'readAsText' | 'readAsDataURL' | 'readAsArrayBuffer'; | ||
|
||
interface FileContent { | ||
status: 'fulfilled' | 'rejected'; | ||
readValue: string | ArrayBuffer; | ||
originFile: Nullable<File>; | ||
} | ||
|
||
interface ReadFileOptions { | ||
file: FileList | File; | ||
readType: ReadType; | ||
accepts?: string[]; | ||
} | ||
|
||
const useFileReader: () => { | ||
readFile: ({ | ||
file, | ||
readType, | ||
accepts, | ||
}: ReadFileOptions) => Promise<FileContent[]>; | ||
fileContents: FileContent[]; | ||
isLoading: boolean; | ||
}; | ||
``` | ||
|
||
## Usage | ||
|
||
```tsx | ||
import React, { useState } from 'react'; | ||
import { useFileReader } from '@modern-kit/react'; | ||
|
||
const Example = () => { | ||
const { readFile, fileContents, loading } = useFileReader() | ||
|
||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||
if(!e.target.files) return; | ||
|
||
readFile({ file: e.target.files, readType: 'readAsText' }); | ||
/* | ||
* 1. readFile은 Promise<FileContent[]> 반환합니다. 해당 값은 fileContents와 동일합니다. | ||
* ex) const data = await readFile(e.target.files, 'readAsDataURL'); | ||
* | ||
* 2. accepts로 원하는 파일 타입만 읽어올 수 있습니다. | ||
* accepts옵션을 넘겨주지 않으면 모든 파일 타입을 허용합니다. | ||
* ex) readFile({ file: e.target.files, readType: 'readAsText', accepts: ['text/plain'] }); | ||
*/ | ||
} | ||
|
||
return ( | ||
<div> | ||
<input multiple type="file" onChange={handleChange} /> | ||
</div> | ||
); | ||
}; | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
import { Nullable } from '@modern-kit/types'; | ||
import { useState } from 'react'; | ||
|
||
type ReadType = 'readAsText' | 'readAsDataURL' | 'readAsArrayBuffer'; | ||
|
||
interface FileContent { | ||
status: 'fulfilled' | 'rejected'; | ||
readValue: string | ArrayBuffer; | ||
originFile: Nullable<File>; | ||
} | ||
|
||
interface ReadFileOptions { | ||
file: FileList | File; | ||
readType: ReadType; | ||
accepts?: string[]; | ||
} | ||
|
||
const isFile = (file: FileList | File): file is File => { | ||
return file instanceof File; | ||
}; | ||
|
||
const isFileList = (file: FileList | File): file is FileList => { | ||
return file instanceof FileList; | ||
}; | ||
|
||
const inValidFileType = (file: FileList | File) => { | ||
return !isFile(file) && !isFileList(file); | ||
}; | ||
|
||
const getFiles = (file: File | FileList, accepts: string[]) => { | ||
const files = isFile(file) ? [file] : Array.from(file); | ||
|
||
return accepts.length > 0 | ||
? files.filter((file) => accepts.includes(file.type)) | ||
: files; | ||
}; | ||
|
||
const getReaderPromise = (reader: FileReader, file: File) => { | ||
return new Promise<Nullable<FileContent['readValue']>>((resolve, reject) => { | ||
reader.onload = () => { | ||
resolve(reader.result); | ||
}; | ||
reader.onerror = () => { | ||
reject(`Failed to read file ${file.name}`); | ||
}; | ||
}); | ||
}; | ||
|
||
export const useFileReader = () => { | ||
const [fileContents, setFileContents] = useState<FileContent[]>([]); | ||
const [isLoading, setIsLoading] = useState(false); | ||
|
||
const readFile = async ({ | ||
file, | ||
readType, | ||
accepts = [], | ||
}: ReadFileOptions) => { | ||
if (inValidFileType(file)) { | ||
return []; | ||
} | ||
|
||
const files = getFiles(file, accepts); | ||
|
||
setIsLoading(true); | ||
setFileContents([]); | ||
|
||
const readerPromises = files.map((file) => { | ||
const reader = new FileReader(); | ||
|
||
try { | ||
reader[readType](file); | ||
} catch { | ||
return Promise.reject(`Failed to read file ${file.name}`); | ||
} | ||
|
||
return getReaderPromise(reader, file); | ||
}); | ||
|
||
const settledPromises = await Promise.allSettled(readerPromises); | ||
const contents: FileContent[] = settledPromises.map((el, idx) => { | ||
const isFulfilled = el.status === 'fulfilled'; | ||
|
||
return { | ||
status: el.status, | ||
readValue: isFulfilled ? el.value : el.reason, | ||
originFile: isFulfilled ? files[idx] : null, | ||
}; | ||
}); | ||
|
||
setFileContents(contents); | ||
setIsLoading(false); | ||
|
||
return contents; | ||
}; | ||
|
||
return { readFile, fileContents, isLoading }; | ||
}; |
168 changes: 168 additions & 0 deletions
168
packages/react/src/hooks/useFileReader/useFileReader.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,168 @@ | ||
import { renderHook, waitFor } from '@testing-library/react'; | ||
import { useFileReader } from '.'; | ||
import { | ||
MockFileReaderForcedCallOnError, | ||
MockFileReaderThrowError, | ||
mockFileList, | ||
} from '../../utils/test/mockFile'; | ||
|
||
afterEach(() => { | ||
vi.clearAllMocks(); | ||
}); | ||
|
||
const getSuccessFileContent = (file: File) => { | ||
return { | ||
status: 'fulfilled', | ||
readValue: 'file', | ||
originFile: file, | ||
}; | ||
}; | ||
|
||
const testFile1 = new File(['file'], 'test1.txt', { | ||
type: 'text/plain', | ||
}); | ||
const testFile2 = new File(['file'], 'test2.csv', { | ||
type: 'text/csv', | ||
}); | ||
|
||
const testFileList = mockFileList([testFile1, testFile2]); | ||
const errorTestFile = '' as any; | ||
|
||
const errorFileContent = { | ||
status: 'rejected', | ||
readValue: 'Failed to read file test1.txt', | ||
originFile: null, | ||
}; | ||
|
||
describe('useFileReader', () => { | ||
describe('Success Case', () => { | ||
it('should return the normal file contents in "fileContents" when a value of type "File" is passed as an argument to "readFile"', async () => { | ||
const { result } = renderHook(() => useFileReader()); | ||
const expectedSuccessFileContents = [getSuccessFileContent(testFile1)]; | ||
|
||
await waitFor(async () => { | ||
const fileContents = await result.current.readFile({ | ||
file: testFile1, | ||
readType: 'readAsText', | ||
}); | ||
|
||
expect(result.current.isLoading).toBeTruthy(); | ||
expect(fileContents).toEqual(expectedSuccessFileContents); | ||
}); | ||
|
||
await waitFor(() => { | ||
expect(result.current.isLoading).toBeFalsy(); | ||
expect(result.current.fileContents).toEqual( | ||
expectedSuccessFileContents | ||
); | ||
}); | ||
}); | ||
|
||
it('should return the normal file contents in "fileContents" when a value of type "FileList" is passed as an argument to "readFile"', async () => { | ||
const { result } = renderHook(() => useFileReader()); | ||
const expectedSuccessFileContents = [ | ||
getSuccessFileContent(testFile1), | ||
getSuccessFileContent(testFile2), | ||
]; | ||
|
||
await waitFor(async () => { | ||
const fileContents = await result.current.readFile({ | ||
file: testFileList, | ||
readType: 'readAsText', | ||
}); | ||
|
||
expect(result.current.isLoading).toBeTruthy(); | ||
expect(fileContents).toEqual(expectedSuccessFileContents); | ||
}); | ||
|
||
await waitFor(() => { | ||
expect(result.current.isLoading).toBeFalsy(); | ||
expect(result.current.fileContents).toEqual( | ||
expectedSuccessFileContents | ||
); | ||
}); | ||
}); | ||
|
||
it('should only read files of types specified in the "accepts" attribute', async () => { | ||
const { result } = renderHook(() => useFileReader()); | ||
const expectedSuccessFileContents = [getSuccessFileContent(testFile2)]; | ||
|
||
await waitFor(async () => { | ||
const fileContents = await result.current.readFile({ | ||
file: testFileList, | ||
readType: 'readAsText', | ||
accepts: ['text/csv'], | ||
}); | ||
expect(result.current.isLoading).toBeTruthy(); | ||
expect(fileContents).toEqual(expectedSuccessFileContents); | ||
}); | ||
|
||
await waitFor(() => { | ||
expect(result.current.isLoading).toBeFalsy(); | ||
expect(result.current.fileContents).toEqual( | ||
expectedSuccessFileContents | ||
); | ||
}); | ||
}); | ||
}); | ||
|
||
describe('Error Case', () => { | ||
// Line: getReaderPromise - reader.onerror | ||
it('should return the error contents in "fileContents" when "reader.onerror" is called', async () => { | ||
const { result } = renderHook(() => useFileReader()); | ||
const failedExpectedFileContents = [errorFileContent]; | ||
|
||
vi.stubGlobal('FileReader', MockFileReaderForcedCallOnError); | ||
|
||
await waitFor(async () => { | ||
const fileContents = await result.current.readFile({ | ||
file: testFile1, | ||
readType: 'readAsText', | ||
}); | ||
expect(fileContents).toEqual(failedExpectedFileContents); | ||
}); | ||
|
||
await waitFor(() => { | ||
expect(result.current.fileContents).toEqual(failedExpectedFileContents); | ||
}); | ||
}); | ||
|
||
// Line: readerPromises - catch | ||
it('should return the error contents in "fileContents" if an error occurs during the call to "reader[readType]"', async () => { | ||
const { result } = renderHook(() => useFileReader()); | ||
const failedExpectedFileContents = [errorFileContent]; | ||
|
||
vi.stubGlobal('FileReader', MockFileReaderThrowError); | ||
|
||
await waitFor(async () => { | ||
const fileContents = await result.current.readFile({ | ||
file: testFile1, | ||
readType: 'readAsText', | ||
}); | ||
|
||
expect(fileContents).toEqual(failedExpectedFileContents); | ||
}); | ||
|
||
await waitFor(() => { | ||
expect(result.current.fileContents).toEqual(failedExpectedFileContents); | ||
}); | ||
}); | ||
|
||
// Line: inValidFileType | ||
it('should return an empty array for "fileContents" if the argument to "readFile" is neither of type "File" nor "FileList"', async () => { | ||
const { result } = renderHook(() => useFileReader()); | ||
|
||
await waitFor(async () => { | ||
const fileContents = await result.current.readFile({ | ||
file: errorTestFile, | ||
readType: 'readAsText', | ||
}); | ||
expect(fileContents).toEqual([]); | ||
}); | ||
|
||
await waitFor(() => { | ||
expect(result.current.fileContents).toEqual([]); | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
export const mockFileList = (files: File[]): FileList => { | ||
const input = document.createElement('input'); | ||
input.setAttribute('type', 'file'); | ||
input.multiple = true; | ||
|
||
const fileList: FileList = Object.create(input.files); | ||
|
||
files.forEach((file, idx) => { | ||
fileList[idx] = file; | ||
}); | ||
|
||
Object.defineProperty(fileList, 'length', { value: files.length }); | ||
return fileList; | ||
}; | ||
|
||
export class MockFileReaderForcedCallOnError { | ||
onerror: (...args: any[]) => any; | ||
|
||
constructor() { | ||
this.onerror = () => {}; | ||
} | ||
|
||
readAsText(file: File) { | ||
if (this.onerror) { | ||
setTimeout(() => { | ||
this.onerror(new Error(`Error, ${file.name}`)); | ||
}, 0); | ||
} | ||
} | ||
} | ||
|
||
export class MockFileReaderThrowError { | ||
onerror: (...args: any[]) => any; | ||
|
||
constructor() { | ||
this.onerror = () => {}; | ||
} | ||
|
||
readAsText() { | ||
throw new Error('Failed to read file'); | ||
} | ||
} |