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

[패널 페이지] 질문 아이템, 답변 리스트 컴포넌트로 분리한다 #474

Merged
merged 14 commits into from
Sep 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
37 changes: 37 additions & 0 deletions src/components/panel/AnswerList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Answer } from '@/lib/api/answer';

import React from 'react';

import { Account } from '@/components/vectors';
import { formatDistance } from '@/lib/format-date';

interface Props {
nickname: string;
answers: Answer[];
now: Date;
}
export function AnswerList({ nickname, answers, now }: Props): JSX.Element {
return (
<ul>
{answers.map((answer) => (
<li
key={answer.id}
className="flex flex-col gap-3 p-5 w-full border-y border-grey-light"
>
<div className="flex items-center justify-between">
<div className="flex justify-start items-center gap-1 overflow-hidden">
<div role="img" aria-hidden>
<Account className="fill-grey-darkest" />
</div>
<div className="font-bold whitespace-nowrap">{nickname}</div>
<div className="text-grey-dark">
{formatDistance(now, new Date(answer.createdAt))}
</div>
</div>
</div>
<div className="whitespace-pre-wrap">{answer.content}</div>
</li>
))}
</ul>
);
}
167 changes: 6 additions & 161 deletions src/components/panel/InfiniteQuestionList.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,20 @@
import type { MyActiveInfo } from '@/lib/api/active-Info';
import type { Panel } from '@/lib/api/panel';
import type {
GetQuestionsParams,
Question,
QuestionPage,
} from '@/lib/api/question';
import type { LikeQuestionEvent } from '@/lib/socketClient';
import type { InfiniteData } from '@tanstack/react-query';
import type { GetQuestionsParams } from '@/lib/api/question';

import React, { useCallback, useState } from 'react';

import { useQuery, useQueryClient } from '@tanstack/react-query';
import { clsx } from 'clsx';
import { produce } from 'immer';

import { useSocketClient } from '@/contexts/SocketClientContext';
import { activeInfoDetailQuery } from '@/hooks/queries/active-info';
import {
useLikeQuestionMutation,
useQuestionsInfiniteQuery,
} from '@/hooks/queries/question';
import { ApiError } from '@/lib/apiClient';
import { queryKey } from '@/lib/queryKey';

import { IntersectionArea } from '../system/IntersectionArea';
import { IntersectionArea } from '@/components/system/IntersectionArea';
import { useQuestionsInfiniteQuery } from '@/hooks/queries/question';

import { QuestionList } from './QuestionList';

interface Props {
panelId: Panel['sid'];
}
type Sort = GetQuestionsParams['sort'];

