diff --git a/.changeset/curly-seahorses-sit.md b/.changeset/curly-seahorses-sit.md new file mode 100644 index 000000000..f88d2a0a9 --- /dev/null +++ b/.changeset/curly-seahorses-sit.md @@ -0,0 +1,5 @@ +--- +'@modern-kit/react': minor +--- + +feat(react): useFileReader 훅 추가 diff --git a/docs/docs/react/hooks/useFileReader.mdx b/docs/docs/react/hooks/useFileReader.mdx new file mode 100644 index 000000000..1f026c6b4 --- /dev/null +++ b/docs/docs/react/hooks/useFileReader.mdx @@ -0,0 +1,63 @@ +# useFileReader + +`File` 객체를 원하는 읽기 메서드(`readAsText`,`readAsDataURL`,`readAsArrayBuffer`)로 읽고, 읽은 파일 컨텐츠를 반환하는 커스텀 훅입니다. + +
+ +## Interface +```tsx +type ReadType = 'readAsText' | 'readAsDataURL' | 'readAsArrayBuffer'; + +interface FileContent { + status: 'fulfilled' | 'rejected'; + readValue: string | ArrayBuffer; + originFile: Nullable; +} + +interface ReadFileOptions { + file: FileList | File; + readType: ReadType; + accepts?: string[]; +} + +const useFileReader: () => { + readFile: ({ + file, + readType, + accepts, + }: ReadFileOptions) => Promise; + 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) => { + if(!e.target.files) return; + + readFile({ file: e.target.files, readType: 'readAsText' }); + /* + * 1. readFile은 Promise 반환합니다. 해당 값은 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 ( +
+ +
+ ); +}; +``` \ No newline at end of file diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index ae78ba8c4..d88a78aed 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -2,6 +2,7 @@ export * from './useAsyncPreservedCallback'; export * from './useAsyncProcessQueue'; export * from './useBlockPromiseMultipleClick'; export * from './useDebounce'; +export * from './useFileReader'; export * from './useForceUpdate'; export * from './useImageStatus'; export * from './useIntersectionObserver'; diff --git a/packages/react/src/hooks/useFileReader/index.ts b/packages/react/src/hooks/useFileReader/index.ts new file mode 100644 index 000000000..8e30b580f --- /dev/null +++ b/packages/react/src/hooks/useFileReader/index.ts @@ -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; +} + +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>((resolve, reject) => { + reader.onload = () => { + resolve(reader.result); + }; + reader.onerror = () => { + reject(`Failed to read file ${file.name}`); + }; + }); +}; + +export const useFileReader = () => { + const [fileContents, setFileContents] = useState([]); + 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 }; +}; diff --git a/packages/react/src/hooks/useFileReader/useFileReader.spec.ts b/packages/react/src/hooks/useFileReader/useFileReader.spec.ts new file mode 100644 index 000000000..81bd8d85d --- /dev/null +++ b/packages/react/src/hooks/useFileReader/useFileReader.spec.ts @@ -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([]); + }); + }); + }); +}); diff --git a/packages/react/src/utils/test/mockFile.ts b/packages/react/src/utils/test/mockFile.ts new file mode 100644 index 000000000..b3688cfab --- /dev/null +++ b/packages/react/src/utils/test/mockFile.ts @@ -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'); + } +}