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_LEVEL_OPTIONS,
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;
/** 공통코드 `QUIZ_ANSWER` 상세코드의 코드명 — 이 문자열이 그대로 저장된다. */
answerOptions: readonly string[];
/**
* 정답별 「노출할 문구」 선택지. O와 X의 목록이 달라 그룹이 갈린다
* (`QUIZ_ANSWER_O`·`QUIZ_ANSWER_X`).
*/
messageOptionsByAnswer: Record<string, readonly CommonCode[]>;
item?: QuizItem;
}
/**
* 콘텐츠 등록·수정 (금융OX퀴즈) — 시안 5402:12360.
*
* 정답여부는 선택지(공통코드 `QUIZ_ANSWER`)마다 한 줄이고 각 줄이 「사용자에게 노출할
* 문구」를 갖는다. 문구 선택지는 정답마다 다른 공통코드에서 온다 — O는 `QUIZ_ANSWER_O`,
* X는 `QUIZ_ANSWER_X`다(사용자 확정). 문구는 모두 백엔드로 보낸다 — 정답이 아닌 쪽에도
* 보여 줄 문구가 필요하기 때문이다. 「기타」를 고르면 문구를 직접 적고 그 값이 전송된다.
* 앞의 라디오는 어느 쪽이 정답인지만 정한다.
*/
export function QuizForm({
schools,
gradesBySchool,
answerOptions,
messageOptionsByAnswer,
item,
}: QuizFormProps) {
const isEdit = item !== undefined;
const [state, formAction, isPending] = useActionState(
isEdit ? updateQuizAction : createQuizAction,
INITIAL_CONTENT_FORM_STATE
);
const [answer, setAnswer] = useState(item?.answer || answerOptions[0] || '');
// 선택지 각각의 노출 문구. 정답이 아닌 쪽도 보내므로 선택지 수만큼 따로 들고 있는다.
const [messages, setMessages] = useState<
Record<string, { code: string; text: string }>
>(() =>
Object.fromEntries(
answerOptions.map((option) => [
option,
// 코드가 아직 등록되지 않았을 수 있다 — 그때는 「기타」로 시작한다.
{
code: messageOptionsByAnswer[option]?.[0]?.code ?? ANSWER_MESSAGE_ETC,
text: '',
},
])
)
);
/** 코드 목록 + 「기타」. 기타는 코드가 아니라 직접 입력을 여는 자리라 항상 뒤에 붙인다. */
const messageOptionsOf = (option: string) => [
...(messageOptionsByAnswer[option] ?? []).map((code) => ({
value: code.code,
label: code.label,
})),
{ value: ANSWER_MESSAGE_ETC, label: '기타' },
];
const patchMessage = (
option: string,
patch: Partial<{ code: string; text: string }>
) =>
setMessages((current) => ({
...current,
[option]: { ...current[option], ...patch },
}));
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
const answerError =
errors.answer ??
answerOptions
.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}>
{answerOptions.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>
</>
);
}