Skip to content

Commit a244340

Browse files
authored
feat(fe): Integrate question improvement API and enhance question/answer writing UI (#64)
* feat: add API for question improvement * refactor: remove unused question improvement API and related exports * feat: add API for question improvement with request and response schemas * feat: add utility functions for content body length validation * feat: add CreateQuestionModal components for improved question creation flow * feat: enhance CreateReplyModal with new footer and side components for improved user interaction * feat: enhance typography styles in index.css for improved readability * feat: update CreateReplyModalSide component to swap icon display based on content type * feat: update CreateQuestionModal and CreateQuestionModalFooter to use Question type for improved type safety * refactor: change getContentBodyLength function for improved readability
1 parent bf43058 commit a244340

File tree

12 files changed

+456
-94
lines changed

12 files changed

+456
-94
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export const getContentBodyLength = (body: string) => {
2+
const regex = /!?\[(\w+)\]\([^)]+\)/g;
3+
const matches = body.match(regex) || [];
4+
5+
return matches.reduce((length, match) => {
6+
const textContent = /\[(\w+)\]/.exec(match)?.[1] || '';
7+
return length - match.trim().length + textContent.trim().length;
8+
}, body.trim().length);
9+
};
10+
11+
export const isValidBodyLength = (body: string) => {
12+
return body.trim().length > 0 && body.trim().length <= 500;
13+
};
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import axios from 'axios';
2+
import { z } from 'zod';
3+
4+
export const QuestionImprovementRequestSchema = z.object({
5+
token: z.string(),
6+
sessionId: z.string(),
7+
body: z.string().min(0),
8+
});
9+
10+
export const QuestionImprovementResponseSchema = z.object({
11+
result: z.object({
12+
question: z.string(),
13+
}),
14+
});
15+
16+
export type QuestionImprovementRequest = z.infer<typeof QuestionImprovementRequestSchema>;
17+
18+
export type QuestionImprovementResponse = z.infer<typeof QuestionImprovementResponseSchema>;
19+
20+
export const postQuestionImprovement = (body: QuestionImprovementRequest) =>
21+
axios
22+
.post<QuestionImprovementResponse>('/api/ai/question-improve', QuestionImprovementRequestSchema.parse(body))
23+
.then((res) => QuestionImprovementResponseSchema.parse(res.data));
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { useMutation } from '@tanstack/react-query';
2+
import { useState } from 'react';
3+
4+
import {
5+
postQuestionImprovement,
6+
QuestionImprovementRequest,
7+
} from '@/features/create-update-question/api/improve-question.api';
8+
9+
export const useQuestionWritingSupport = ({ handleAccept }: { handleAccept: (body: string) => void }) => {
10+
const { mutate: questionImprovement, isPending: isQuestionImprovementInProgress } = useMutation({
11+
mutationFn: (request: QuestionImprovementRequest) => {
12+
return postQuestionImprovement(request);
13+
},
14+
onSuccess: (data) => {
15+
setSupportResult(data.result.question);
16+
},
17+
});
18+
19+
const [supportResult, setSupportResult] = useState<string | null>(null);
20+
21+
const accept = () => {
22+
if (supportResult) {
23+
handleAccept(supportResult);
24+
setSupportResult(null);
25+
}
26+
};
27+
28+
const reject = () => {
29+
setSupportResult(null);
30+
};
31+
32+
const requestEnable = !isQuestionImprovementInProgress;
33+
34+
return {
35+
questionImprovement,
36+
isQuestionImprovementInProgress,
37+
requestEnable,
38+
supportResult,
39+
accept,
40+
reject,
41+
};
42+
};

apps/client/src/features/create-update-question/ui/CreateQuestionModal.tsx

Lines changed: 58 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,78 @@
11
import { useState } from 'react';
2-
import Markdown from 'react-markdown';
32

43
import { useQuestionMutation } from '@/features/create-update-question/model/useQuestionMutation';
4+
import { useQuestionWritingSupport } from '@/features/create-update-question/model/useQuestionWritingSupport';
5+
import CreateQuestionModalFooter from '@/features/create-update-question/ui/CreateQuestionModalFooter';
6+
import CreateQuestionModalSide from '@/features/create-update-question/ui/CreateQuestionModalSide';
7+
import QuestionContentView from '@/features/create-update-question/ui/QuestionContentView';
58

6-
import { Question } from '@/entities/session';
9+
import { Question, useSessionStore } from '@/entities/session';
10+
import { getContentBodyLength, isValidBodyLength } from '@/entities/session/model/qna.util';
711

8-
import { Button } from '@/shared/ui/button';
9-
import { useModalContext } from '@/shared/ui/modal';
12+
import { useToastStore } from '@/shared/ui/toast';
1013

1114
interface CreateQuestionModalProps {
1215
question?: Question;
1316
}
1417

