Skip to content

Commit

Permalink
feat(react): useFileReader 추가 (#111)
Browse files Browse the repository at this point in the history
* feat(react): useFileReader 추가

* docs: useFileReader 문서 추가

* docs: useFileReader 문서 수정
  • Loading branch information
ssi02014 authored May 8, 2024
1 parent d03d454 commit 75e98e7
Show file tree
Hide file tree
Showing 6 changed files with 376 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/curly-seahorses-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modern-kit/react': minor
---

feat(react): useFileReader 훅 추가
63 changes: 63 additions & 0 deletions docs/docs/react/hooks/useFileReader.mdx
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>
);
};
```
1 change: 1 addition & 0 deletions packages/react/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
97 changes: 97 additions & 0 deletions packages/react/src/hooks/useFileReader/index.ts
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 packages/react/src/hooks/useFileReader/useFileReader.spec.ts
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([]);
});
});
});
});
42 changes: 42 additions & 0 deletions packages/react/src/utils/test/mockFile.ts
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');
}
}

0 comments on commit 75e98e7

Please sign in to comment.