Skip to content

Commit

Permalink
[FE] refactor: 기존 이미지 업로드로 수정 (#599)
Browse files Browse the repository at this point in the history
* refactor: 이미지 파일 이름 수정 삭제

* feat: useFormData 추가

* refactor: 꿀조합 폼 요청 폼데이터로 수정

* refactor: 리뷰 폼 요청 폼데이터로 수정

* refactor: 회원 정보 수정 폼 요청 폼데이터로 수정

* refactor: s3 관련 로직 삭제

* test: useImageUploader 훅 테스트 작성
  • Loading branch information
Leejin-Yang authored Sep 12, 2023
1 parent 69ecf96 commit 5e0a91a
Show file tree
Hide file tree
Showing 22 changed files with 188 additions and 194 deletions.
61 changes: 61 additions & 0 deletions frontend/__tests__/hooks/useImageUploader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useImageUploader } from '@/hooks/common';
import { renderHook, act } from '@testing-library/react';

const originalCreateObjectUrl = URL.createObjectURL;
const originalRevokeObjectUrl = URL.revokeObjectURL;

beforeAll(() => {
URL.createObjectURL = jest.fn(() => 'mocked url');
URL.revokeObjectURL = jest.fn();
});

afterAll(() => {
URL.createObjectURL = originalCreateObjectUrl;
URL.revokeObjectURL = originalRevokeObjectUrl;
});

it('uploadImage를 사용하여 이미지 파일을 업로드할 수 있다.', () => {
const { result } = renderHook(() => useImageUploader());

const file = new File(['dummy content'], 'example.png', { type: 'image/png' });

act(() => {
result.current.uploadImage(file);
});

expect(result.current.imageFile).toBe(file);
expect(result.current.previewImage).toBe('mocked url');
expect(URL.createObjectURL).toHaveBeenCalledWith(file);
});

it('이미지 파일이 아니면 "이미지 파일만 업로드 가능합니다." 메시지를 보여주는 alert 창이 뜬다.', () => {
const { result } = renderHook(() => useImageUploader());

const file = new File(['dummy content'], 'example.txt', { type: 'text/plain' });

global.alert = jest.fn();

act(() => {
result.current.uploadImage(file);
});

expect(global.alert).toHaveBeenCalledWith('이미지 파일만 업로드 가능합니다.');
});

it('deleteImage를 사용하여 이미지 파일을 삭제할 수 있다.', () => {
const { result } = renderHook(() => useImageUploader());

const file = new File(['dummy content'], 'example.png', { type: 'image/png' });

act(() => {
result.current.uploadImage(file);
});

act(() => {
result.current.deleteImage();
});

expect(result.current.imageFile).toBeNull();
expect(result.current.previewImage).toBe('');
expect(URL.revokeObjectURL).toHaveBeenCalledWith('mocked url');
});
13 changes: 11 additions & 2 deletions frontend/src/apis/ApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ export class ApiClient {
});
}

postData({ params, queries, credentials = false }: RequestOptions, body?: FormData) {
postData({ params, queries, credentials = false }: RequestOptions, body: FormData) {
return fetchApi(this.getUrl(params, queries), {
method: 'POST',
headers: this.#headers,
body: body ? body : null,
body: body,
credentials: credentials ? 'include' : 'omit',
});
}
Expand All @@ -64,6 +64,15 @@ export class ApiClient {
});
}

putData({ params, queries, credentials = false }: RequestOptions, body: FormData) {
return fetchApi(this.getUrl(params, queries), {
method: 'PUT',
headers: this.#headers,
body: body,
credentials: credentials ? 'include' : 'omit',
});
}

