File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'use client';
import { useState, useActionState } from 'react';
import { FoxHelperText } from '@fox/core/components/fox-helper-text';
import { FoxInput } from '@fox/core/components/fox-input';
import { FoxPageHeader } from '@fox/core/components/fox-page-header';
import { FoxRadio } from '@fox/core/components/fox-radio';
import { FoxRadioGroup } from '@fox/core/components/fox-radio-group';
import { FoxSelect } from '@fox/core/components/fox-select';
import { FoxTextArea } from '@fox/core/components/fox-text-area';
import { submitFormAction } from '@/app/_hooks/submit-form-action';
import {
ANSWER_MESSAGE_ETC,
QUIZ_ANSWER_OPTIONS,
QUIZ_LEVEL_OPTIONS,
type QuizAnswer,
type QuizItem,
type GradesBySchool,
} from '@/lib/domain/content';
import {
ANSWER_MESSAGE_MAX_LENGTH,
INITIAL_CONTENT_FORM_STATE,
MESSAGE_MAX_LENGTH,
QUESTION_MAX_LENGTH,
} from '@/lib/domain/content-form';
import { contentBreadcrumb, contentListPath } from '@/lib/domain/content-query';
import type { CommonCode } from '@/lib/domain/common-code';
import { createQuizAction, updateQuizAction } from '../../_actions';
import { ContentFormActions } from '../../_components/content-form-actions';
import { ContentFormRow } from '../../_components/content-form-row';
import { ContentGradeField } from '../../_components/content-grade-field';
import { ContentKeywordField } from '../../_components/content-keyword-field';
import { ContentVisibilityField } from '../../_components/content-visibility-field';
import styles from '../../_components/content-form.module.scss';
interface QuizFormProps {
schools: readonly CommonCode[];
gradesBySchool: GradesBySchool;
/**
* O·X 각각의 「노출할 문구」 선택지. 두 목록이 서로 달라 그룹이 갈린다
* (`QUIZ_ANSWER_O`·`QUIZ_ANSWER_X`).
*/
messageOptionsByAnswer: Record<QuizAnswer, readonly CommonCode[]>;
item?: QuizItem;
}
/**
* 콘텐츠 등록·수정 (금융OX퀴즈) — 시안 5402:12360.
*
* 정답여부는 O·X 두 줄이고 각 줄이 「사용자에게 노출할 문구」를 갖는다. 문구 선택지는
* O·X가 서로 다른 공통코드 그룹에서 온다 — `QUIZ_ANSWER_O` · `QUIZ_ANSWER_X`(사용자 확정).
* 문구는 둘 다 백엔드로 보낸다 — 정답이 아닌 쪽에도 보여 줄 문구가 필요하기 때문이다.
* 「기타」를 고르면 문구를 직접 적고 그 값이 전송된다. 앞의 라디오는 어느 쪽이 정답인지만
* 정한다.
*/
export function QuizForm({
schools,
gradesBySchool,
messageOptionsByAnswer,
item,
}: QuizFormProps) {
const isEdit = item !== undefined;
const [state, formAction, isPending] = useActionState(
isEdit ? updateQuizAction : createQuizAction,
INITIAL_CONTENT_FORM_STATE
);
const [answer, setAnswer] = useState<string>(
item?.answer || QUIZ_ANSWER_OPTIONS[0]
);
/**
* 저장된 문구를 선택 상자로 되살린다. 백엔드는 코드가 아니라 문구만 갖고 있어, 코드명과
* 맞는 것이 있으면 그 코드로, 없으면 「기타」 + 직접 입력으로 돌린다.
*/
const initialMessageOf = (option: QuizAnswer) => {
const saved =
item?.answerMessages.find((message) => message.answer === option)
?.message ?? '';
const matched = messageOptionsByAnswer[option].find(
(code) => code.label === saved
);
if (matched) {
return { code: matched.code, text: '' };
}
if (saved) {
return { code: ANSWER_MESSAGE_ETC, text: saved };
}
// 코드가 아직 등록되지 않았을 수 있다 — 그때는 「기타」로 시작한다.
return {
code: messageOptionsByAnswer[option][0]?.code ?? ANSWER_MESSAGE_ETC,
text: '',
};
};
// O·X 각각의 노출 문구. 정답이 아닌 쪽도 보내므로 둘 다 따로 들고 있는다.
const [messages, setMessages] = useState<
Record<QuizAnswer, { code: string; text: string }>
>(() => ({ O: initialMessageOf('O'), X: initialMessageOf('X') }));
/** 코드 목록 + 「기타」. 기타는 코드가 아니라 직접 입력을 여는 자리라 항상 뒤에 붙인다. */
const messageOptionsOf = (option: QuizAnswer) => [
...messageOptionsByAnswer[option].map((code) => ({
value: code.code,
label: code.label,
})),
{ value: ANSWER_MESSAGE_ETC, label: '기타' },
];
const patchMessage = (
option: QuizAnswer,
patch: Partial<{ code: string; text: string }>
) =>
setMessages((current) => ({
...current,
[option]: { ...current[option], ...patch },
}));
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
const answerError =
errors.answer ??
QUIZ_ANSWER_OPTIONS.flatMap((option) => [
errors[`answerMessage${option}Code`],
errors[`answerMessage${option}Text`],
]).find(Boolean);
return (
<>
<FoxPageHeader
title={`콘텐츠 ${isEdit ? '수정' : '등록'} (금융OX퀴즈)`}
breadcrumb={contentBreadcrumb('quiz', isEdit ? '수정' : '등록')}
/>
<form
onSubmit={(event) => submitFormAction(event, formAction)}
className={styles.form}
>
{isEdit && <input type="hidden" name="quizId" value={item.quizId} />}
{/* 화면에 입력 칸이 없는 값이라 그대로 돌려보내 지워지지 않게 한다(도메인 주석 참조). */}
{isEdit && (
<ContentFormRow label="콘텐츠ID">
<FoxInput size="md" value={item.quizId} readOnly />
</ContentFormRow>
)}
<ContentFormRow label="학년선택" required>
<ContentGradeField
schools={schools}
gradesBySchool={gradesBySchool}
defaultSchoolCode={item?.schoolCode}
defaultGrades={item?.grades}
schoolError={errors.schoolCode}
gradesError={errors.grades}
/>
</ContentFormRow>
<ContentFormRow label="난이도" required>
<FoxRadioGroup name="level" size="md" label="난이도" orientation="horizontal">
{QUIZ_LEVEL_OPTIONS.map((option) => (
<FoxRadio
key={option.value}
value={option.value}
label={option.label}
defaultChecked={(item?.level || QUIZ_LEVEL_OPTIONS[1].value) === option.value}
/>
))}
</FoxRadioGroup>
{errors.level && <FoxHelperText type="danger" message={errors.level} />}
</ContentFormRow>
<ContentFormRow label="키워드">
<ContentKeywordField
defaultKeywords={
item ? item.keywords : undefined
}
error={errors.keywords}
/>
</ContentFormRow>
<ContentFormRow label="질문" required>
<FoxTextArea
name="question"
aria-label="질문"
defaultValue={item?.question ?? ''}
rows={3}
placeholder="질문을 입력하세요."
maxLength={QUESTION_MAX_LENGTH}
counter
invalid={Boolean(errors.question)}
message={errors.question}
/>
</ContentFormRow>
<ContentFormRow label="정답여부" required>
<div className={styles.answerLines}>
{QUIZ_ANSWER_OPTIONS.map((option) => (
<div key={option} className={styles.inline}>
<FoxRadio
name="answer"
size="md"
value={option}
label={option}
checked={answer === option}
onChange={setAnswer}
/>
<div className={styles.answerMessage}>
<FoxSelect
size="md"
ariaLabel={`${option} 노출 문구`}
options={messageOptionsOf(option)}
value={messages[option].code}
onValueChange={(code) => patchMessage(option, { code })}
/>
</div>
{messages[option].code === ANSWER_MESSAGE_ETC && (
<div className={styles.answerMessage}>
<FoxInput
size="md"
aria-label={`${option}에 노출할 문구`}
value={messages[option].text}
onChange={(text) => patchMessage(option, { text })}
placeholder="내용을 입력하세요."
maxLength={ANSWER_MESSAGE_MAX_LENGTH}
/>
</div>
)}
<input
type="hidden"
name={`answerMessage${option}Code`}
value={messages[option].code}
/>
<input
type="hidden"
name={`answerMessage${option}Text`}
value={messages[option].text}
/>
</div>
))}
</div>
{answerError && <FoxHelperText type="danger" message={answerError} />}
</ContentFormRow>
<ContentFormRow label="해설" required>
<FoxTextArea
name="explanation"
aria-label="해설"
defaultValue={item?.explanation ?? ''}
rows={4}
placeholder="해설(전달 메시지)을 입력하세요."
maxLength={MESSAGE_MAX_LENGTH}
counter
invalid={Boolean(errors.explanation)}
message={errors.explanation}
/>
</ContentFormRow>
<ContentFormRow label="공개 설정" required>
<ContentVisibilityField defaultVisible={item?.isVisible ?? false} />
</ContentFormRow>
{state.status === 'error' && state.message && (
<FoxHelperText type="danger" message={state.message} />
)}
<ContentFormActions
submitLabel={isEdit ? '수정' : '등록'}
listHref={contentListPath('quiz')}
pending={isPending}
/>
</form>
</>
);
}