Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FE] refactor: 기존 이미지 업로드로 수정 #599

Merged
merged 7 commits into from
Sep 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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