Files
tech-log-frontend/src/features/tech-log/presentation/studio/components/new-document-form.tsx
T

183 lines
5.1 KiB
TypeScript

import { useEffect, useRef, useState, type FormEvent } from "react";
import type {
CreateDocumentInput,
RecordKind,
} from "../../../contracts/studio/contract.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { useStudio } from "../use-studio.ts";
const types = [
{
kind: "CASE",
title: "Case",
description: "문제를 재현하고 검증한 결론을 기록합니다.",
fields: "문제 · 결론 · 환경 · 재현 · 본문",
},
{
kind: "REFERENCE",
title: "Reference",
description: "반복해서 적용할 기술 기준을 정리합니다.",
fields: "목적 · 규칙 · 적용 조건 · 예외 · 예시",
},
{
kind: "QUESTION",
title: "Question",
description: "아직 닫히지 않은 판단과 다음 검증을 관리합니다.",
fields: "상태 · 사실 · 가정 · 미지수 · 선택지",
},
{
kind: "PROJECT_DECISION",
title: "Decision",
description: "프로젝트가 선택한 방향과 그 근거·영향을 기록합니다.",
fields: "상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거",
},
] as const;
function emptyDocument(kind: RecordKind): CreateDocumentInput {
const common = {
title: "",
slug: "" as const,
summary: "",
topicId: null,
projectId: null,
relations: [],
};
if (kind === "CASE") {
return {
...common,
kind,
problem: "",
conclusion: "",
environment: "",
reproduction: "",
lastVerifiedOn: null,
bodyMarkdown: "",
};
}
if (kind === "REFERENCE") {
return {
...common,
kind,
purpose: "",
rules: [],
applyWhen: [],
exceptions: [],
examples: [],
verifiedOn: null,
};
}
if (kind === "QUESTION") {
return {
...common,
kind,
questionStatus: null,
facts: [],
assumptions: [],
unknowns: [],
constraints: [],
options: [],
nextValidation: "",
resolution: null,
};
}
return {
...common,
kind,
decisionStatus: null,
decidedOn: null,
statement: "",
rationale: "",
consequences: [],
};
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
export function NewDocumentForm() {
const { gateway, navigateInternal, setRequestAnnouncement } = useStudio();
const [kind, setKind] = useState<RecordKind>("CASE");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const locked = useRef(false);
const activeRequest = useRef<AbortController | null>(null);
useEffect(() => () => activeRequest.current?.abort(), []);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (locked.current) return;
locked.current = true;
setPending(true);
setError("");
const controller = new AbortController();
activeRequest.current = controller;
try {
const document = await gateway.createDocument(emptyDocument(kind), {
idempotencyKey: createLocalId("studio-create"),
signal: controller.signal,
});
setRequestAnnouncement(
`${types.find((type) => type.kind === kind)?.title} 작업본을 만들었습니다.`,
);
navigateInternal(`/studio/documents/${document.id}/edit`);
} catch (reason) {
if (!isAbortError(reason)) {
setError("작업본을 만들지 못했습니다. 다시 시도해 주세요.");
}
} finally {
if (activeRequest.current === controller) activeRequest.current = null;
locked.current = false;
setPending(false);
}
};
return (
<div className="studio-page studio-new-page">
<header className="studio-page-heading">
<p className="studio-eyebrow">NEW WORKING COPY</p>
<h1> 문서</h1>
<p>
목적에 맞는 기록 종류를 선택하면 작업본을 만들고 바로 편집을 시작합니다.
</p>
</header>
<form
onSubmit={(event) => {
void submit(event);
}}
>
<fieldset className="studio-type-list">
<legend>문서 종류</legend>
{types.map((type) => (
<label key={type.kind}>
<input
type="radio"
name="kind"
value={type.kind}
checked={kind === type.kind}
onChange={() => setKind(type.kind)}
/>
<strong>{type.title}</strong>
<span>{type.description}</span>
<small>{type.fields}</small>
</label>
))}
</fieldset>
<div className="studio-create-footer">
<button className="studio-primary-button" type="submit" disabled={pending}>
{pending ? "만드는 중…" : "작업본 만들기"}
</button>
<p> 화면의 작업본은 현재 Studio 세션에서만 유지됩니다.</p>
</div>
{error ? (
<p className="studio-screen-error" role="alert">
{error}
</p>
) : null}
</form>
</div>
);
}