delete<B>({ params, queries, credentials = false }: RequestOptions, body?: B) {
return fetchApi(this.getUrl(params, queries), {
method: 'DELETE',
Expand Down
1 change: 0 additions & 1 deletion frontend/src/apis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,3 @@ export const memberApi = new ApiClient('/members');
export const recipeApi = new ApiClient('/recipes');
export const searchApi = new ApiClient('/search');
export const logoutApi = new ApiClient('/logout');
export const s3Api = new ApiClient('/s3');
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import RecipeNameInput from '../RecipeNameInput/RecipeNameInput';
import RecipeUsedProducts from '../RecipeUsedProducts/RecipeUsedProducts';

import { ImageUploader, SvgIcon } from '@/components/Common';
import { useImageUploader } from '@/hooks/common';
import { useImageUploader, useFormData } from '@/hooks/common';
import { useRecipeFormValueContext, useRecipeFormActionContext } from '@/hooks/context';
import { useRecipeRegisterFormMutation } from '@/hooks/queries/recipe';
import { useS3Upload } from '@/hooks/s3';
import type { RecipeRequest } from '@/types/recipe';

interface RecipeRegisterFormProps {
closeRecipeDialog: () => void;
Expand All @@ -20,12 +20,18 @@ const RecipeRegisterForm = ({ closeRecipeDialog }: RecipeRegisterFormProps) => {
const theme = useTheme();

const { previewImage, imageFile, uploadImage, deleteImage } = useImageUploader();
const { uploadToS3, fileUrl } = useS3Upload(imageFile);

const recipeFormValue = useRecipeFormValueContext();
const { resetRecipeFormValue } = useRecipeFormActionContext();

const { mutateAsync } = useRecipeRegisterFormMutation();
const formData = useFormData<RecipeRequest>({
imageKey: 'images',
imageFile: imageFile === null ? imageFile : [imageFile],
formContentKey: 'recipeRequest',
formContent: recipeFormValue,
});

const { mutate } = useRecipeRegisterFormMutation();

const isValid =
recipeFormValue.title.length > 0 && recipeFormValue.content.length > 0 && recipeFormValue.productIds.length > 0;
Expand All @@ -39,26 +45,20 @@ const RecipeRegisterForm = ({ closeRecipeDialog }: RecipeRegisterFormProps) => {
const handleRecipeFormSubmit: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();

try {
await uploadToS3();
await mutateAsync(
{ ...recipeFormValue, images: fileUrl !== null ? [fileUrl] : null },
{
onSuccess: () => {
resetAndCloseForm();
},
mutate(formData, {
onSuccess: () => {
resetAndCloseForm();
},
onError: (error) => {
resetAndCloseForm();
if (error instanceof Error) {
alert(error.message);
return;
}
);
} catch (error) {
resetAndCloseForm();

if (error instanceof Error) {
alert(error.message);
return;
}

alert('꿀조합 등록을 다시 시도해주세요.');
}
alert('꿀조합 등록을 다시 시도해주세요');
},
});
};

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import StarRate from '../StarRate/StarRate';
import { ImageUploader, SvgIcon } from '@/components/Common';
import { ProductOverviewItem } from '@/components/Product';
import { MIN_DISPLAYED_TAGS_LENGTH } from '@/constants';
import { useImageUploader, useScroll } from '@/hooks/common';
import { useFormData, useImageUploader, useScroll } from '@/hooks/common';
import { useReviewFormActionContext, useReviewFormValueContext } from '@/hooks/context';
import { useProductDetailQuery } from '@/hooks/queries/product';
import { useReviewRegisterFormMutation } from '@/hooks/queries/review';
import { useS3Upload } from '@/hooks/s3';
import type { ReviewRequest } from '@/types/review';

const MIN_RATING_SCORE = 0;
const MIN_SELECTED_TAGS_COUNT = 1;
Expand All @@ -28,22 +28,27 @@ interface ReviewRegisterFormProps {

const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog }: ReviewRegisterFormProps) => {
const { scrollToPosition } = useScroll();

const { previewImage, imageFile, uploadImage, deleteImage } = useImageUploader();
const { uploadToS3, fileUrl } = useS3Upload(imageFile);

const reviewFormValue = useReviewFormValueContext();
const { resetReviewFormValue } = useReviewFormActionContext();

const { data: productDetail } = useProductDetailQuery(productId);
const { mutateAsync } = useReviewRegisterFormMutation(productId);
const { mutate } = useReviewRegisterFormMutation(productId);

const isValid =
reviewFormValue.rating > MIN_RATING_SCORE &&
reviewFormValue.tagIds.length >= MIN_SELECTED_TAGS_COUNT &&
reviewFormValue.tagIds.length <= MIN_DISPLAYED_TAGS_LENGTH &&
reviewFormValue.content.length > MIN_CONTENT_LENGTH;

const formData = useFormData<ReviewRequest>({
imageKey: 'image',
imageFile: imageFile,
formContentKey: 'reviewRequest',
formContent: reviewFormValue,
});

const resetAndCloseForm = () => {
deleteImage();
resetReviewFormValue();
Expand All @@ -53,27 +58,21 @@ const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog }: ReviewR
const handleSubmit: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();

try {
await uploadToS3();
await mutateAsync(
{ ...reviewFormValue, image: fileUrl },
{
onSuccess: () => {
resetAndCloseForm();
scrollToPosition(targetRef);
},
mutate(formData, {
onSuccess: () => {
resetAndCloseForm();
scrollToPosition(targetRef);
},
onError: (error) => {
resetAndCloseForm();
if (error instanceof Error) {
alert(error.message);
return;
}
);
} catch (error) {
resetAndCloseForm();

if (error instanceof Error) {
alert(error.message);
return;
}

alert('리뷰 등록을 다시 시도해주세요');
}
alert('리뷰 등록을 다시 시도해주세요');
},
});
};

return (
Expand Down
12 changes: 5 additions & 7 deletions frontend/src/contexts/RecipeFormContext.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import type { PropsWithChildren } from 'react';
import { createContext, useState } from 'react';

import type { RecipeRequest } from '@/types/recipe';

type RecipeValue = Omit<RecipeRequest, 'images'>;
import type { RecipeRequest, RecipeRequestKey } from '@/types/recipe';

interface RecipeFormActionParams {
target: keyof RecipeValue;
target: RecipeRequestKey;
value: string | number;
action?: 'add' | 'remove';
}
Expand All @@ -16,17 +14,17 @@ interface RecipeFormAction {
resetRecipeFormValue: () => void;
}

const initialRecipeFormValue: RecipeValue = {
const initialRecipeFormValue: RecipeRequest = {
title: '',
productIds: [],
content: '',
};

export const RecipeFormValueContext = createContext<RecipeValue | null>(null);
export const RecipeFormValueContext = createContext<RecipeRequest | null>(null);
export const RecipeFormActionContext = createContext<RecipeFormAction | null>(null);

const RecipeFormProvider = ({ children }: PropsWithChildren) => {
const [recipeFormValue, setRecipeFormValue] = useState<RecipeValue>({
const [recipeFormValue, setRecipeFormValue] = useState<RecipeRequest>({
title: '',
productIds: [],
content: '',
Expand Down
10 changes: 4 additions & 6 deletions frontend/src/contexts/ReviewFormContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ import type { PropsWithChildren } from 'react';
import { createContext, useState } from 'react';

import { MIN_DISPLAYED_TAGS_LENGTH } from '@/constants';
import type { ReviewRequest } from '@/types/review';

type ReviewValue = Omit<ReviewRequest, 'image'>;
import type { ReviewRequest, ReviewRequestKey } from '@/types/review';

interface ReviewFormActionParams {
target: keyof ReviewValue;
target: ReviewRequestKey;
value: string | number | boolean;
isSelected?: boolean;
}
Expand All @@ -17,14 +15,14 @@ interface ReviewFormAction {
resetReviewFormValue: () => void;
}

const initialReviewFormValue: ReviewValue = {
const initialReviewFormValue: ReviewRequest = {
rating: 0,
tagIds: [],
content: '',
rebuy: false,
};

export const ReviewFormValueContext = createContext<ReviewValue | null>(null);
export const ReviewFormValueContext = createContext<ReviewRequest | null>(null);
export const ReviewFormActionContext = createContext<ReviewFormAction | null>(null);

const ReviewFormProvider = ({ children }: PropsWithChildren) => {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/hooks/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export { default as useRoutePage } from './useRoutePage';
export { default as useScroll } from './useScroll';
export { default as useSortOption } from './useSortOption';
export { default as useImageUploader } from './useImageUploader';
export { default as useFormData } from './useFormData';
export { default as useTimeout } from './useTimeout';
export { default as useRouteChangeTracker } from './useRouteChangeTracker';
29 changes: 29 additions & 0 deletions frontend/src/hooks/common/useFormData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
interface useFormDataProps<T> {
imageKey: string;
imageFile: File | File[] | null;
formContentKey: string;
formContent: T;
}

const useFormData = <T>({ imageKey, imageFile, formContentKey, formContent }: useFormDataProps<T>) => {
const formData = new FormData();
const formContentString = JSON.stringify(formContent);
const formContentBlob = new Blob([formContentString], { type: 'application/json' });

formData.append(formContentKey, formContentBlob);

if (!imageFile) return formData;

if (Array.isArray(imageFile)) {
imageFile.forEach((file) => {
formData.append(imageKey, file, file.name);
});
return formData;
}

formData.append(imageKey, imageFile, imageFile.name);

return formData;
};

export default useFormData;
16 changes: 2 additions & 14 deletions frontend/src/hooks/common/useImageUploader.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
import { useState } from 'react';

import { uuid } from '@/utils/uuid';

const isImageFile = (file: File) => file.type !== 'image/png' && file.type !== 'image/jpeg';

const editImageFileName = (imageFile: File) => {
const fileName = imageFile.name;
const fileExtension = fileName.split('.').pop();
const newFileName = `${uuid()}.${fileExtension}`;

return new File([imageFile], newFileName, { type: imageFile.type });
};

const useImageUploader = () => {
const [imageFile, setImageFile] = useState<File | null>(null);
const [previewImage, setPreviewImage] = useState('');
Expand All @@ -22,10 +12,8 @@ const useImageUploader = () => {
return;
}

const editedImageFile = editImageFileName(imageFile);

setPreviewImage(URL.createObjectURL(editedImageFile));
setImageFile(editedImageFile);
setPreviewImage(URL.createObjectURL(imageFile));
setImageFile(imageFile);
};

const deleteImage = () => {
Expand Down
Loading

0 comments on commit 5e0a91a

Please sign in to comment.