export function InfiniteQuestionList({ panelId }: Props): JSX.Element {
const [sort, setSort] = useState<Sort>(undefined);
const questionsQuery = useQuestionsInfiniteQuery(panelId, sort);
Expand All @@ -46,92 +30,6 @@ export function InfiniteQuestionList({ panelId }: Props): JSX.Element {
[questionsQuery],
);

const socketClient = useSocketClient();
const queryClient = useQueryClient();
const likeQuestionMutation = useLikeQuestionMutation<{
prevQuestions: InfiniteData<QuestionPage> | undefined;
prevActiveInfo: MyActiveInfo | undefined;
}>({
onMutate: async ({ id, active }) => {
await queryClient.cancelQueries(queryKey.question.lists());

const prevQuestions = queryClient.getQueryData<
InfiniteData<QuestionPage>
>(queryKey.question.list(panelId, sort));
queryClient.setQueryData<InfiniteData<QuestionPage>>(
queryKey.question.list(panelId, sort),
updateQuestion(id, active),
);

const prevActiveInfo = queryClient.getQueryData<MyActiveInfo>(
activeInfoDetailQuery(panelId).queryKey,
);
queryClient.setQueryData<MyActiveInfo>(
activeInfoDetailQuery(panelId).queryKey,
updateLikedList(id, active),
);

return { prevActiveInfo, prevQuestions };
},
onSuccess: ({ id, likeNum }) => {
socketClient.publishToPanel<LikeQuestionEvent>(panelId, {
eventType: 'LIKE_QUESTION',
data: {
questionId: id,
likeNum,
},
});
},
onError: (err, variables, context) => {
if (err instanceof SyntaxError) return;
if (!(err instanceof ApiError)) return;
if (err.data === undefined) return;

const { statusCode, code } = err.data;

if (statusCode === 400) {
if (
code === 'INVALID_ACTIVE_LIKE_QUESTION' ||
code === 'INVALID_INACTIVE_LIKE_QUESTION'
) {
queryClient.setQueryData<InfiniteData<QuestionPage>>(
queryKey.question.list(panelId, sort),
context?.prevQuestions,
);
queryClient.setQueryData<MyActiveInfo>(
queryKey.activeInfo.detail(panelId),
context?.prevActiveInfo,
);
}
} else if (statusCode === 404) {
if (code === 'NOT_EXIST_QUESTION') {
queryClient.setQueryData<InfiniteData<QuestionPage>>(
queryKey.question.list(panelId, sort),
removeQuestion(variables.id),
);
queryClient.setQueryData<MyActiveInfo>(
queryKey.activeInfo.detail(panelId),
removeQuestionFromLikedList(variables.id),
);
}
}
},
onSettled: () => {
queryClient.invalidateQueries(queryKey.question.lists());
},
});

// [NOTE] 패널 페이지 로더에서 active info query를 `staleTime: Infinity`로 prefetch하므로
// active info query의 데이터가 fresh하다는 것이 보장된다
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
const activeInfoQuery = useQuery(activeInfoDetailQuery(panelId)).data!;
const handleLikeButtonClick = (question: Question) => () => {
likeQuestionMutation.mutate({
id: question.id,
active: !activeInfoQuery.likedIds.includes(question.id),
});
};

// TODO: fallback UI 제공
if (questionsQuery.isLoading) return <div>loading</div>;
if (questionsQuery.isError) return <div>error</div>;
Expand Down Expand Up @@ -172,64 +70,11 @@ export function InfiniteQuestionList({ panelId }: Props): JSX.Element {
</button>
</div>
<QuestionList
panelId={panelId}
sort={sort}
questionPages={questionPages}
onLikeButtonClick={handleLikeButtonClick}
likeIds={activeInfoQuery.likedIds}
/>
<IntersectionArea onIntersection={fetchQuestions} />
</div>
);
}

// TODO: mutate할 때 page id 받아서 for문 없애기
const updateQuestion =
(questionId: Question['id'], active: boolean) =>
(prevQuestions: InfiniteData<QuestionPage> | undefined) =>
produce(prevQuestions, (draft) => {
if (!draft) return;

draft.pages.forEach((page) => {
page.questions.forEach((question) => {
if (question.id === questionId) {
if (active) question.likeNum += 1;
else question.likeNum -= 1;
}
});
});
});

const removeQuestion =
(questionId: Question['id']) =>
(prevQuestions: InfiniteData<QuestionPage> | undefined) =>
produce(prevQuestions, (draft) => {
if (!draft) return;

draft.pages.forEach((page) => {
page.questions = page.questions.filter(
(question) => question.id !== questionId,
);
});
});

const updateLikedList =
(questionId: Question['id'], active: boolean) =>
(prevActiveInfo: MyActiveInfo | undefined) =>
produce(prevActiveInfo, (draft) => {
if (!draft) return;

if (active) {
draft.likedIds.push(questionId);
} else {
draft.likedIds = draft.likedIds.filter(
(likeId) => likeId !== questionId,
);
}
});

const removeQuestionFromLikedList =
(questionId: Question['id']) => (prevActiveInfo: MyActiveInfo | undefined) =>
produce(prevActiveInfo, (draft) => {
if (!draft) return;

draft.likedIds = draft.likedIds.filter((likeId) => likeId !== questionId);
});
100 changes: 34 additions & 66 deletions src/components/panel/QAModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { flushSync } from 'react-dom';
import { useRouteLoaderData } from 'react-router-dom';

