Files
tech-log-frontend/src/features/tech-log/presentation/studio/components/question-fields.tsx
T
DongHyeonkaandClaude Opus 5 0eb3c86839 fix: 새 목록 항목의 id 가 계약을 건너갈 수 있게 한다
관계를 고르고 저장하면 400 이 돌아왔고, 편집기에는 "saveStudioDocument broke its
contract." 한 줄만 남았다. 저장은 통째로 실패했다.

새 관계·규칙·선택지·문장의 id 를 `createLocalId` 로 만들고 있었다. 그 함수는 접두사를
붙여 `relation-<uuid>` 를 돌려준다 — React key 나 Idempotency-Key 로는 맞지만 계약이
그 자리에 요구하는 것은 uuid 다. 서버는 파싱조차 못 하고 InvalidFormatException 으로
거절했다.

목록에 고를 대상이 하나도 없던 동안에는(catalog RELATION/EVIDENCE 가 스텁이었다) 아무도
이 경로를 지나지 않아 드러나지 않았다. 두 결함이 서로를 가리고 있었다.

Case 는 목록 항목이 없어 무사했다. Reference 의 규칙, Question 의 선택지, Decision 의
consequences 는 모두 같은 이유로 저장되지 않았을 것이다.

`createNewItemId` 로 나눈다. 서버는 자기가 소유하지 않은 id 를 어차피 새로 부여하므로
(`StudioRelationStore.replace`) 이 값은 "이 줄은 새것"이라는 표시일 뿐이다 — 지켜야 할
것은 형식뿐이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 19:03:27 +09:00

71 lines
6.2 KiB
TypeScript

import type { components } from "../../../contracts/studio/generated.ts";
import { createNewItemId } from "../../../domain/studio/local-id.ts";
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
import { OrderedTextList } from "./ordered-text-list.tsx";
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
export const QUESTION_FIELD_PATHS = [
"/questionStatus",
"/facts",
"/assumptions",
"/unknowns",
"/constraints",
"/options",
"/nextValidation",
"/resolution",
] as const;
type CatalogEntry = components["schemas"]["CatalogEntry"];
type QuestionInput = components["schemas"]["QuestionInput"];
type QuestionOption = components["schemas"]["QuestionOption"];
function orderedOptions(options: QuestionOption[]): QuestionOption[] {
return options.map((option, order) => ({ ...option, order }));
}
export function QuestionFields({ draft, evidence, issues, onChange }: { draft: QuestionInput; evidence: CatalogEntry[]; issues: readonly FieldIssue[]; onChange(draft: QuestionInput): void }) {
const update = (patch: Partial<QuestionInput>) => onChange({ ...draft, ...patch });
const moveOption = (index: number, delta: -1 | 1) => {
const target = index + delta;
if (target < 0 || target >= draft.options.length) return;
const next = [...draft.options];
[next[index], next[target]] = [next[target]!, next[index]!];
update({ options: orderedOptions(next) });
};
return (
<section className="studio-editor-section" aria-labelledby="studio-question-fields-title">
<div className="studio-editor-section-heading"><p className="studio-eyebrow">QUESTION</p><h2 id="studio-question-fields-title">판단과 다음 검증</h2></div>
<label className="studio-field"><span>질문 상태</span><select value={draft.questionStatus ?? ""} onChange={(event) => {
const value = event.currentTarget.value;
if (value === "RESOLVED") update({ questionStatus: "RESOLVED", resolution: draft.resolution ?? { summary: "", evidenceTargetId: null, linkLabel: "" } });
else update({ questionStatus: value === "OPEN" ? "OPEN" : null, resolution: null });
}}><option value="">아직 정하지 않음</option><option value="OPEN">OPEN</option><option value="RESOLVED">RESOLVED</option></select><FieldNotice issues={issues} path="/questionStatus" /></label>
<OrderedTextList label="사실" fieldId="studio-field-facts" items={draft.facts} onChange={(facts) => update({ facts })} />
<FieldNotice issues={issues} path="/facts" />
<OrderedTextList label="가정" fieldId="studio-field-assumptions" items={draft.assumptions} onChange={(assumptions) => update({ assumptions })} />
<FieldNotice issues={issues} path="/assumptions" />
<OrderedTextList label="미지수" fieldId="studio-field-unknowns" items={draft.unknowns} onChange={(unknowns) => update({ unknowns })} />
<FieldNotice issues={issues} path="/unknowns" />
<OrderedTextList label="제약" fieldId="studio-field-constraints" items={draft.constraints} onChange={(constraints) => update({ constraints })} />
<FieldNotice issues={issues} path="/constraints" />
<fieldset id="studio-field-options" className="studio-ordered-list" tabIndex={-1}><legend>선택지</legend>
<FieldNotice issues={issues} path="/options" />
{draft.options.length === 0 ? <p>아직 입력한 선택지가 없습니다.</p> : null}
{draft.options.map((option, index) => <div className="studio-ordered-item" key={option.id}>
<label><span>선택지 {index + 1} 제목</span><input value={option.title} onChange={(event) => update({ options: orderedOptions(draft.options.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, title: event.currentTarget.value } : candidate)) })} /></label>
<label><span>선택지 {index + 1} 설명</span><textarea value={option.description} onChange={(event) => update({ options: orderedOptions(draft.options.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, description: event.currentTarget.value } : candidate)) })} /></label>
<div className="studio-item-actions"><button type="button" onClick={() => moveOption(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => moveOption(index, 1)} disabled={index === draft.options.length - 1}>아래로</button><button type="button" onClick={() => update({ options: orderedOptions(draft.options.filter((_, candidateIndex) => candidateIndex !== index)) })}>삭제</button></div>
</div>)}
<button className="studio-add-item" type="button" disabled={draft.options.length >= 50} onClick={() => { if (draft.options.length < 50) update({ options: orderedOptions([...draft.options, { id: createNewItemId(), title: "", description: "", order: draft.options.length }]) }); }}>선택지 추가</button>
</fieldset>
<label className="studio-field studio-field--wide"><span>다음 검증</span><textarea value={draft.nextValidation} onChange={(event) => update({ nextValidation: event.currentTarget.value })} /><FieldNotice issues={issues} path="/nextValidation" /></label>
{draft.questionStatus === "RESOLVED" && draft.resolution ? <fieldset className="studio-resolution-fields"><legend>해결 내용</legend>
<FieldNotice issues={issues} path="/resolution" />
<label className="studio-field studio-field--wide"><span>해결 요약</span><textarea value={draft.resolution.summary} onChange={(event) => update({ resolution: { ...draft.resolution!, summary: event.currentTarget.value } })} /></label>
<label className="studio-field"><span>해결 근거</span><select value={draft.resolution.evidenceTargetId ?? ""} onChange={(event) => update({ resolution: { ...draft.resolution!, evidenceTargetId: event.currentTarget.value || null } })}><option value="">근거 선택</option>{evidence.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
<label className="studio-field"><span>근거 링크 문구</span><input value={draft.resolution.linkLabel} onChange={(event) => update({ resolution: { ...draft.resolution!, linkLabel: event.currentTarget.value } })} /></label>
</fieldset> : null}
</section>
);
}