Files
tech-log-frontend/src/features/tech-log/presentation/studio/components/relation-editor.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

36 lines
2.8 KiB
TypeScript

import type { components } from "../../../contracts/studio/generated.ts";
import { createNewItemId } from "../../../domain/studio/local-id.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
type RelationInput = components["schemas"]["RelationInput"];
function ordered(relations: RelationInput[]): RelationInput[] {
return relations.map((relation, order) => ({ ...relation, order }));
}
export function RelationEditor({ relations, catalog, evidence = false, onChange }: { relations: RelationInput[]; catalog: CatalogEntry[]; evidence?: boolean; onChange(relations: RelationInput[]): void }) {
const noun = evidence ? "근거" : "관계";
const update = (index: number, patch: Partial<RelationInput>) => onChange(ordered(relations.map((relation, candidateIndex) => candidateIndex === index ? { ...relation, ...patch } : relation)));
const move = (index: number, delta: -1 | 1) => {
const target = index + delta;
if (target < 0 || target >= relations.length) return;
const next = [...relations];
[next[index], next[target]] = [next[target]!, next[index]!];
onChange(ordered(next));
};
return (
<fieldset className="studio-ordered-list">
<legend>{evidence ? "근거 기록" : "관계"}</legend>
{relations.length === 0 ? <p>{evidence ? "연결한 근거 기록이 없습니다." : "연결한 공개 기록이 없습니다."}</p> : null}
{relations.map((relation, index) => (
<div className="studio-relation-item" key={relation.id ?? `relation-${index}`}>
<label><span>{noun} {index + 1} 대상</span><select value={relation.targetId ?? ""} onChange={(event) => update(index, { targetId: event.currentTarget.value || null })}><option value="">대상 선택</option>{catalog.map((entry) => <option key={entry.id} value={entry.id} disabled={relations.some((candidate, candidateIndex) => candidateIndex !== index && candidate.targetId === entry.id)}>{entry.label}</option>)}</select></label>
<label><span>{noun} {index + 1} 이유</span><input value={relation.reason} onChange={(event) => update(index, { reason: event.currentTarget.value })} /></label>
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => move(index, 1)} disabled={index === relations.length - 1}>아래로</button><button type="button" onClick={() => onChange(ordered(relations.filter((_, candidateIndex) => candidateIndex !== index)))}>삭제</button></div>
</div>
))}
<button className="studio-add-item" type="button" disabled={relations.length >= 20} onClick={() => { if (relations.length < 20) onChange(ordered([...relations, { id: createNewItemId(), targetId: null, reason: "", order: relations.length }])); }}>{noun} 추가</button>
</fieldset>
);
}