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] feat: 꿀조합 댓글 기능 구현 #744

Merged
merged 22 commits into from
Oct 13, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
162d561
feat: CommentItem 컴포넌트 추가
xodms0309 Oct 11, 2023
6a47996
feat: 댓글 목록 가져오는 쿼리 추가
xodms0309 Oct 11, 2023
7d849bc
feat: 상품 상세 페이지에 댓글 컴포넌트 추가
xodms0309 Oct 11, 2023
5dc497f
feat: Input 컴포넌트 속성에 minWidth값 추가
xodms0309 Oct 12, 2023
e713a04
feat: CommentInput 컴포넌트 추가
xodms0309 Oct 12, 2023
f5c90fb
feat: 댓글 등록 기능 구현
xodms0309 Oct 12, 2023
33b36c0
feat: 사용자가 입력한 글자수 UI 추가
xodms0309 Oct 12, 2023
9a09d11
feat: 리뷰 반영
xodms0309 Oct 12, 2023
b0a25cc
feat: text area 텍스트 크기 수정
xodms0309 Oct 12, 2023
0e5fa37
feat: CommentList 컴포넌트 추가
xodms0309 Oct 12, 2023
02001e1
feat: 디자인 수정
xodms0309 Oct 12, 2023
78a91ba
feat: api 변경 적용
xodms0309 Oct 12, 2023
7dba7c3
refactor: CommentInput -> CommentForm으로 네이밍 수정
xodms0309 Oct 12, 2023
89af090
feat: data fetching 로직을 CommentList내부로 이동
xodms0309 Oct 12, 2023
67a109b
feat: 댓글 무한 스크롤로 변경
xodms0309 Oct 12, 2023
996e9d0
fix: 토스트 컴포넌트가 가운데 정렬되지 않는 문제 해결
xodms0309 Oct 12, 2023
6b4e689
feat: 전송 아이콘 추가
xodms0309 Oct 12, 2023
d6ed26b
feat: 댓글 컴포넌트를 fixed로 변경
xodms0309 Oct 12, 2023
a860bb6
feat: 댓글 컴포넌트 사이 공백 추가
xodms0309 Oct 12, 2023
838d38e
feat: Response 객체에 totalElements 값 추가
xodms0309 Oct 12, 2023
980e205
feat: pageParam의 기본값 추가
xodms0309 Oct 12, 2023
a08dd8e
feat: index.ts에서 export문 추가
xodms0309 Oct 13, 2023
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
11 changes: 8 additions & 3 deletions frontend/src/components/Common/Input/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ interface InputProps extends ComponentPropsWithRef<'input'> {
* Input 컴포넌트의 너비값입니다.
*/
customWidth?: string;
/**
* Input 컴포넌트의 최소 너비값입니다.
*/
minWidth?: string;
/**
* Input value에 에러가 있는지 여부입니다.
*/
Expand All @@ -24,12 +28,12 @@ interface InputProps extends ComponentPropsWithRef<'input'> {

const Input = forwardRef(
(
{ customWidth = '300px', isError = false, rightIcon, errorMessage, ...props }: InputProps,
{ customWidth = '300px', minWidth, isError = false, rightIcon, errorMessage, ...props }: InputProps,
ref: ForwardedRef<HTMLInputElement>
) => {
return (
<>
<InputContainer customWidth={customWidth}>
<InputContainer customWidth={customWidth} minWidth={minWidth}>
<CustomInput ref={ref} isError={isError} {...props} />
{rightIcon && <IconWrapper>{rightIcon}</IconWrapper>}
</InputContainer>
Expand All @@ -43,11 +47,12 @@ Input.displayName = 'Input';

export default Input;

type InputContainerStyleProps = Pick<InputProps, 'customWidth'>;
type InputContainerStyleProps = Pick<InputProps, 'customWidth' | 'minWidth'>;
type CustomInputStyleProps = Pick<InputProps, 'isError'>;

const InputContainer = styled.div<InputContainerStyleProps>`
position: relative;
min-width: ${({ minWidth }) => minWidth ?? 0};
max-width: ${({ customWidth }) => customWidth};
text-align: center;
`;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react';

import CommentInput from './CommentInput';

const meta: Meta<typeof CommentInput> = {
title: 'recipe/CommentInput',
component: CommentInput,
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};
80 changes: 80 additions & 0 deletions frontend/src/components/Recipe/CommentInput/CommentInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Button, Text, useTheme } from '@fun-eat/design-system';
import type { ChangeEventHandler, FormEventHandler } from 'react';
import { useState } from 'react';
import styled from 'styled-components';

import { Input } from '@/components/Common';
import { useToastActionContext } from '@/hooks/context';
import useRecipeCommentMutation from '@/hooks/queries/recipe/useRecipeCommentMutation';

interface CommentInputProps {
recipeId: number;
}

const MAX_COMMENT_LENGTH = 200;

const CommentInput = ({ recipeId }: CommentInputProps) => {
const [commentValue, setCommentValue] = useState('');
const { mutate } = useRecipeCommentMutation(recipeId);

const theme = useTheme();
const { toast } = useToastActionContext();

const handleCommentInput: ChangeEventHandler<HTMLInputElement> = (e) => {
setCommentValue(e.target.value);
};

const handleSubmitComment: FormEventHandler<HTMLFormElement> = async (e) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

async 없어도 잘 동작하겠네요!

e.preventDefault();

mutate(
{ content: commentValue },
{
onSuccess: () => {
setCommentValue('');
toast.success('댓글이 등록되었습니다.');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🥪

},
onError: (error) => {
if (error instanceof Error) {
alert(error.message);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지워도 될 거 같네여

return;
}

toast.error('댓글을 등록하는데 오류가 발생했습니다.');
},
}
);
};

return (
<>
<CommentInputForm onSubmit={handleSubmitComment}>
<Input
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

200자면 Input으로 했을 때 내용이 다 보이지 않을거 같아서 Textarea가 좋아보이는데 어떻게 생각하시나요??

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋아요 ~

placeholder="댓글을 입력하세요. (200자)"
minWidth="90%"
value={commentValue}
onChange={handleCommentInput}
/>
<SubmitButton size="xs" customWidth="40px" disabled={commentValue.length === 0}>
등록
</SubmitButton>
</CommentInputForm>
<Text size="xs" color={theme.textColors.info} align="right">
{commentValue.length} / {MAX_COMMENT_LENGTH}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스크린리더를 위해 '자'를 넣어주세요!
{commentValue.length}자 / {MAX_COMMENT_LENGTH}자

</Text>
</>
);
};

export default CommentInput;

const CommentInputForm = styled.form`
display: flex;
gap: 4px;
justify-content: space-around;
`;

const SubmitButton = styled(Button)`
background: ${({ theme, disabled }) => (disabled ? theme.colors.gray2 : theme.colors.primary)};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
`;
18 changes: 18 additions & 0 deletions frontend/src/components/Recipe/CommentItem/CommentItem.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/react';

import CommentItem from './CommentItem';

import comments from '@/mocks/data/comments.json';

const meta: Meta<typeof CommentItem> = {
title: 'recipe/CommentItem',
component: CommentItem,
args: {
comment: comments[0],
},
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};
50 changes: 50 additions & 0 deletions frontend/src/components/Recipe/CommentItem/CommentItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Divider, Spacing, Text, useTheme } from '@fun-eat/design-system';
import styled from 'styled-components';

import type { Comment } from '@/types/recipe';
import { getFormattedDate } from '@/utils/date';

interface CommentItemProps {
comment: Comment;
}

const CommentItem = ({ comment }: CommentItemProps) => {
const theme = useTheme();
const { author, content, createdAt } = comment;

return (
<>
<AuthorWrapper>
<AuthorProfileImage src={author.profileImage} alt={`${author.nickname}님의 프로필`} width={32} height={32} />
<div>
<Text size="xs" color={theme.textColors.info}>
{author.nickname} 님
</Text>
<Text size="xs" color={theme.textColors.info}>
{getFormattedDate(createdAt)}
</Text>
</div>
</AuthorWrapper>
<CommentContent size="sm">{content}</CommentContent>
<Divider variant="disabled" />
<Spacing size={16} />
</>
);
};

export default CommentItem;

const AuthorWrapper = styled.div`
display: flex;
gap: 12px;
align-items: center;
`;

const AuthorProfileImage = styled.img`
border: 1px solid ${({ theme }) => theme.colors.primary};
border-radius: 50%;
`;

const CommentContent = styled(Text)`
margin: 16px 0;
`;
2 changes: 2 additions & 0 deletions frontend/src/components/Recipe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export { default as RecipeItem } from './RecipeItem/RecipeItem';
export { default as RecipeList } from './RecipeList/RecipeList';
export { default as RecipeRegisterForm } from './RecipeRegisterForm/RecipeRegisterForm';
export { default as RecipeFavorite } from './RecipeFavorite/RecipeFavorite';
export { default as CommentItem } from './CommentItem/CommentItem';
export { default as CommentInput } from './CommentInput/CommentInput';
1 change: 1 addition & 0 deletions frontend/src/hooks/queries/recipe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { default as useRecipeDetailQuery } from './useRecipeDetailQuery';
export { default as useRecipeRegisterFormMutation } from './useRecipeRegisterFormMutation';
export { default as useRecipeFavoriteMutation } from './useRecipeFavoriteMutation';
export { default as useInfiniteRecipesQuery } from './useInfiniteRecipesQuery';
export { default as useRecipeCommentQuery } from './useRecipeCommentQuery';
24 changes: 24 additions & 0 deletions frontend/src/hooks/queries/recipe/useRecipeCommentMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';

import { recipeApi } from '@/apis';

interface RecipeCommentRequestBody {
content: string;
}

const headers = { 'Content-Type': 'application/json' };

const postRecipeComment = (recipeId: number, body: RecipeCommentRequestBody) => {
return recipeApi.post({ params: `/${recipeId}/comments`, credentials: true }, headers, body);
};

const useRecipeCommentMutation = (recipeId: number) => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (body: RecipeCommentRequestBody) => postRecipeComment(recipeId, body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['recipeComment', recipeId] }),
});
};

