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 13 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
13 changes: 13 additions & 0 deletions frontend/src/components/Recipe/CommentForm/CommentForm.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react';

import CommentForm from './CommentForm';

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

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

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

import { useToastActionContext } from '@/hooks/context';
import useRecipeCommentMutation from '@/hooks/queries/recipe/useRecipeCommentMutation';
Copy link
Collaborator

Choose a reason for hiding this comment

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

index에 추가해주세요.!


interface CommentFormProps {
recipeId: number;
}

const MAX_COMMENT_LENGTH = 200;

const CommentForm = ({ recipeId }: CommentFormProps) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

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

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

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

const handleSubmitComment: FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault();

mutate(
{ comment: commentValue },
{
onSuccess: () => {
setCommentValue('');
toast.success('댓글이 등록되었습니다.');
},
onError: (error) => {
if (error instanceof Error) {
toast.error(error.message);
return;
}

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

return (
<>
<Form onSubmit={handleSubmitComment}>
<CommentTextarea
placeholder="댓글을 입력하세요. (200자)"
value={commentValue}
onChange={handleCommentInput}
maxLength={MAX_COMMENT_LENGTH}
/>
<SubmitButton size="xs" customWidth="40px" customHeight="auto" disabled={commentValue.length === 0}>
등록
</SubmitButton>
</Form>
<Spacing size={8} />
<Text size="xs" color={theme.textColors.info} align="right">
{commentValue.length}자 / {MAX_COMMENT_LENGTH}자
</Text>
</>
);
};

export default CommentForm;

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

const CommentTextarea = styled(Textarea)`
width: calc(100% - 50px);
padding: 8px;
font-size: 1.4rem;
`;

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: {
recipeComment: 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 {
recipeComment: Comment;
}

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

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">{comment}</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;
`;
13 changes: 13 additions & 0 deletions frontend/src/components/Recipe/CommentList/CommentList.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react';

import CommentList from './CommentList';

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

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

export const Default: Story = {};
19 changes: 19 additions & 0 deletions frontend/src/components/Recipe/CommentList/CommentList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import CommentItem from '../CommentItem/CommentItem';

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

interface CommentListProps {
comments: Comment[];
}

const CommentList = ({ comments }: CommentListProps) => {
return (
<>
{comments.map((comment) => (
<CommentItem key={comment.id} recipeComment={comment} />
))}
</>
);
};

export default CommentList;
3 changes: 3 additions & 0 deletions frontend/src/components/Recipe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ 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 CommentForm } from './CommentForm/CommentForm';
export { default as CommentList } from './CommentList/CommentList';
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 {
comment: 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"
},
"comment": "저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. ",
"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"
},
"comment": "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"
},
"comment": "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));
}),
];
28 changes: 23 additions & 5 deletions frontend/src/pages/RecipeDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { Heading, Spacing, Text, theme } from '@fun-eat/design-system';
import { Divider, Heading, Spacing, Text, theme } from '@fun-eat/design-system';
import { useQueryErrorResetBoundary } from '@tanstack/react-query';
import { Suspense } from 'react';
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 { ErrorBoundary, ErrorComponent, Loading, SectionTitle } from '@/components/Common';
import { CommentForm, CommentList, 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 { reset } = useQueryErrorResetBoundary();

const { id, images, title, content, author, products, totalPrice, favoriteCount, favorite, createdAt } = recipeDetail;

return (
Expand Down Expand Up @@ -65,7 +70,20 @@ export const RecipeDetailPage = () => {
<RecipeContent size="lg" lineHeight="lg">
{content}
</RecipeContent>
<Spacing size={40} />
<Spacing size={24} />
<Divider variant="disabled" customHeight="2px" />
<Spacing size={24} />
<Heading as="h3" size="lg">
댓글 ({recipeComments.length}개)
</Heading>
<Spacing size={12} />
<ErrorBoundary fallback={ErrorComponent} handleReset={reset}>
<Suspense fallback={<Loading />}>
<CommentList comments={recipeComments} />
</Suspense>
</ErrorBoundary>
<CommentForm recipeId={Number(recipeId)} />
Copy link
Collaborator

Choose a reason for hiding this comment

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

하나를 말해도 열을 아는 천재 타미
파이팅! 👍

<Spacing size={12} />
</RecipeDetailPageContainer>
);
};
Expand Down
Loading
Loading