1518
function CreateQuestionModal({ question }: Readonly<CreateQuestionModalProps>) {
16-
const { closeModal } = useModalContext();
19+
const addToast = useToastStore((state) => state.addToast);
20+
21+
const token = useSessionStore((state) => state.sessionToken);
22+
const sessionId = useSessionStore((state) => state.sessionId);
23+
1724
const { body, setBody, handleSubmit, submitDisabled } = useQuestionMutation(question);
25+
const { questionImprovement, requestEnable, supportResult, accept, reject } = useQuestionWritingSupport({
26+
handleAccept: setBody,
27+
});
1828

1929
const [openPreview, setOpenPreview] = useState(false);
2030

31+
const bodyLength = getContentBodyLength(body);
32+
33+
const buttonEnabled = !submitDisabled && requestEnable && isValidBodyLength(body);
34+
35+
const handleCreateOrUpdate = () => {
36+
if (buttonEnabled) handleSubmit();
37+
};
38+
39+
const handleQuestionImprovement = () => {
40+
if (buttonEnabled && sessionId && token) {
41+
questionImprovement({ token, sessionId, body });
42+
}
43+
};
44+
45+
const handleQuestionSummary = () => {
46+
if (buttonEnabled) addToast({ type: 'INFO', message: '추후 업데이트 예정입니다.', duration: 3000 });
47+
};
48+
49+
const handleRetry = () => {
50+
if (sessionId && token) questionImprovement({ token, sessionId, body });
51+
};
52+
2153
return (
22-
<div className='flex h-[20rem] w-[40rem] flex-col gap-2 rounded-lg bg-gray-50 p-4'>
23-
{openPreview ? (
24-
<div className='flex-1 overflow-y-auto rounded border bg-white p-4'>
25-
<Markdown className='prose prose-stone'>{body}</Markdown>
26-
</div>
27-
) : (
28-
<textarea
29-
className='flex-1 resize-none rounded border p-4 focus:outline-none'
30-
value={body}
31-
onChange={(e) => setBody(e.target.value)}
32-
placeholder='질문을 남겨주세요.'
54+
<div className='relative flex h-[20rem] w-[40rem] flex-col rounded-lg bg-gray-50 p-4'>
55+
<div className='flex h-[15rem] flex-1 rounded border bg-white'>
56+
<QuestionContentView
57+
supportResult={supportResult}
58+
questionBody={body}
59+
openPreview={openPreview}
60+
isWritingPending={!requestEnable}
61+
onQuestionBodyChange={setBody}
3362
/>
34-
)}
35-
<footer className='flex h-[3rem] flex-row items-end justify-between'>
36-
<div className='flex flex-row gap-2'>
37-
<Button className='bg-indigo-600'>
38-
<div className='text-sm font-bold text-white'>질문 개선하기</div>
39-
</Button>
40-
<Button className='bg-indigo-600'>
41-
<div className='text-sm font-bold text-white'>질문 축약하기</div>
42-
</Button>
43-
<Button className='bg-gray-500' onClick={() => setOpenPreview(!openPreview)}>
44-
<div className='text-sm font-bold text-white'>{openPreview ? '작성하기' : '미리보기'}</div>
45-
</Button>
46-
</div>
47-
<div className='flex flex-row gap-2'>
48-
<Button className='bg-gray-500' onClick={closeModal}>
49-
<div className='text-sm font-bold text-white'>취소하기</div>
50-
</Button>
51-
<Button
52-
className={`${!submitDisabled ? 'bg-indigo-600' : 'cursor-not-allowed bg-indigo-300'}`}
53-
onClick={handleSubmit}
54-
>
55-
<div className='text-sm font-bold text-white'>{question ? '수정하기' : '생성하기'}</div>
56-
</Button>
57-
</div>
58-
</footer>
63+
<CreateQuestionModalSide bodyLength={bodyLength} openPreview={openPreview} setOpenPreview={setOpenPreview} />
64+
</div>
65+
<CreateQuestionModalFooter
66+
supportResult={supportResult}
67+
question={question}
68+
buttonEnabled={buttonEnabled}
69+
handleQuestionImprovement={handleQuestionImprovement}
70+
handleQuestionSummary={handleQuestionSummary}
71+
handleCreateOrUpdate={handleCreateOrUpdate}
72+
handleRetry={handleRetry}
73+
accept={accept}
74+
reject={reject}
75+
/>
5976
</div>
6077
);
6178
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Question } from '@/entities/session';
2+
3+
import { Button } from '@/shared/ui/button';
4+
import { useModalContext } from '@/shared/ui/modal';
5+
6+
interface CreateQuestionModalFooterProps {
7+
supportResult: string | null;
8+
question?: Question;
9+
buttonEnabled: boolean;
10+
handleQuestionImprovement: () => void;
11+
handleQuestionSummary: () => void;
12+
handleCreateOrUpdate: () => void;
13+
handleRetry: () => void;
14+
accept: () => void;
15+
reject: () => void;
16+
}
17+
18+
export default function CreateQuestionModalFooter({
19+
supportResult,
20+
question,
21+
buttonEnabled,
22+
handleQuestionImprovement,
23+
handleQuestionSummary,
24+
handleCreateOrUpdate,
25+
handleRetry,
26+
accept,
27+
reject,
28+
}: Readonly<CreateQuestionModalFooterProps>) {
29+
const { closeModal } = useModalContext();
30+
31+
return (
32+
<footer className='flex h-[3rem] flex-row items-end justify-between'>
33+
{supportResult === null ? (
34+
<>
35+
<div className='flex flex-row gap-2'>
36+
<Button
37+
className={`${buttonEnabled ? 'bg-indigo-600' : 'cursor-not-allowed bg-indigo-300'}`}
38+
onClick={handleQuestionImprovement}
39+
>
40+
<div className='text-sm font-bold text-white'>질문 개선하기</div>
41+
</Button>
42+
<Button
43+
className={`${buttonEnabled ? 'bg-indigo-600' : 'cursor-not-allowed bg-indigo-300'}`}
44+
onClick={handleQuestionSummary}
45+
>
46+
<div className='text-sm font-bold text-white'>질문 축약하기</div>
47+
</Button>
48+
</div>
49+
<div className='flex flex-row gap-2'>
50+
<Button className='bg-gray-500' onClick={closeModal}>
51+
<div className='text-sm font-bold text-white'>취소하기</div>
52+
</Button>
53+
<Button
54+
className={`${buttonEnabled ? 'bg-indigo-600' : 'cursor-not-allowed bg-indigo-300'}`}
55+
onClick={handleCreateOrUpdate}
56+
>
57+
<div className='text-sm font-bold text-white'>{question ? '수정하기' : '생성하기'}</div>
58+
</Button>
59+
</div>
60+
</>
61+
) : (
62+
<>
63+
<div className='flex flex-row gap-2'>
64+
<Button className='bg-gray-500' onClick={reject}>
65+
<div className='text-sm font-bold text-white'>취소하기</div>
66+
</Button>
67+
<Button className='bg-gray-500' onClick={handleRetry}>
68+
<div className='text-sm font-bold text-white'>다시 작성하기</div>
69+
</Button>
70+
<Button className='bg-indigo-600' onClick={accept}>
71+
<div className='text-sm font-bold text-white'>사용하기</div>
72+
</Button>
73+
</div>
74+
</>
75+
)}
76+
</footer>
77+
);
78+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { VscEdit, VscMarkdown } from 'react-icons/vsc';
2+
3+
interface CreateQuestionModalSideProps {
4+
bodyLength: number;
5+
openPreview: boolean;
6+
setOpenPreview: (openPreview: boolean) => void;
7+
}
8+
9+
export default function CreateQuestionModalSide({
10+
bodyLength,
11+
openPreview,
12+
setOpenPreview,
13+
}: Readonly<CreateQuestionModalSideProps>) {
14+
return (
15+
<div className='absolute right-8 flex h-[calc(100%-5rem)] flex-col items-center justify-between py-4'>
16+
<button
17+
className='flex h-10 w-10 items-center justify-center rounded-full border p-2 shadow-md'
18+
onClick={() => setOpenPreview(!openPreview)}
19+
>
20+
{openPreview ? <VscEdit size={32} /> : <VscMarkdown size={32} />}
21+
</button>
22+
<span className={`text-xs ${bodyLength > 500 ? 'text-red-600' : 'text-slate-400'}`}>{bodyLength}/500</span>
23+
</div>
24+
);
25+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import Markdown from 'react-markdown';
2+
3+
interface QuestionContentViewProps {
4+
supportResult: string | null;
5+
questionBody: string;
6+
openPreview: boolean;
7+
isWritingPending: boolean;
8+
onQuestionBodyChange: (body: string) => void;
9+
}
10+
11+
export default function QuestionContentView({
12+
supportResult,
13+
questionBody,
14+
openPreview,
15+
isWritingPending,
16+
onQuestionBodyChange,
17+
}: Readonly<QuestionContentViewProps>) {
18+
if (isWritingPending) {
19+
return (
20+
<div className='flex flex-1 items-center justify-center overflow-y-auto rounded border bg-white p-4'>
21+
<div className='h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-500' />
22+
</div>
23+
);
24+
}
25+
26+
if (supportResult !== null) {
27+
if (openPreview) {
28+
return (
29+
<div className='flex-1 overflow-auto p-4'>
30+
<Markdown className='w-[calc(100%-3rem] prose prose-stone h-full break-words pr-[3rem]'>
31+
{supportResult}
32+
</Markdown>
33+
</div>
34+
);
35+
}
36+
return (
37+
<textarea
38+
className='flex-1 resize-none overflow-auto p-4 pr-[4rem] focus:outline-none'
39+
value={supportResult}
40+
readOnly={true}
41+
/>
42+
);
43+
}
44+
45+
if (openPreview) {
46+
return (
47+
<div className='h-full flex-1 overflow-auto p-4'>
48+
<Markdown className='w-[calc(100%-3rem] prose prose-stone h-full max-h-full break-words pr-[3rem]'>
49+
{questionBody}
50+
</Markdown>
51+
</div>
52+
);
53+
}
54+
55+
return (
56+
<textarea
57+
className='flex-1 resize-none overflow-auto p-4 pr-[4rem] focus:outline-none'
58+
value={questionBody}
59+
onChange={(e) => onQuestionBodyChange(e.target.value)}
60+
placeholder='질문을 남겨주세요.'
61+
/>
62+
);
63+
}

0 commit comments

Comments
 (0)