export default useRecipeCommentMutation;
16 changes: 16 additions & 0 deletions frontend/src/hooks/queries/recipe/useRecipeCommentQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useSuspendedQuery } from '../useSuspendedQuery';

import { recipeApi } from '@/apis';
import type { Comment } from '@/types/recipe';

const fetchRecipeComments = async (recipeId: number) => {
const response = await recipeApi.get({ params: `/${recipeId}/comments` });
const data: Comment[] = await response.json();
return data;
};

const useRecipeCommentQuery = (recipeId: number) => {
return useSuspendedQuery(['recipeComment', recipeId], () => fetchRecipeComments(recipeId));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

무한스크롤! 백엔드와 이야기해보세요~

};

export default useRecipeCommentQuery;
29 changes: 29 additions & 0 deletions frontend/src/mocks/data/comments.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[
{
"author": {
"nickname": "펀잇",
"profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34"
},
"content": "저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. ",
"createdAt": "2023-08-09T10:10:10",
"id": 1
},
{
"author": {
"nickname": "펀잇",
"profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34"
},
"content": "string",
"createdAt": "2023-08-09T10:10:10",
"id": 1
},
{
"author": {
"nickname": "펀잇",
"profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34"
},
"content": "string",
"createdAt": "2023-08-09T10:10:10",
"id": 1
}
]
9 changes: 9 additions & 0 deletions frontend/src/mocks/handlers/recipeHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { rest } from 'msw';

