fix: 오류 수정

This commit is contained in:
DongHyeonka
2026-08-22 14:40:49 +09:00
parent 1801414592
commit c03b0c77b8
23 changed files with 644 additions and 112 deletions
@@ -5,9 +5,20 @@ import type { Asset, AssetKind } from "../../../contracts/studio/contract.ts";
import { useStudioAssetGateway } from "../use-studio.ts";
import { AssetPicker, buildEvidenceDirective } from "./asset-picker.tsx";
import { AssetUploadDialog } from "./asset-upload-dialog.tsx";
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
type CaseInput = components["schemas"]["CaseInput"];
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
export const CASE_FIELD_PATHS = [
"/problem",
"/conclusion",
"/environment",
"/reproduction",
"/lastVerifiedOn",
"/bodyMarkdown",
] as const;
const ASSET_KIND_OPTIONS: ReadonlyArray<{ value: AssetKind; label: string }> = [
{ value: "IMAGE", label: "이미지" },
{ value: "DIAGRAM", label: "다이어그램" },
@@ -36,11 +47,14 @@ function insertAtCursor(
export function CaseFields({
draft,
issues,
onChange,
onAssetsObserved,
onAssetUploaded,
}: {
draft: CaseInput;
/** 마지막 게시 시도가 남긴 지적. 각 칸 아래에는 그 칸의 것만 붙는다. */
issues: readonly FieldIssue[];
onChange(draft: CaseInput): void;
/**
* The editor screen owns the resolution catalog Instant Preview reads, and
@@ -81,12 +95,12 @@ export function CaseFields({
<section className="studio-editor-section" aria-labelledby="studio-case-fields-title">
<div className="studio-editor-section-heading"><p className="studio-eyebrow">CASE</p><h2 id="studio-case-fields-title"> </h2></div>
<div className="studio-field-grid">
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.problem} onChange={(event) => update({ problem: event.currentTarget.value })} /></label>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.conclusion} onChange={(event) => update({ conclusion: event.currentTarget.value })} /></label>
<label className="studio-field"><span> </span><textarea value={draft.environment} onChange={(event) => update({ environment: event.currentTarget.value })} /></label>
<label className="studio-field"><span> </span><textarea value={draft.reproduction} onChange={(event) => update({ reproduction: event.currentTarget.value })} /></label>
<label className="studio-field"><span> </span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /></label>
<label className="studio-field studio-field--wide"><span> Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /></label>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.problem} onChange={(event) => update({ problem: event.currentTarget.value })} /><FieldNotice issues={issues} path="/problem" /></label>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.conclusion} onChange={(event) => update({ conclusion: event.currentTarget.value })} /><FieldNotice issues={issues} path="/conclusion" /></label>
<label className="studio-field"><span> </span><textarea value={draft.environment} onChange={(event) => update({ environment: event.currentTarget.value })} /><FieldNotice issues={issues} path="/environment" /></label>
<label className="studio-field"><span> </span><textarea value={draft.reproduction} onChange={(event) => update({ reproduction: event.currentTarget.value })} /><FieldNotice issues={issues} path="/reproduction" /></label>
<label className="studio-field"><span> </span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/lastVerifiedOn" /></label>
<label className="studio-field studio-field--wide"><span> Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /><FieldNotice issues={issues} path="/bodyMarkdown" /></label>
</div>
<div className="studio-asset-panel" aria-labelledby="studio-asset-panel-title">
<p className="studio-eyebrow">EVIDENCE</p>
@@ -1,33 +1,48 @@
import type { components } from "../../../contracts/studio/generated.ts";
import type { WorkingCopyInput } from "../../../contracts/studio/contract.ts";
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
import { RelationEditor } from "./relation-editor.tsx";
type CatalogEntry = components["schemas"]["CatalogEntry"];
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. 나머지는 게시 버튼 옆에 남는다. */
export const COMMON_FIELD_PATHS = [
"/title",
"/slug",
"/summary",
"/topicId",
"/projectId",
"/relations",
] as const;
export function CommonDocumentFields({
draft,
topics,
projects,
relations,
issues,
onUpdate,
}: {
draft: WorkingCopyInput;
topics: CatalogEntry[];
projects: CatalogEntry[];
relations: CatalogEntry[];
/** 마지막 게시 시도가 남긴 지적. 각 칸 아래에는 그 칸의 것만 붙는다. */
issues: readonly FieldIssue[];
onUpdate(patch: Partial<WorkingCopyInput>): void;
}) {
return (
<section className="studio-editor-section" aria-labelledby="studio-common-fields-title">
<div className="studio-editor-section-heading"><p className="studio-eyebrow">DOCUMENT</p><h2 id="studio-common-fields-title"> </h2></div>
<div className="studio-field-grid">
<label className="studio-field studio-field--wide"><span></span><input value={draft.title} maxLength={120} onChange={(event) => onUpdate({ title: event.currentTarget.value })} /></label>
<label className="studio-field"><span>slug</span><input value={draft.slug} maxLength={100} placeholder="비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)" onChange={(event) => onUpdate({ slug: event.currentTarget.value as WorkingCopyInput["slug"] })} /></label>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.summary} maxLength={300} onChange={(event) => onUpdate({ summary: event.currentTarget.value })} /></label>
<label className="studio-field"><span>Topic</span><select value={draft.topicId ?? ""} onChange={(event) => onUpdate({ topicId: event.currentTarget.value || null })}><option value=""> </option>{topics.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
<label className="studio-field"><span>Project</span><select value={draft.projectId ?? ""} onChange={(event) => onUpdate({ projectId: event.currentTarget.value || null })}><option value=""></option>{projects.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
<label className="studio-field studio-field--wide"><span></span><input value={draft.title} maxLength={120} onChange={(event) => onUpdate({ title: event.currentTarget.value })} /><FieldNotice issues={issues} path="/title" /></label>
<label className="studio-field"><span>slug</span><input value={draft.slug} maxLength={100} placeholder="비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)" onChange={(event) => onUpdate({ slug: event.currentTarget.value as WorkingCopyInput["slug"] })} /><FieldNotice issues={issues} path="/slug" /></label>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.summary} maxLength={300} onChange={(event) => onUpdate({ summary: event.currentTarget.value })} /><FieldNotice issues={issues} path="/summary" /></label>
<label className="studio-field"><span>Topic</span><select value={draft.topicId ?? ""} onChange={(event) => onUpdate({ topicId: event.currentTarget.value || null })}><option value=""> </option>{topics.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select><FieldNotice issues={issues} path="/topicId" /></label>
<label className="studio-field"><span>Project</span><select value={draft.projectId ?? ""} onChange={(event) => onUpdate({ projectId: event.currentTarget.value || null })}><option value=""></option>{projects.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select><FieldNotice issues={issues} path="/projectId" /></label>
</div>
<RelationEditor evidence={draft.kind === "PROJECT_DECISION"} relations={draft.relations} catalog={relations} onChange={(next) => onUpdate({ relations: next })} />
<FieldNotice issues={issues} path="/relations" />
</section>
);
}
@@ -23,7 +23,26 @@ export type DocumentEditorController = {
version: number;
issues: readonly { severity: "ERROR" | "WARNING"; code: string; message: string; path: string }[];
}> | null;
/**
* 게시가 진행 중인가. 한 번의 클릭이 저장·검증·미리보기·게시 네 번의 왕복을 만들므로, 그
* 사이에 다시 누르면 같은 문서를 두 번 게시하려 든다.
*/
publishing: boolean;
/** 게시가 실패한 이유. 서버가 준 문구를 그대로 쓴다. */
publishError: string;
/**
* 게시를 막은 검증 항목. 예전에는 이것을 보려면 검증 화면으로 나가야 했다 — 고칠 칸은 편집
* 화면에 있는데 무엇이 모자란지는 다른 화면에 있었다. 지금은 막힌 자리에서 바로 보여 준다.
*/
publishIssues: readonly { severity: "ERROR" | "WARNING"; code: string; message: string; path: string }[];
update(patch: Partial<WorkingCopyInput>): void;
replace(draft: WorkingCopyInput): void;
save(): Promise<void>;
/**
* 저장 → 검증 → 미리보기 → 게시를 한 번에 수행한다. 백엔드는 게시 요청에 신선한 검증 id 와
* 미리보기 id, 그리고 현재 경고를 모두 확인했다는 목록을 요구한다 —
* `PublishStudioDocumentUseCase` 가 셋을 모두 검사한다. 그 셋은 작성자에게 물어볼 것이 없으므로
* 여기서 채운다. 작성자가 밟는 단계는 저장과 게시 둘뿐이다.
*/
publish(): Promise<void>;
};
@@ -81,6 +81,9 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
* 돌아와서 어느 칸이었는지 기억해야 했다.
*/
const [validationSource, setValidationSource] = useState<WorkingCopyDetail | null>(null);
const [publishing, setPublishing] = useState(false);
const [publishError, setPublishError] = useState("");
const [publishIssues, setPublishIssues] = useState<readonly ValidationIssue[]>([]);
const observeAssets = useCallback((observed: readonly Asset[]) => {
setAssets((current) => mergeAssetCatalog(current, observed));
}, []);
@@ -173,6 +176,112 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
}
}, [begin, editor, setStatus, studio]);
/**
* 저장 → 검증 → 미리보기 → 게시를 한 번에 수행한다.
*
* <p>백엔드는 게시 요청에 신선한 검증 id 와 미리보기 id, 그리고 현재 경고를 모두 확인했다는
* 목록을 요구한다(`PublishStudioDocumentUseCase`). 예전에는 그 셋을 작성자가 세 화면을 차례로
* 밟아 만들었다 — 검증 화면에서 버튼을 누르고, 미리보기 화면에서 또 누르고, 게시 화면에서
* 경고를 하나씩 체크했다.
*
* <p>그 셋은 작성자에게 물어볼 것이 없다. 검증 결과는 서버가 판정하고, 미리보기는 그 판정에서
* 만들어지며, 경고는 게시를 막지 않는다. 그래서 여기서 잇달아 부른다. 서버가 지키던 불변식은
* 그대로다 — 사라진 것은 작성자가 밟던 화면뿐이다.
*
* <p>막는 것은 `ERROR` 뿐이다. 그때는 게시를 멈추고 지적을 그 칸 아래에 보여 준다.
*/
const publish = useCallback(async () => {
const current = editor;
if (!current || current.status === "SAVING" || current.status === "CONFLICT") return;
setPublishError("");
setPublishIssues([]);
setPublishing(true);
const id = current.documentId;
try {
// 게시는 저장된 버전을 대상으로 한다. 편집 중인 값이 있으면 먼저 맞춘다 — 그러지 않으면
// 방금 고친 칸이 반영되지 않은 채 검증받는다.
let version = current.saved.version;
if (current.status === "DIRTY") {
const slug = current.draft.slug.trim() || slugFromName(current.draft.title);
if (slug && !SLUG_SHAPE.test(slug)) {
setPublishError("slug 은 영문 소문자·숫자·하이픈만 쓸 수 있습니다. 비워 두면 제목에서 만들어 드립니다.");
return;
}
const draft = slug === current.draft.slug
? current.draft
: ({ ...current.draft, slug } as typeof current.draft);
setStatus("SAVING");
const saved = await studio.gateway.saveDocument(
id,
{ expectedVersion: version, document: draft },
{ idempotencyKey: createLocalId("studio-publish-save") },
);
begin(saved.document, inputOf(saved.document));
setValidationSource(saved);
version = saved.document.version;
}
const report = await studio.gateway.validateDocument(
id,
{ expectedVersion: version },
{ idempotencyKey: createLocalId("studio-publish-validate") },
);
if (report.status === "INVALID") {
setPublishIssues(report.issues);
setPublishError("게시할 수 없습니다. 표시한 칸을 채워 주세요.");
studio.setRequestAnnouncement("게시할 수 없습니다. 표시한 칸을 채워 주세요.");
return;
}
const preview = await studio.gateway.createPreview(
id,
{ expectedVersion: version, validationId: report.validationId },
{ idempotencyKey: createLocalId("studio-publish-preview") },
);
// 경고는 게시를 막지 않는다. 서버는 "현재 경고를 모두 확인했다"는 목록을 요구할 뿐이므로
// 방금 받은 경고를 그대로 넘긴다.
const acknowledged = [
...new Set(
report.issues
.filter((issue) => issue.severity === "WARNING")
.map((issue) => issue.code),
),
].sort();
const result = await studio.gateway.publishDocument(
id,
{
expectedVersion: version,
validationId: report.validationId,
previewId: preview.previewId,
acknowledgedWarningCodes: acknowledged,
},
{ idempotencyKey: createLocalId("studio-publish") },
);
// 경고를 남긴 채 게시했다면 그 사실은 남겨 둔다 — 게시가 되었다고 해서 지적이 사라진 것은
// 아니다.
setPublishIssues(report.issues.filter((issue) => issue.severity === "WARNING"));
studio.setRequestAnnouncement("게시했습니다.");
studio.clearEditor();
studio.navigateInternal(
`/studio/publications/${result.event.publicationEventId}/preview`,
);
} catch (error) {
if (isStudioGatewayError(error) && error.code === "VERSION_CONFLICT") {
setStatus("CONFLICT");
} else if (current.status === "DIRTY") {
setStatus("DIRTY");
}
const detail = isStudioGatewayError(error)
? error.problem.detail
: "게시하지 못했습니다. 다시 시도해 주세요.";
setPublishError(detail);
studio.setRequestAnnouncement(detail);
} finally {
setPublishing(false);
}
}, [begin, editor, setStatus, studio]);
/**
* 검증의 신선도는 시각에 달렸는데, 시계는 렌더마다 새 함수다. 그것을 효과 의존성에 넣었더니
* 문서를 끝없이 다시 불러왔다 — 화면이 정착하지 못해 탭 전환조차 먹히지 않았다. 시각은
@@ -188,15 +297,19 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
status: editor.status,
saveError,
validation,
publishing,
publishError,
publishIssues,
update(patch) {
updateDraft({ ...editor.draft, ...patch, kind: editor.draft.kind } as WorkingCopyInput);
},
replace(draft) {
if (draft.kind === editor.draft.kind) updateDraft(draft);
},
publish,
save,
};
}, [documentId, editor, save, saveError, updateDraft, validation]);
}, [documentId, editor, publish, publishError, publishIssues, publishing, save, saveError, updateDraft, validation]);
const currentResult = result?.key === requestKey ? result : null;
const problem = currentResult?.problem ?? null;
@@ -3,13 +3,14 @@ import { useRef, useState, type KeyboardEvent } from "react";
import type { components } from "../../../contracts/studio/generated.ts";
import type { Asset } from "../../../contracts/studio/contract.ts";
import type { DocumentEditorController } from "./document-editor-controller.ts";
import { CaseFields } from "./case-fields.tsx";
import { CommonDocumentFields } from "./common-document-fields.tsx";
import { CASE_FIELD_PATHS, CaseFields } from "./case-fields.tsx";
import { COMMON_FIELD_PATHS, CommonDocumentFields } from "./common-document-fields.tsx";
import { DocumentStatusRail } from "./document-status-rail.tsx";
import { InstantPreview } from "./instant-preview.tsx";
import { ProjectDecisionFields } from "./project-decision-fields.tsx";
import { QuestionFields } from "./question-fields.tsx";
import { ReferenceFields } from "./reference-fields.tsx";
import { DECISION_FIELD_PATHS, ProjectDecisionFields } from "./project-decision-fields.tsx";
import { issuesOutside } from "./field-issues.tsx";
import { QUESTION_FIELD_PATHS, QuestionFields } from "./question-fields.tsx";
import { REFERENCE_FIELD_PATHS, ReferenceFields } from "./reference-fields.tsx";
type CatalogEntry = components["schemas"]["CatalogEntry"];
@@ -48,6 +49,23 @@ export function DocumentEditor({
const relations = catalog.filter(({ type }) => type === "RELATION");
const evidence = catalog.filter(({ type }) => type === "EVIDENCE");
/*
게시를 막는 것이 무엇인지 그 칸 아래에 적는다. 예전에는 이 목록이 화면 맨 위에 한 덩어리로
있었고, 작성자는 `/topicId` 같은 경로를 읽고 어느 칸인지 스스로 찾아야 했다.
어느 칸에도 붙지 못한 것은 게시 버튼 옆에 남긴다 — 사라지면 이유를 말해 주지 않는 실패만
남는다.
*/
const issues = controller.publishIssues;
const kindPaths = controller.draft.kind === "CASE"
? CASE_FIELD_PATHS
: controller.draft.kind === "REFERENCE"
? REFERENCE_FIELD_PATHS
: controller.draft.kind === "QUESTION"
? QUESTION_FIELD_PATHS
: DECISION_FIELD_PATHS;
const unplaced = issuesOutside(issues, [...COMMON_FIELD_PATHS, ...kindPaths]);
return (
<div className="studio-editor-page">
<div className="studio-editor-tabs" role="tablist" aria-label="문서 편집 화면">
@@ -62,49 +80,20 @@ export function DocumentEditor({
<h1> </h1>
<p>{controller.draft.title || "제목 없는 작업본"}</p>
</header>
{/*
게시를 막는 것이 무엇인지 고치는 자리에서 보여 준다. 예전에는 별도 검증 화면으로
나가야만 알 수 있었고, 돌아와서는 어느 칸이었는지 기억해야 했다.
검증은 저장된 버전을 기준으로 도므로 이 목록도 그 버전의 것이다 — 저장한 뒤 다시
검증하기 전까지는 방금 고친 것이 아직 반영되지 않는다. 그 사실을 숨기지 않는다.
*/}
{controller.validation && controller.validation.issues.length ? (
<section
className={`studio-editor-validation${controller.validation.current ? "" : " studio-editor-validation--stale"}`}
role={controller.validation.current ? "alert" : "status"}
aria-label="검증에서 지적된 항목"
>
<p className="studio-editor-validation-title">
{controller.validation.current
? "게시하려면 아래를 채워야 합니다"
: `버전 ${controller.validation.version} 검사에서 지적된 항목입니다 — 저장 후 다시 검증하면 갱신됩니다`}
</p>
<ul>
{controller.validation.issues.map((issue) => (
<li key={`${issue.code}-${issue.path}`} data-severity={issue.severity}>
<span>{issue.severity === "ERROR" ? "필수" : "확인"}</span>
{issue.message}
<code>{issue.path}</code>
</li>
))}
</ul>
</section>
) : null}
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} onUpdate={controller.update} />
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} issues={issues} onUpdate={controller.update} />
{controller.draft.kind === "CASE"
? <CaseFields draft={controller.draft} onChange={controller.replace} onAssetsObserved={onAssetsObserved} onAssetUploaded={onAssetUploaded} />
? <CaseFields draft={controller.draft} issues={issues} onChange={controller.replace} onAssetsObserved={onAssetsObserved} onAssetUploaded={onAssetUploaded} />
: controller.draft.kind === "REFERENCE"
? <ReferenceFields draft={controller.draft} onChange={controller.replace} />
? <ReferenceFields draft={controller.draft} issues={issues} onChange={controller.replace} />
: controller.draft.kind === "QUESTION"
? <QuestionFields draft={controller.draft} evidence={evidence} onChange={controller.replace} />
: <ProjectDecisionFields draft={controller.draft} onChange={controller.replace} />}
? <QuestionFields draft={controller.draft} evidence={evidence} issues={issues} onChange={controller.replace} />
: <ProjectDecisionFields draft={controller.draft} issues={issues} onChange={controller.replace} />}
</div>
<div id="studio-preview-panel" role="tabpanel" aria-labelledby="studio-preview-tab" hidden={tab !== "PREVIEW"}>
<InstantPreview draft={controller.draft} catalog={catalog} assets={assets} />
</div>
</div>
<DocumentStatusRail controller={controller} />
<DocumentStatusRail controller={controller} unplacedIssues={unplaced} />
</div>
</div>
);
@@ -1,5 +1,5 @@
import type { DocumentEditorController } from "./document-editor-controller.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import type { FieldIssue } from "./field-issues.tsx";
const labels = {
CLEAN: "저장됨",
@@ -15,36 +15,42 @@ const kindLabels = {
PROJECT_DECISION: "Decision",
} as const;
export function DocumentStatusRail({ controller }: { controller: DocumentEditorController }) {
export function DocumentStatusRail({
controller,
unplacedIssues,
}: {
controller: DocumentEditorController;
/**
* 어느 칸에도 붙지 못한 지적. 화면에 없는 칸을 가리키는 것들이며, 여기 남기지 않으면 이유를
* 말해 주지 않는 실패만 남는다.
*/
unplacedIssues: readonly FieldIssue[];
}) {
const busy = controller.status === "SAVING" || controller.publishing;
return (
<aside className="studio-document-status-rail" aria-labelledby="studio-document-status-title">
<p className="studio-eyebrow">WORKING COPY</p>
<h2 id="studio-document-status-title"> </h2>
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
<dl><div><dt> </dt><dd>{controller.saved.version}</dd></div><div><dt></dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
<button type="button" onClick={() => { void controller.save(); }} disabled={controller.status === "CLEAN" || controller.status === "SAVING" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
<button type="button" onClick={() => { void controller.save(); }} disabled={busy || controller.status === "CLEAN" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
{/*
게시까지의 길을 통째로 보여 준다. 예전에는 다음 한 칸("저장본 검증")만 있었고, 작성자는
검증 → 미리보기 → 게시를 하나씩 밟아 본 뒤에야 게시 화면이 있다는 것을 알 수 있었다 —
"게시" 라는 말이 어디에도 먼저 나오지 않으니 게시하는 방법을 알기 어려웠다.
버튼은 저장과 게시 둘뿐이다. 예전에는 게시까지 검증 → 미리보기 → 게시 세 화면을 차례로
밟아야 했다 — 백엔드가 게시 요청에 신선한 검증 id 와 미리보기 id, 그리고 현재 경고를 모두
확인했다는 목록을 요구하기 때문이다(`PublishStudioDocumentUseCase`).
앞 단계를 마치지 않으면 뒤 단계가 거절하는 것은 그대로다. 여기서 바꾼 것은 순서를
숨기지 않는 것뿐이다.
그 셋은 작성자에게 물어볼 것이 없다. 검증은 서버가 판정하고, 미리보기는 그 판정으로부터
만들어지며, 경고는 게시를 막지 않는다. 그래서 세 요청을 이 버튼 뒤로 옮겼다. 계약도
백엔드도 그대로다 — 사라진 것은 작성자가 밟던 화면이지 서버가 지키던 불변식이 아니다.
*/}
<nav className="studio-editor-flow" aria-label="게시까지의 단계">
<ol>
{[
{ label: "검증", href: `/studio/documents/${controller.saved.id}/validation` },
{ label: "미리보기", href: `/studio/documents/${controller.saved.id}/preview` },
{ label: "게시", href: `/studio/documents/${controller.saved.id}/publish` },
].map((step, index) => (
<li key={step.href}>
<span aria-hidden="true">{index + 1}</span>
<GuardedStudioLink href={step.href}>{step.label}</GuardedStudioLink>
</li>
))}
</ol>
</nav>
<button
className="studio-primary-button"
type="button"
onClick={() => { void controller.publish(); }}
disabled={busy || controller.status === "CONFLICT"}
>
{controller.publishing ? "게시 중…" : "게시"}
</button>
{/*
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
@@ -54,9 +60,20 @@ export function DocumentStatusRail({ controller }: { controller: DocumentEditorC
<p className="studio-editor-conflict" role="alert"> . .</p>
) : controller.saveError ? (
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
) : controller.publishError ? (
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
) : (
<p> . .</p>
<p> . .</p>
)}
{unplacedIssues.length ? (
<ul className="studio-editor-unplaced-issues" aria-label="칸에 붙지 못한 지적">
{unplacedIssues.map((issue) => (
<li key={`${issue.code}:${issue.path}`} data-severity={issue.severity}>
{issue.message} <code>{issue.path}</code>
</li>
))}
</ul>
) : null}
</aside>
);
}
@@ -0,0 +1,73 @@
import type { components } from "../../../contracts/studio/generated.ts";
export type FieldIssue = components["schemas"]["ValidationIssue"];
/**
* 검증 항목을 그 항목이 가리키는 칸 옆으로 나눠 주기 위한 것들.
*
* <p>예전에는 게시하기 전에 검증 화면으로 나가서 버튼을 누르고, 지적을 읽고, 편집 화면으로
* 돌아와 어느 칸이었는지 기억해서 고쳐야 했다. 검증 결과는 이미 `path` 로 어느 칸인지 말하고
* 있었으므로 — `/title`, `/topicId`, `/problem` — 그 자리에 그대로 붙이면 화면을 오갈 이유가
* 없어진다.
*/
/**
* `path` 가 가리키는 칸의 지적을 고른다.
*
* <p>정확히 같은 경로뿐 아니라 그 아래 경로도 함께 고른다. 배열 칸은 서버가
* `/relations/0/targetId` 처럼 항목을 짚어 주는데, 화면에는 `relations` 라는 칸 하나만 있기
* 때문이다. 이렇게 하지 않으면 그런 지적은 어느 칸에도 붙지 못하고 사라진다.
*/
export function issuesFor(
issues: readonly FieldIssue[],
path: string,
): readonly FieldIssue[] {
return issues.filter(
(issue) => issue.path === path || issue.path.startsWith(`${path}/`),
);
}
/**
* `paths` 중 어느 것에도 붙지 않는 지적. 게시 버튼 옆에 남겨 두기 위한 것이다 — 화면에 없는
* 칸을 가리키는 지적이 조용히 사라지면, 작성자는 이유를 말해 주지 않는 실패만 보게 된다.
*/
export function issuesOutside(
issues: readonly FieldIssue[],
paths: readonly string[],
): readonly FieldIssue[] {
return issues.filter(
(issue) =>
!paths.some(
(path) => issue.path === path || issue.path.startsWith(`${path}/`),
),
);
}
/**
* 한 칸에 붙는 지적을 그 칸 아래에 적는다. `ERROR` 는 게시를 막고 `WARNING` 은 막지 않으므로
* 둘을 다른 색으로 구분하되, 둘 다 읽히도록 `role` 을 준다.
*/
export function FieldNotice({
issues,
path,
}: {
issues: readonly FieldIssue[];
path: string;
}) {
const matched = issuesFor(issues, path);
if (matched.length === 0) return null;
return (
<>
{matched.map((issue) => (
<span
key={`${issue.code}:${issue.path}`}
className={`studio-field-notice studio-field-notice--${issue.severity.toLowerCase()}`}
data-severity={issue.severity}
role={issue.severity === "ERROR" ? "alert" : "status"}
>
{issue.message}
</span>
))}
</>
);
}
@@ -1,13 +1,26 @@
import type { components } from "../../../contracts/studio/generated.ts";
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
import { OrderedTextList } from "./ordered-text-list.tsx";
type ProjectDecisionInput = components["schemas"]["ProjectDecisionInput"];
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
export const DECISION_FIELD_PATHS = [
"/decisionStatus",
"/decidedOn",
"/statement",
"/rationale",
"/consequences",
] as const;
export function ProjectDecisionFields({
draft,
issues,
onChange,
}: {
draft: ProjectDecisionInput;
/** 마지막 게시 시도가 남긴 지적. 각 칸 아래에는 그 칸의 것만 붙는다. */
issues: readonly FieldIssue[];
onChange(draft: ProjectDecisionInput): void;
}) {
const update = (patch: Partial<ProjectDecisionInput>) => {
@@ -41,6 +54,7 @@ export function ProjectDecisionFields({
<option value="PROPOSED">PROPOSED</option>
<option value="ADOPTED">ADOPTED</option>
</select>
<FieldNotice issues={issues} path="/decisionStatus" />
</label>
<label className="studio-field">
<span></span>
@@ -51,6 +65,7 @@ export function ProjectDecisionFields({
update({ decidedOn: event.currentTarget.value || null });
}}
/>
<FieldNotice issues={issues} path="/decidedOn" />
</label>
<label className="studio-field studio-field--wide">
<span></span>
@@ -58,6 +73,7 @@ export function ProjectDecisionFields({
value={draft.statement}
onChange={(event) => update({ statement: event.currentTarget.value })}
/>
<FieldNotice issues={issues} path="/statement" />
</label>
<label className="studio-field studio-field--wide">
<span> </span>
@@ -65,6 +81,7 @@ export function ProjectDecisionFields({
value={draft.rationale}
onChange={(event) => update({ rationale: event.currentTarget.value })}
/>
<FieldNotice issues={issues} path="/rationale" />
</label>
</div>
<OrderedTextList
@@ -73,6 +90,7 @@ export function ProjectDecisionFields({
items={draft.consequences}
onChange={(consequences) => update({ consequences })}
/>
<FieldNotice issues={issues} path="/consequences" />
</section>
);
}
@@ -1,7 +1,20 @@
import type { components } from "../../../contracts/studio/generated.ts";
import { createLocalId } 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"];
@@ -10,7 +23,7 @@ function orderedOptions(options: QuestionOption[]): QuestionOption[] {
return options.map((option, order) => ({ ...option, order }));
}
export function QuestionFields({ draft, evidence, onChange }: { draft: QuestionInput; evidence: CatalogEntry[]; onChange(draft: QuestionInput): void }) {
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;
@@ -26,12 +39,17 @@ export function QuestionFields({ draft, evidence, onChange }: { draft: QuestionI
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></label>
}}><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>
@@ -40,8 +58,9 @@ export function QuestionFields({ draft, evidence, onChange }: { draft: QuestionI
</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: createLocalId("option"), 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 })} /></label>
<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>
@@ -1,15 +1,26 @@
import type { components } from "../../../contracts/studio/generated.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
import { OrderedTextList } from "./ordered-text-list.tsx";
type ReferenceInput = components["schemas"]["ReferenceInput"];
type ReferenceRule = components["schemas"]["ReferenceRule"];
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
export const REFERENCE_FIELD_PATHS = [
"/purpose",
"/rules",
"/applyWhen",
"/exceptions",
"/examples",
"/verifiedOn",
] as const;
function orderedRules(rules: ReferenceRule[]): ReferenceRule[] {
return rules.map((rule, order) => ({ ...rule, order }));
}
export function ReferenceFields({ draft, onChange }: { draft: ReferenceInput; onChange(draft: ReferenceInput): void }) {
export function ReferenceFields({ draft, issues, onChange }: { draft: ReferenceInput; issues: readonly FieldIssue[]; onChange(draft: ReferenceInput): void }) {
const update = (patch: Partial<ReferenceInput>) => onChange({ ...draft, ...patch });
const moveRule = (index: number, delta: -1 | 1) => {
const target = index + delta;
@@ -21,8 +32,9 @@ export function ReferenceFields({ draft, onChange }: { draft: ReferenceInput; on
return (
<section className="studio-editor-section" aria-labelledby="studio-reference-fields-title">
<div className="studio-editor-section-heading"><p className="studio-eyebrow">REFERENCE</p><h2 id="studio-reference-fields-title"> </h2></div>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.purpose} onChange={(event) => update({ purpose: event.currentTarget.value })} /></label>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.purpose} onChange={(event) => update({ purpose: event.currentTarget.value })} /><FieldNotice issues={issues} path="/purpose" /></label>
<fieldset className="studio-ordered-list"><legend></legend>
<FieldNotice issues={issues} path="/rules" />
{draft.rules.length === 0 ? <p> .</p> : null}
{draft.rules.map((rule, index) => <div className="studio-ordered-item" key={rule.id}>
<label><span> {index + 1} </span><input value={rule.title} onChange={(event) => update({ rules: orderedRules(draft.rules.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, title: event.currentTarget.value } : candidate)) })} /></label>
@@ -32,9 +44,12 @@ export function ReferenceFields({ draft, onChange }: { draft: ReferenceInput; on
<button className="studio-add-item" type="button" disabled={draft.rules.length >= 50} onClick={() => { if (draft.rules.length < 50) update({ rules: orderedRules([...draft.rules, { id: createLocalId("rule"), title: "", body: "", order: draft.rules.length }]) }); }}> </button>
</fieldset>
<OrderedTextList label="적용 조건" items={draft.applyWhen} onChange={(applyWhen) => update({ applyWhen })} />
<FieldNotice issues={issues} path="/applyWhen" />
<OrderedTextList label="예외" items={draft.exceptions} onChange={(exceptions) => update({ exceptions })} />
<FieldNotice issues={issues} path="/exceptions" />
<OrderedTextList label="예시" items={draft.examples} onChange={(examples) => update({ examples })} />
<label className="studio-field"><span> </span><input type="date" value={draft.verifiedOn ?? ""} onChange={(event) => update({ verifiedOn: event.currentTarget.value || null })} /></label>
<FieldNotice issues={issues} path="/examples" />
<label className="studio-field"><span> </span><input type="date" value={draft.verifiedOn ?? ""} onChange={(event) => update({ verifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/verifiedOn" /></label>
</section>
);
}
@@ -94,7 +94,31 @@ export function TaxonomyManager() {
setPending(true);
setError("");
try {
await managementGateway.createProject(title);
const created = await managementGateway.createProject(title);
/*
생성 계약은 이름만 받는데(`CreateDraftRequest`), slug 가 없는 프로젝트는 공개 화면에
나타날 수 없다 — 공개 계약의 `ProjectSummary` 는 `slug` 와 `path` 를 요구하므로 서버는
slug 가 빈 프로젝트를 통째로 생략한다. 기록에 프로젝트를 붙여 게시해도 공개 문서의
프로젝트 칸이 비어 있던 이유가 이것이다.
그래서 만든 직후에 이름에서 만든 slug 를 채운다. 주제가 `{name, slug}` 를 함께 보내는
것과 같은 규칙이고(`slugFromName`), 한글 이름도 로마자로 옮겨 유효한 slug 가 된다.
*/
const project = await managementGateway.getProject(created.id);
await managementGateway.updateProject(created.id, {
expectedVersion: project.version,
name: project.name,
slug: slugFromName(project.name),
oneLinePurpose: project.oneLinePurpose ?? "",
purposeMarkdown: project.purposeMarkdown ?? "",
boundaryMarkdown: project.boundaryMarkdown ?? "",
phase: project.phase,
technologyLabels: project.technologyLabels ?? [],
targetVisibility:
project.targetVisibility === "PUBLIC" || project.targetVisibility === "UNLISTED"
? project.targetVisibility
: "PRIVATE",
});
setProjectTitle("");
setRequestAnnouncement(`프로젝트 ${title} 을(를) 만들었습니다.`);
reload();