import { Button } from '@/components/system/Button';
import { ArrowBack, Account, Like } from '@/components/vectors';
import { ArrowBack } from '@/components/vectors';
import { useSocketClient } from '@/contexts/SocketClientContext';
import {
useAnswersQuery,
Expand All @@ -22,9 +22,11 @@ import {
import { useUserStore } from '@/hooks/stores/useUserStore';
import { useCurrentDate } from '@/hooks/useCurrentDate';
import { useOutsideClick } from '@/hooks/useOutsideClick';
import { formatDistance } from '@/lib/format-date';
import { queryKey } from '@/lib/queryKey';

import { AnswerList } from './AnswerList';
import { QuestionItem } from './QuestionItem';

interface Props {
close: () => void;
questionId: Question['id'];
Expand Down Expand Up @@ -135,47 +137,13 @@ export function QAModal({
aria-label="질문과 답변 모달"
className="fixed inset-0 flex flex-col gap-6 overflow-auto justify-start bg-white"
>
<header className="flex justify-start items-center gap-2 bg-primary-dark shadow-md px-3 min-h-[64px]">
<button type="button" className="rounded-full p-1" onClick={close}>
<ArrowBack className="fill-white" />
<span className="sr-only">뒤로 가기</span>
</button>
<span className="text-white">{panel.title}</span>
</header>
<div className="flex flex-col gap-3 p-5 w-full border-y border-grey-light">
<div className="flex items-center justify-between">
<div className="flex justify-start items-center gap-1 overflow-hidden">
<div role="img" aria-hidden>
<Account className="fill-grey-darkest" />
</div>
<div className="font-bold whitespace-nowrap">익명</div>
<div className="text-grey-dark">
{formatDistance(now, new Date(question.createdAt))}
</div>
{question.answerNum ? (
<span className="ml-1 px-3 rounded-2xl bg-primary text-white text-sm">
답변 {question.answerNum}개
</span>
) : null}
</div>
<button
aria-label={`좋아요 버튼, 좋아요 ${question.likeNum}개`}
aria-pressed={isActived}
className={clsx(
'flex itesm-center gap-2 py-1 px-2 rounded-2xl border-2 text-sm',
isActived
? 'border-green font-bold text-green bg-green-light fill-green hover:bg-green-lighter active:opacity-80'
: 'border-grey-light text-grey-dark fill-grey-dark hover:bg-grey-lighter active:opacity-80',
)}
type="button"
onClick={onLikeButtonClick}
>
<Like className="w-5 h-5" />
{question.likeNum}
</button>
</div>
<div className="whitespace-pre-wrap">{question.content}</div>
</div>
<ModalHeader title={panel.title} onGoBackButtonClick={close} />
<QuestionItem
question={question}
isActived={isActived}
onLikeButtonClick={onLikeButtonClick}
now={new Date()}
/>
{userId === author.id && (
<div
ref={formContainer}
Expand Down Expand Up @@ -269,30 +237,30 @@ export function QAModal({
</div>
)}
<div className="flex-1">
<ul>
{answers.map((answer) => (
<li
key={answer.id}
className="flex flex-col gap-3 p-5 w-full border-y border-grey-light"
>
<div className="flex items-center justify-between">
<div className="flex justify-start items-center gap-1 overflow-hidden">
<div role="img" aria-hidden>
<Account className="fill-grey-darkest" />
</div>
<div className="font-bold whitespace-nowrap">
{author.nickname}
</div>
<div className="text-grey-dark">
{formatDistance(now, new Date(answer.createdAt))}
</div>
</div>
</div>
<div className="whitespace-pre-wrap">{answer.content}</div>
</li>
))}
</ul>
<AnswerList answers={answers} nickname={author.nickname} now={now} />
</div>
</div>
);
}

function ModalHeader({
title,
onGoBackButtonClick,
}: {
title: string;
onGoBackButtonClick: () => void;
}): JSX.Element {
return (
<header className="flex justify-start items-center gap-2 bg-primary-dark shadow-md px-3 min-h-[64px]">
<button
type="button"
className="rounded-full p-1"
onClick={onGoBackButtonClick}
>
<ArrowBack className="fill-white" />
<span className="sr-only">뒤로 가기</span>
</button>
<span className="text-white">{title}</span>
</header>
);
}
Loading