import { isRecipeSortOption, isSortOrder } from './utils';
import comments from '../data/comments.json';
import recipeDetail from '../data/recipeDetail.json';
import mockRecipes from '../data/recipes.json';

Expand Down Expand Up @@ -88,4 +89,12 @@ export const recipeHandlers = [
ctx.json({ ...sortedRecipes, recipes: sortedRecipes.recipes.slice(page * 5, (page + 1) * 5) })
);
}),

rest.get('/api/recipes/:recipeId/comments', (req, res, ctx) => {
return res(ctx.status(200), ctx.json(comments));
}),

rest.post('/api/recipes/:recipeId/comments', (req, res, ctx) => {
return res(ctx.status(201));
}),
];
20 changes: 16 additions & 4 deletions frontend/src/pages/RecipeDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { Heading, Spacing, Text, theme } from '@fun-eat/design-system';
import { Divider, Heading, Spacing, Text, theme } from '@fun-eat/design-system';
import { useParams } from 'react-router-dom';
import styled from 'styled-components';

import RecipePreviewImage from '@/assets/plate.svg';
import { SectionTitle } from '@/components/Common';
import { RecipeFavorite } from '@/components/Recipe';
import { useRecipeDetailQuery } from '@/hooks/queries/recipe';
import { CommentInput, CommentItem, RecipeFavorite } from '@/components/Recipe';
import { useRecipeCommentQuery, useRecipeDetailQuery } from '@/hooks/queries/recipe';
import { getFormattedDate } from '@/utils/date';

export const RecipeDetailPage = () => {
const { recipeId } = useParams();

const { data: recipeDetail } = useRecipeDetailQuery(Number(recipeId));
const { data: recipeComments } = useRecipeCommentQuery(Number(recipeId));
const { id, images, title, content, author, products, totalPrice, favoriteCount, favorite, createdAt } = recipeDetail;

return (
Expand Down Expand Up @@ -65,7 +66,18 @@ export const RecipeDetailPage = () => {
<RecipeContent size="lg" lineHeight="lg">
{content}
</RecipeContent>
<Spacing size={40} />
<Spacing size={16} />
<Divider variant="disabled" />
<Spacing size={16} />
<Heading as="h3" size="lg">
댓글 ({recipeComments.length}개)
</Heading>
<Spacing size={12} />
{recipeComments.map((comment) => (
<CommentItem key={comment.id} comment={comment} />
))}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

List 컴포넌트를 만들어서 Suspense 적용하는건 어떻게 생각하시나요??

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

옙~

<CommentInput recipeId={Number(recipeId)} />
<Spacing size={12} />
</RecipeDetailPageContainer>
);
};
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/types/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,10 @@ export interface RecipeFavoriteRequestBody {

type RecipeProductWithPrice = Pick<Product, 'id' | 'name' | 'price'>;
export type RecipeProduct = Omit<RecipeProductWithPrice, 'price'>;

export interface Comment {
id: number;
author: Member;
content: string;
createdAt: string;
}
Loading