feat: port TechLog Studio editors
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
# Task 11 report
|
||||||
|
|
||||||
|
## Mapping
|
||||||
|
|
||||||
|
- Source common, Case, Reference, and Question fields → exact target labels, controls, order, loaded values, conditional resolution fields, and CSS classes.
|
||||||
|
- Source ordered text/rule/option/relation editors → presentation-owned add, remove, reorder, local IDs, limits, and accessibility names.
|
||||||
|
- Source document editor/status rail → source tabs, keyboard focus, working-copy status, dirty indicator, version/kind rail, and deferred workflow controls.
|
||||||
|
- Source instant preview → Content Format v1 `projectWorkingCopy` plus the shared `PublicRecordRenderer`; no gateway preview mutation or parser/renderer duplication.
|
||||||
|
- Source edit page → registered route input adaptation for the document ID.
|
||||||
|
- Task 10 provider seam → smallest generic provider-owned editor session (`saved`, `draft`, `status`) retained across editor tabs.
|
||||||
|
|
||||||
|
## TDD evidence
|
||||||
|
|
||||||
|
- RED: `corepack pnpm exec vitest run tests/features/tech-log/studio-editor-smoke.test.tsx` failed at the exact missing `document-editor-screen.tsx` import before test collection.
|
||||||
|
- First GREEN: the same focused command passed 1 file / 5 tests.
|
||||||
|
- Focused regression: editor smoke plus `content-format.test.ts` and `public-render.test.tsx` passed 3 files / 41 tests.
|
||||||
|
- Scope check: `git diff --check` passed.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Added the 12 Task 11 component/page files under `src/features/tech-log/presentation/studio/`.
|
||||||
|
- Extended `studio-provider.tsx` and `use-studio.ts` only with presentation-owned editor session state.
|
||||||
|
- Added `tests/features/tech-log/studio-editor-smoke.test.tsx`.
|
||||||
|
|
||||||
|
## SHA
|
||||||
|
|
||||||
|
- Base: `887f5e6eb1a5ccdf4feab0b27fae1c5823233190`.
|
||||||
|
- Implementation: the commit containing this report, titled `feat: port TechLog Studio editors` (final SHA recorded in the Task 11 handoff).
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- Save/conflict resolution, guarded navigation, validation, server preview, publish, and unpublish workflows remain deferred to Tasks 12/13. The source Save control is present but disabled until Task 12 supplies the workflow.
|
||||||
|
- Broad app/test types, lint, build, architecture, security, visual, and full-suite gates remain deferred to integrated Task 14 by user direction.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
|
||||||
|
type CaseInput = components["schemas"]["CaseInput"];
|
||||||
|
|
||||||
|
export function CaseFields({ draft, onChange }: { draft: CaseInput; onChange(draft: CaseInput): void }) {
|
||||||
|
const update = (patch: Partial<CaseInput>) => onChange({ ...draft, ...patch });
|
||||||
|
return (
|
||||||
|
<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 className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import type { WorkingCopyInput } from "../../../contracts/studio/contract.ts";
|
||||||
|
import { RelationEditor } from "./relation-editor.tsx";
|
||||||
|
|
||||||
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
|
|
||||||
|
export function CommonDocumentFields({
|
||||||
|
draft,
|
||||||
|
topics,
|
||||||
|
projects,
|
||||||
|
relations,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
draft: WorkingCopyInput;
|
||||||
|
topics: CatalogEntry[];
|
||||||
|
projects: CatalogEntry[];
|
||||||
|
relations: CatalogEntry[];
|
||||||
|
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} 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>
|
||||||
|
</div>
|
||||||
|
<RelationEditor relations={draft.relations} catalog={relations} onChange={(next) => onUpdate({ relations: next })} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
|
||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import type {
|
||||||
|
WorkingCopy,
|
||||||
|
WorkingCopyInput,
|
||||||
|
} from "../../../contracts/studio/contract.ts";
|
||||||
|
import { DocumentEditor, type DocumentEditorController } from "./document-editor.tsx";
|
||||||
|
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
||||||
|
|
||||||
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
|
const catalogTypes = ["TOPIC", "PROJECT", "RELATION", "EVIDENCE"] as const;
|
||||||
|
|
||||||
|
function inputOf(document: WorkingCopy): WorkingCopyInput {
|
||||||
|
const input: Record<string, unknown> = { ...document };
|
||||||
|
delete input.id;
|
||||||
|
delete input.version;
|
||||||
|
delete input.updatedAt;
|
||||||
|
return input as WorkingCopyInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||||
|
const studio = useStudio();
|
||||||
|
const session = useStudioEditorSession();
|
||||||
|
const { begin, clear, editor, updateDraft } = session;
|
||||||
|
const [retryKey, setRetryKey] = useState(0);
|
||||||
|
const requestKey = `${documentId}:${retryKey}`;
|
||||||
|
const [result, setResult] = useState<{
|
||||||
|
key: string;
|
||||||
|
catalog: CatalogEntry[];
|
||||||
|
problem: Error | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const request = new AbortController();
|
||||||
|
clear();
|
||||||
|
void Promise.all([
|
||||||
|
studio.gateway.getDocument(documentId, { signal: request.signal }),
|
||||||
|
...catalogTypes.map((type) => studio.gateway.getCatalog(
|
||||||
|
{ type, limit: 100 },
|
||||||
|
{ signal: request.signal },
|
||||||
|
)),
|
||||||
|
]).then(
|
||||||
|
([detail, ...catalogPages]) => {
|
||||||
|
if (request.signal.aborted) return;
|
||||||
|
setResult({
|
||||||
|
key: requestKey,
|
||||||
|
catalog: catalogPages.flatMap((page) => page.items),
|
||||||
|
problem: null,
|
||||||
|
});
|
||||||
|
begin(detail.document, inputOf(detail.document));
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
if (request.signal.aborted) return;
|
||||||
|
setResult({
|
||||||
|
key: requestKey,
|
||||||
|
catalog: [],
|
||||||
|
problem: error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error("Studio 문서를 불러오지 못했습니다."),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => request.abort();
|
||||||
|
}, [begin, clear, documentId, requestKey, studio.gateway]);
|
||||||
|
|
||||||
|
const controller = useMemo<DocumentEditorController | null>(() => {
|
||||||
|
if (!editor || editor.documentId !== documentId) return null;
|
||||||
|
return {
|
||||||
|
saved: editor.saved,
|
||||||
|
draft: editor.draft,
|
||||||
|
status: editor.status,
|
||||||
|
update(patch) {
|
||||||
|
updateDraft({ ...editor.draft, ...patch, kind: editor.draft.kind } as WorkingCopyInput);
|
||||||
|
},
|
||||||
|
replace(draft) {
|
||||||
|
if (draft.kind === editor.draft.kind) updateDraft(draft);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [documentId, editor, updateDraft]);
|
||||||
|
|
||||||
|
const currentResult = result?.key === requestKey ? result : null;
|
||||||
|
const problem = currentResult?.problem ?? null;
|
||||||
|
if (!currentResult || (!problem && !controller)) {
|
||||||
|
return <section className="studio-editor-loading" role="status"><p>문서 편집기를 준비하고 있습니다.</p></section>;
|
||||||
|
}
|
||||||
|
if (problem && isStudioGatewayError(problem) && problem.code === "DOCUMENT_NOT_FOUND") {
|
||||||
|
return (
|
||||||
|
<section className="studio-route-state">
|
||||||
|
<p className="studio-eyebrow">404</p>
|
||||||
|
<h1>Studio 화면을 찾을 수 없습니다</h1>
|
||||||
|
<p>이 작업본은 현재 Studio 세션에 없습니다.</p>
|
||||||
|
<Link to="/studio/documents">작업본으로 돌아가기</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (problem) {
|
||||||
|
return (
|
||||||
|
<section className="studio-route-state" role="alert">
|
||||||
|
<p className="studio-eyebrow">EDITOR ERROR</p>
|
||||||
|
<h1>문서를 불러오지 못했습니다</h1>
|
||||||
|
<p>{isStudioGatewayError(problem) ? problem.problem.detail : problem.message}</p>
|
||||||
|
<button type="button" onClick={() => setRetryKey((value) => value + 1)}>다시 시도</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <DocumentEditor controller={controller!} catalog={currentResult.catalog} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useRef, useState, type KeyboardEvent } from "react";
|
||||||
|
|
||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import type {
|
||||||
|
WorkingCopy,
|
||||||
|
WorkingCopyInput,
|
||||||
|
} from "../../../contracts/studio/contract.ts";
|
||||||
|
import type { StudioEditorStatus } from "../use-studio.ts";
|
||||||
|
import { CaseFields } from "./case-fields.tsx";
|
||||||
|
import { CommonDocumentFields } from "./common-document-fields.tsx";
|
||||||
|
import { DocumentStatusRail } from "./document-status-rail.tsx";
|
||||||
|
import { InstantPreview } from "./instant-preview.tsx";
|
||||||
|
import { QuestionFields } from "./question-fields.tsx";
|
||||||
|
import { ReferenceFields } from "./reference-fields.tsx";
|
||||||
|
|
||||||
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
|
|
||||||
|
export type DocumentEditorController = {
|
||||||
|
saved: WorkingCopy;
|
||||||
|
draft: WorkingCopyInput;
|
||||||
|
status: StudioEditorStatus;
|
||||||
|
update(patch: Partial<WorkingCopyInput>): void;
|
||||||
|
replace(draft: WorkingCopyInput): void;
|
||||||
|
save?: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DocumentEditor({ controller, catalog }: { controller: DocumentEditorController; catalog: CatalogEntry[] }) {
|
||||||
|
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
|
||||||
|
const editTab = useRef<HTMLButtonElement>(null);
|
||||||
|
const previewTab = useRef<HTMLButtonElement>(null);
|
||||||
|
const selectTab = (next: "EDIT" | "PREVIEW") => {
|
||||||
|
setTab(next);
|
||||||
|
(next === "EDIT" ? editTab : previewTab).current?.focus();
|
||||||
|
};
|
||||||
|
const keyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
let next: "EDIT" | "PREVIEW" | null = null;
|
||||||
|
if (event.key === "ArrowLeft" || event.key === "ArrowRight") next = tab === "EDIT" ? "PREVIEW" : "EDIT";
|
||||||
|
if (event.key === "Home") next = "EDIT";
|
||||||
|
if (event.key === "End") next = "PREVIEW";
|
||||||
|
if (!next) return;
|
||||||
|
event.preventDefault();
|
||||||
|
selectTab(next);
|
||||||
|
};
|
||||||
|
const topics = catalog.filter(({ type }) => type === "TOPIC");
|
||||||
|
const projects = catalog.filter(({ type }) => type === "PROJECT");
|
||||||
|
const relations = catalog.filter(({ type }) => type === "RELATION");
|
||||||
|
const evidence = catalog.filter(({ type }) => type === "EVIDENCE");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="studio-editor-page">
|
||||||
|
<div className="studio-editor-tabs" role="tablist" aria-label="문서 편집 화면">
|
||||||
|
<button id="studio-edit-tab" ref={editTab} type="button" role="tab" aria-selected={tab === "EDIT"} aria-controls="studio-edit-panel" tabIndex={tab === "EDIT" ? 0 : -1} onClick={() => selectTab("EDIT")} onKeyDown={keyDown}>편집</button>
|
||||||
|
<button id="studio-preview-tab" ref={previewTab} type="button" role="tab" aria-selected={tab === "PREVIEW"} aria-controls="studio-preview-panel" tabIndex={tab === "PREVIEW" ? 0 : -1} onClick={() => selectTab("PREVIEW")} onKeyDown={keyDown}>즉시 미리보기</button>
|
||||||
|
</div>
|
||||||
|
<div className="studio-editor-layout">
|
||||||
|
<div className="studio-editor-workspace">
|
||||||
|
<div id="studio-edit-panel" role="tabpanel" aria-labelledby="studio-edit-tab" hidden={tab !== "EDIT"}>
|
||||||
|
<header className="studio-editor-heading">
|
||||||
|
<p className="studio-eyebrow">{controller.draft.kind} · VERSION {controller.saved.version}</p>
|
||||||
|
<h1>문서 편집</h1>
|
||||||
|
<p>{controller.draft.title || "제목 없는 작업본"}</p>
|
||||||
|
</header>
|
||||||
|
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} onUpdate={controller.update} />
|
||||||
|
{controller.draft.kind === "CASE"
|
||||||
|
? <CaseFields draft={controller.draft} onChange={controller.replace} />
|
||||||
|
: controller.draft.kind === "REFERENCE"
|
||||||
|
? <ReferenceFields draft={controller.draft} onChange={controller.replace} />
|
||||||
|
: <QuestionFields draft={controller.draft} evidence={evidence} 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} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DocumentStatusRail controller={controller} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import type { DocumentEditorController } from "./document-editor.tsx";
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
CLEAN: "저장됨",
|
||||||
|
DIRTY: "저장되지 않음",
|
||||||
|
SAVING: "저장 중…",
|
||||||
|
CONFLICT: "저장 충돌",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function DocumentStatusRail({ controller }: { controller: DocumentEditorController }) {
|
||||||
|
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>{controller.draft.kind}</dd></div></dl>
|
||||||
|
<button type="button" onClick={() => { if (controller.save) void controller.save(); }} disabled={!controller.save || controller.status === "CLEAN" || controller.status === "SAVING" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
||||||
|
<Link className="studio-editor-next-link" to={`/studio/documents/${controller.saved.id}/validation`}>저장본 검증</Link>
|
||||||
|
{controller.status === "CONFLICT" ? <p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p> : <p>불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.</p>}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import type { WorkingCopyInput } from "../../../contracts/studio/contract.ts";
|
||||||
|
import { ContentFormatError } from "../../../domain/content-format/parse-case-content.ts";
|
||||||
|
import { projectWorkingCopy } from "../../../domain/content-format/project-public-render-model.ts";
|
||||||
|
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
|
||||||
|
|
||||||
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
|
|
||||||
|
function supportsPreviewEvidenceKey(key: string): boolean {
|
||||||
|
return key === "fetch-strategy-boundary";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePreviewEvidenceAsset(key: string) {
|
||||||
|
if (!supportsPreviewEvidenceKey(key)) {
|
||||||
|
throw new Error(`Unknown local evidence asset: ${key}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
src: "/media/fetch-strategy-boundary.svg",
|
||||||
|
width: 1080,
|
||||||
|
height: 420,
|
||||||
|
triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기",
|
||||||
|
dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InstantPreview({ draft, catalog }: { draft: WorkingCopyInput; catalog: CatalogEntry[] }) {
|
||||||
|
let model: ReturnType<typeof projectWorkingCopy> | null = null;
|
||||||
|
let issues: string[] | null = null;
|
||||||
|
try {
|
||||||
|
model = projectWorkingCopy(
|
||||||
|
draft,
|
||||||
|
catalog,
|
||||||
|
{ mode: "PREVIEW", publishedAt: null },
|
||||||
|
supportsPreviewEvidenceKey,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
issues = error instanceof ContentFormatError
|
||||||
|
? error.issues.map((issue) => `${issue.line}:${issue.column} ${issue.detail}`)
|
||||||
|
: ["현재 입력으로 공개 문서를 구성할 수 없습니다."];
|
||||||
|
}
|
||||||
|
if (issues) return <section className="studio-preview-error" role="alert" aria-labelledby="studio-preview-error-title"><h2 id="studio-preview-error-title">초안을 미리 볼 수 없습니다</h2><ul>{issues.map((issue) => <li key={issue}>{issue}</li>)}</ul></section>;
|
||||||
|
return (
|
||||||
|
<div className="studio-instant-preview">
|
||||||
|
<PublicRecordRenderer
|
||||||
|
model={model!}
|
||||||
|
embedded
|
||||||
|
resolveEvidenceAsset={resolvePreviewEvidenceAsset}
|
||||||
|
resolvePublishedLabel={() => undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||||
|
|
||||||
|
type OrderedText = components["schemas"]["OrderedText"];
|
||||||
|
|
||||||
|
function ordered(items: OrderedText[]): OrderedText[] {
|
||||||
|
return items.map((item, order) => ({ ...item, order }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderedTextList({ label, fieldId, items, onChange }: { label: string; fieldId?: string; items: OrderedText[]; onChange(items: OrderedText[]): void }) {
|
||||||
|
const move = (index: number, delta: -1 | 1) => {
|
||||||
|
const target = index + delta;
|
||||||
|
if (target < 0 || target >= items.length) return;
|
||||||
|
const next = [...items];
|
||||||
|
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||||
|
onChange(ordered(next));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<fieldset id={fieldId} className="studio-ordered-list" tabIndex={fieldId ? -1 : undefined}>
|
||||||
|
<legend>{label}</legend>
|
||||||
|
{items.length === 0 ? <p>아직 입력한 항목이 없습니다.</p> : null}
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div className="studio-ordered-item" key={item.id}>
|
||||||
|
<label><span>{label} {index + 1}</span><textarea id={fieldId ? `${fieldId}-${index}-text` : undefined} value={item.text} onChange={(event) => onChange(ordered(items.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, text: event.currentTarget.value } : candidate)))} /></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 === items.length - 1}>아래로</button><button type="button" onClick={() => onChange(ordered(items.filter((_, candidateIndex) => candidateIndex !== index)))}>삭제</button></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button className="studio-add-item" type="button" disabled={items.length >= 50} onClick={() => { if (items.length < 50) onChange(ordered([...items, { id: createLocalId("item"), text: "", order: items.length }])); }}>{label} 추가</button>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export const defaultPublicationFlowClasses = {
|
||||||
|
page: "studio-publication-page",
|
||||||
|
snapshotPage: "studio-snapshot-page",
|
||||||
|
heading: "studio-publication-heading",
|
||||||
|
routeState: "studio-publication-route-state",
|
||||||
|
eyebrow: "studio-publication-eyebrow",
|
||||||
|
loading: "studio-publication-loading",
|
||||||
|
gateReady: "studio-publication-gate-ready",
|
||||||
|
summary: "studio-publication-summary",
|
||||||
|
changeSummary: "studio-publication-change-summary",
|
||||||
|
publishAction: "studio-publication-action",
|
||||||
|
blocked: "studio-publication-blocked",
|
||||||
|
noWarnings: "studio-publication-no-warnings",
|
||||||
|
warningGroup: "studio-publication-warning-group",
|
||||||
|
warningList: "studio-publication-warning-list",
|
||||||
|
warningItem: "studio-publication-warning-item",
|
||||||
|
error: "studio-publication-error",
|
||||||
|
success: "studio-publication-success",
|
||||||
|
actions: "studio-publication-actions",
|
||||||
|
filters: "studio-publication-filters",
|
||||||
|
errorState: "studio-publication-error-state",
|
||||||
|
emptyState: "studio-publication-empty-state",
|
||||||
|
historyList: "studio-publication-history-list",
|
||||||
|
historyRow: "studio-publication-history-row",
|
||||||
|
eventType: "studio-publication-event-type",
|
||||||
|
eventMain: "studio-publication-event-main",
|
||||||
|
kind: "studio-publication-kind",
|
||||||
|
rowActions: "studio-publication-row-actions",
|
||||||
|
dialog: "studio-publication-dialog",
|
||||||
|
dialogBody: "studio-publication-dialog-body",
|
||||||
|
preservedNote: "studio-publication-preserved-note",
|
||||||
|
dialogActions: "studio-publication-dialog-actions",
|
||||||
|
snapshotToolbar: "studio-publication-snapshot-toolbar",
|
||||||
|
snapshotTitle: "studio-publication-snapshot-title",
|
||||||
|
snapshotDocument: "studio-publication-snapshot-document",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PublicationFlowClasses = Record<keyof typeof defaultPublicationFlowClasses, string>;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||||
|
import { OrderedTextList } from "./ordered-text-list.tsx";
|
||||||
|
|
||||||
|
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, onChange }: { draft: QuestionInput; evidence: CatalogEntry[]; 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></label>
|
||||||
|
<OrderedTextList label="사실" fieldId="studio-field-facts" items={draft.facts} onChange={(facts) => update({ facts })} />
|
||||||
|
<OrderedTextList label="가정" fieldId="studio-field-assumptions" items={draft.assumptions} onChange={(assumptions) => update({ assumptions })} />
|
||||||
|
<OrderedTextList label="미지수" fieldId="studio-field-unknowns" items={draft.unknowns} onChange={(unknowns) => update({ unknowns })} />
|
||||||
|
<OrderedTextList label="제약" fieldId="studio-field-constraints" items={draft.constraints} onChange={(constraints) => update({ constraints })} />
|
||||||
|
<fieldset id="studio-field-options" className="studio-ordered-list" tabIndex={-1}><legend>선택지</legend>
|
||||||
|
{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: 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>
|
||||||
|
{draft.questionStatus === "RESOLVED" && draft.resolution ? <fieldset className="studio-resolution-fields"><legend>해결 내용</legend>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||||
|
import { OrderedTextList } from "./ordered-text-list.tsx";
|
||||||
|
|
||||||
|
type ReferenceInput = components["schemas"]["ReferenceInput"];
|
||||||
|
type ReferenceRule = components["schemas"]["ReferenceRule"];
|
||||||
|
|
||||||
|
function orderedRules(rules: ReferenceRule[]): ReferenceRule[] {
|
||||||
|
return rules.map((rule, order) => ({ ...rule, order }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReferenceFields({ draft, onChange }: { draft: ReferenceInput; onChange(draft: ReferenceInput): void }) {
|
||||||
|
const update = (patch: Partial<ReferenceInput>) => onChange({ ...draft, ...patch });
|
||||||
|
const moveRule = (index: number, delta: -1 | 1) => {
|
||||||
|
const target = index + delta;
|
||||||
|
if (target < 0 || target >= draft.rules.length) return;
|
||||||
|
const next = [...draft.rules];
|
||||||
|
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||||
|
update({ rules: orderedRules(next) });
|
||||||
|
};
|
||||||
|
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>
|
||||||
|
<fieldset className="studio-ordered-list"><legend>규칙</legend>
|
||||||
|
{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>
|
||||||
|
<label><span>규칙 {index + 1} 본문</span><textarea value={rule.body} onChange={(event) => update({ rules: orderedRules(draft.rules.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, body: event.currentTarget.value } : candidate)) })} /></label>
|
||||||
|
<div className="studio-item-actions"><button type="button" onClick={() => moveRule(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => moveRule(index, 1)} disabled={index === draft.rules.length - 1}>아래로</button><button type="button" onClick={() => update({ rules: orderedRules(draft.rules.filter((_, candidateIndex) => candidateIndex !== index)) })}>삭제</button></div>
|
||||||
|
</div>)}
|
||||||
|
<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 })} />
|
||||||
|
<OrderedTextList label="예외" items={draft.exceptions} onChange={(exceptions) => update({ 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>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
|
import { createLocalId } 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, onChange }: { relations: RelationInput[]; catalog: CatalogEntry[]; onChange(relations: RelationInput[]): void }) {
|
||||||
|
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>관계</legend>
|
||||||
|
{relations.length === 0 ? <p>연결한 공개 기록이 없습니다.</p> : null}
|
||||||
|
{relations.map((relation, index) => (
|
||||||
|
<div className="studio-relation-item" key={relation.id ?? `relation-${index}`}>
|
||||||
|
<label><span>관계 {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>관계 {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: createLocalId("relation"), targetId: null, reason: "", order: relations.length }])); }}>관계 추가</button>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||||
|
import { DocumentEditorScreen } from "../components/document-editor-screen.tsx";
|
||||||
|
|
||||||
|
export function DocumentEditPage() {
|
||||||
|
const { params } = useRouteInput<"TECH_LOG_STUDIO_DOCUMENT_EDIT">();
|
||||||
|
return <DocumentEditorScreen documentId={String(params.id)} />;
|
||||||
|
}
|
||||||
@@ -6,7 +6,16 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||||
import { StudioContext, type StudioContextValue } from "./use-studio.ts";
|
import type {
|
||||||
|
WorkingCopy,
|
||||||
|
WorkingCopyInput,
|
||||||
|
} from "../../contracts/studio/contract.ts";
|
||||||
|
import {
|
||||||
|
StudioContext,
|
||||||
|
type StudioContextValue,
|
||||||
|
type StudioEditorState,
|
||||||
|
type StudioEditorStatus,
|
||||||
|
} from "./use-studio.ts";
|
||||||
|
|
||||||
type StudioProviderProps = Readonly<{
|
type StudioProviderProps = Readonly<{
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -25,16 +34,41 @@ export function StudioProvider({
|
|||||||
navigate = defaultNavigate,
|
navigate = defaultNavigate,
|
||||||
}: StudioProviderProps) {
|
}: StudioProviderProps) {
|
||||||
const [gateway] = useState<StudioGateway>(() => createGateway());
|
const [gateway] = useState<StudioGateway>(() => createGateway());
|
||||||
|
const [editor, setEditor] = useState<StudioEditorState | null>(null);
|
||||||
const [requestAnnouncement, setRequestAnnouncement] = useState("");
|
const [requestAnnouncement, setRequestAnnouncement] = useState("");
|
||||||
const navigateInternal = useCallback((href: string) => navigate(href), [navigate]);
|
const navigateInternal = useCallback((href: string) => navigate(href), [navigate]);
|
||||||
|
const beginEditor = useCallback((saved: WorkingCopy, draft: WorkingCopyInput) => {
|
||||||
|
setEditor({ documentId: saved.id, saved, draft, status: "CLEAN" });
|
||||||
|
}, []);
|
||||||
|
const updateEditorDraft = useCallback((draft: WorkingCopyInput) => {
|
||||||
|
setEditor((current) => current ? { ...current, draft, status: "DIRTY" } : current);
|
||||||
|
}, []);
|
||||||
|
const setEditorStatus = useCallback((status: StudioEditorStatus) => {
|
||||||
|
setEditor((current) => current ? { ...current, status } : current);
|
||||||
|
}, []);
|
||||||
|
const clearEditor = useCallback(() => setEditor(null), []);
|
||||||
const value = useMemo<StudioContextValue>(
|
const value = useMemo<StudioContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
gateway,
|
gateway,
|
||||||
|
editor,
|
||||||
requestAnnouncement,
|
requestAnnouncement,
|
||||||
setRequestAnnouncement,
|
setRequestAnnouncement,
|
||||||
navigateInternal,
|
navigateInternal,
|
||||||
|
beginEditor,
|
||||||
|
updateEditorDraft,
|
||||||
|
setEditorStatus,
|
||||||
|
clearEditor,
|
||||||
}),
|
}),
|
||||||
[gateway, navigateInternal, requestAnnouncement],
|
[
|
||||||
|
beginEditor,
|
||||||
|
clearEditor,
|
||||||
|
editor,
|
||||||
|
gateway,
|
||||||
|
navigateInternal,
|
||||||
|
requestAnnouncement,
|
||||||
|
setEditorStatus,
|
||||||
|
updateEditorDraft,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext, useMemo } from "react";
|
||||||
|
|
||||||
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||||
|
import type {
|
||||||
|
WorkingCopy,
|
||||||
|
WorkingCopyInput,
|
||||||
|
} from "../../contracts/studio/contract.ts";
|
||||||
|
|
||||||
|
export type StudioEditorStatus = "CLEAN" | "DIRTY" | "SAVING" | "CONFLICT";
|
||||||
|
|
||||||
|
export type StudioEditorState = Readonly<{
|
||||||
|
documentId: string;
|
||||||
|
saved: WorkingCopy;
|
||||||
|
draft: WorkingCopyInput;
|
||||||
|
status: StudioEditorStatus;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type StudioContextValue = Readonly<{
|
export type StudioContextValue = Readonly<{
|
||||||
gateway: StudioGateway;
|
gateway: StudioGateway;
|
||||||
|
editor: StudioEditorState | null;
|
||||||
requestAnnouncement: string;
|
requestAnnouncement: string;
|
||||||
setRequestAnnouncement(message: string): void;
|
setRequestAnnouncement(message: string): void;
|
||||||
navigateInternal(href: string): void;
|
navigateInternal(href: string): void;
|
||||||
|
beginEditor(saved: WorkingCopy, draft: WorkingCopyInput): void;
|
||||||
|
updateEditorDraft(draft: WorkingCopyInput): void;
|
||||||
|
setEditorStatus(status: StudioEditorStatus): void;
|
||||||
|
clearEditor(): void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const StudioContext = createContext<StudioContextValue | null>(null);
|
export const StudioContext = createContext<StudioContextValue | null>(null);
|
||||||
@@ -16,3 +34,23 @@ export function useStudio(): StudioContextValue {
|
|||||||
if (!context) throw new Error("useStudio must be used within StudioProvider");
|
if (!context) throw new Error("useStudio must be used within StudioProvider");
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useStudioEditorSession() {
|
||||||
|
const studio = useStudio();
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
editor: studio.editor,
|
||||||
|
begin: studio.beginEditor,
|
||||||
|
updateDraft: studio.updateEditorDraft,
|
||||||
|
setStatus: studio.setEditorStatus,
|
||||||
|
clear: studio.clearEditor,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
studio.beginEditor,
|
||||||
|
studio.clearEditor,
|
||||||
|
studio.editor,
|
||||||
|
studio.setEditorStatus,
|
||||||
|
studio.updateEditorDraft,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||||
|
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
||||||
|
import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
|
||||||
|
import { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.tsx";
|
||||||
|
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
function renderEditor(
|
||||||
|
documentId: string,
|
||||||
|
gateway: StudioGateway = createTechLogFeatureInstalledInput().input.createStudioGateway(),
|
||||||
|
) {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[`/studio/documents/${documentId}/edit`]}>
|
||||||
|
<StudioProvider createGateway={() => gateway}>
|
||||||
|
<DocumentEditorScreen documentId={documentId} />
|
||||||
|
</StudioProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelsIn(container: HTMLElement, selector: string): string[] {
|
||||||
|
return Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>(selector),
|
||||||
|
(label) => label.querySelector(":scope > span")?.textContent ?? "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TechLog Studio document editor", () => {
|
||||||
|
it("loads the Case controls in source order, owns dirty state, and renders an instant local preview", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||||
|
const calls = { save: 0, validate: 0, preview: 0, publish: 0 };
|
||||||
|
const gateway = {
|
||||||
|
...base,
|
||||||
|
saveDocument(...args: Parameters<StudioGateway["saveDocument"]>) {
|
||||||
|
calls.save += 1;
|
||||||
|
return base.saveDocument(...args);
|
||||||
|
},
|
||||||
|
validateDocument(...args: Parameters<StudioGateway["validateDocument"]>) {
|
||||||
|
calls.validate += 1;
|
||||||
|
return base.validateDocument(...args);
|
||||||
|
},
|
||||||
|
createPreview(...args: Parameters<StudioGateway["createPreview"]>) {
|
||||||
|
calls.preview += 1;
|
||||||
|
return base.createPreview(...args);
|
||||||
|
},
|
||||||
|
publishDocument(...args: Parameters<StudioGateway["publishDocument"]>) {
|
||||||
|
calls.publish += 1;
|
||||||
|
return base.publishDocument(...args);
|
||||||
|
},
|
||||||
|
} satisfies StudioGateway;
|
||||||
|
const view = renderEditor(FIXTURE_IDS.redisAdapterCase, gateway);
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText("제목")).toHaveValue(
|
||||||
|
"Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유",
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("slug")).toHaveValue("redis-adapter-ttl-boundary");
|
||||||
|
expect(screen.getByLabelText("요약")).toHaveValue(
|
||||||
|
"만료 정책과 명령 실행 책임을 분리했습니다.",
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("Topic")).toHaveValue(FIXTURE_IDS.topicRedis);
|
||||||
|
expect(screen.getByLabelText("Project")).toHaveValue(FIXTURE_IDS.projectBackend);
|
||||||
|
expect(labelsIn(view.container, ".studio-field")).toEqual([
|
||||||
|
"제목",
|
||||||
|
"slug",
|
||||||
|
"요약",
|
||||||
|
"Topic",
|
||||||
|
"Project",
|
||||||
|
"문제",
|
||||||
|
"결론",
|
||||||
|
"검증 환경",
|
||||||
|
"재현 조건",
|
||||||
|
"마지막 검증일",
|
||||||
|
"본문 Markdown",
|
||||||
|
]);
|
||||||
|
expect(screen.getByLabelText("문제")).toHaveValue("저장 기술이 정책을 소유했습니다.");
|
||||||
|
expect(screen.getByLabelText("본문 Markdown")).toHaveValue(
|
||||||
|
"## 책임 경계\n\n정책과 명령을 분리합니다.",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장됨");
|
||||||
|
|
||||||
|
await user.clear(screen.getByLabelText("제목"));
|
||||||
|
await user.type(screen.getByLabelText("제목"), "편집한 Redis 경계");
|
||||||
|
expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent(
|
||||||
|
"저장되지 않음",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("complementary", { name: "작업 상태" })).toHaveTextContent(
|
||||||
|
"저장 버전4종류CASE",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("link", { name: "저장본 검증" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
`/studio/documents/${FIXTURE_IDS.redisAdapterCase}/validation`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const editTab = screen.getByRole("tab", { name: "편집" });
|
||||||
|
const previewTab = screen.getByRole("tab", { name: "즉시 미리보기" });
|
||||||
|
editTab.focus();
|
||||||
|
await user.keyboard("{ArrowRight}");
|
||||||
|
expect(previewTab).toHaveFocus();
|
||||||
|
expect(previewTab).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(
|
||||||
|
within(screen.getByRole("tabpanel", { name: "즉시 미리보기" })).getByRole(
|
||||||
|
"heading",
|
||||||
|
{ level: 1, name: "편집한 Redis 경계" },
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
await user.keyboard("{Home}");
|
||||||
|
expect(editTab).toHaveFocus();
|
||||||
|
expect(screen.getByLabelText("제목")).toHaveValue("편집한 Redis 경계");
|
||||||
|
expect(calls).toEqual({ save: 0, validate: 0, preview: 0, publish: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds, reorders, and removes relations with source accessibility names", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderEditor(FIXTURE_IDS.redisAdapterCase);
|
||||||
|
await screen.findByLabelText("제목");
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "관계 추가" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "관계 추가" }));
|
||||||
|
await user.selectOptions(
|
||||||
|
screen.getByLabelText("관계 1 대상"),
|
||||||
|
FIXTURE_IDS.fetchJoinCase,
|
||||||
|
);
|
||||||
|
await user.type(screen.getByLabelText("관계 1 이유"), "첫 번째 근거");
|
||||||
|
await user.selectOptions(
|
||||||
|
screen.getByLabelText("관계 2 대상"),
|
||||||
|
FIXTURE_IDS.stateNonceReference,
|
||||||
|
);
|
||||||
|
await user.type(screen.getByLabelText("관계 2 이유"), "두 번째 근거");
|
||||||
|
|
||||||
|
const relationRows = screen.getAllByLabelText(/관계 \d 대상/).map((select) =>
|
||||||
|
select.closest(".studio-relation-item") as HTMLElement,
|
||||||
|
);
|
||||||
|
await user.click(within(relationRows[1]!).getByRole("button", { name: "위로" }));
|
||||||
|
expect(screen.getByLabelText("관계 1 대상")).toHaveValue(
|
||||||
|
FIXTURE_IDS.stateNonceReference,
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("관계 1 이유")).toHaveValue("두 번째 근거");
|
||||||
|
|
||||||
|
const firstRow = screen.getByLabelText("관계 1 대상").closest(
|
||||||
|
".studio-relation-item",
|
||||||
|
) as HTMLElement;
|
||||||
|
await user.click(within(firstRow).getByRole("button", { name: "삭제" }));
|
||||||
|
expect(screen.getByLabelText("관계 1 대상")).toHaveValue(FIXTURE_IDS.fetchJoinCase);
|
||||||
|
expect(screen.queryByLabelText("관계 2 대상")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves Reference loaded values and ordered rule/text-list behavior", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderEditor(FIXTURE_IDS.stateNonceReference);
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText("목적")).toHaveValue(
|
||||||
|
"각 검증값의 책임을 다시 찾는 기준입니다.",
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("규칙 1 제목")).toHaveValue("state는 요청을 연결합니다");
|
||||||
|
expect(screen.getByLabelText("적용 조건 1")).toHaveValue("Code Flow를 구성할 때");
|
||||||
|
expect(screen.getByLabelText("마지막 검증일")).toHaveValue("2026-08-13");
|
||||||
|
expect(screen.getByText("아직 입력한 항목이 없습니다.")).toBeVisible();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "규칙 추가" }));
|
||||||
|
await user.type(screen.getByLabelText("규칙 2 제목"), "nonce는 Token을 연결합니다");
|
||||||
|
const secondRule = screen.getByLabelText("규칙 2 제목").closest(
|
||||||
|
".studio-ordered-item",
|
||||||
|
) as HTMLElement;
|
||||||
|
await user.click(within(secondRule).getByRole("button", { name: "위로" }));
|
||||||
|
expect(screen.getByLabelText("규칙 1 제목")).toHaveValue(
|
||||||
|
"nonce는 Token을 연결합니다",
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
within(
|
||||||
|
screen.getByLabelText("규칙 1 제목").closest(".studio-ordered-item") as HTMLElement,
|
||||||
|
).getByRole("button", { name: "삭제" }),
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("규칙 1 제목")).toHaveValue("state는 요청을 연결합니다");
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "적용 조건 추가" }));
|
||||||
|
await user.type(screen.getByLabelText("적용 조건 2"), "두 번째 적용 조건");
|
||||||
|
const secondApplyWhen = screen.getByLabelText("적용 조건 2").closest(
|
||||||
|
".studio-ordered-item",
|
||||||
|
) as HTMLElement;
|
||||||
|
await user.click(within(secondApplyWhen).getByRole("button", { name: "위로" }));
|
||||||
|
expect(screen.getByLabelText("적용 조건 1")).toHaveValue("두 번째 적용 조건");
|
||||||
|
await user.click(
|
||||||
|
within(
|
||||||
|
screen.getByLabelText("적용 조건 1").closest(".studio-ordered-item") as HTMLElement,
|
||||||
|
).getByRole("button", { name: "삭제" }),
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("적용 조건 1")).toHaveValue("Code Flow를 구성할 때");
|
||||||
|
expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent(
|
||||||
|
"저장되지 않음",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves Question field differences, list controls, and conditional resolution fields", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderEditor(FIXTURE_IDS.edgeTokenQuestion);
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText("질문 상태")).toHaveValue("OPEN");
|
||||||
|
expect(screen.getByLabelText("미지수 1")).toHaveValue("신뢰 헤더 위조 가능성");
|
||||||
|
expect(screen.getByRole("textbox", { name: "다음 검증" })).toHaveValue(
|
||||||
|
"위협 모델을 비교합니다.",
|
||||||
|
);
|
||||||
|
expect(screen.queryByLabelText("해결 요약")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "사실 추가" }));
|
||||||
|
await user.type(screen.getByLabelText("사실 1"), "Edge가 서명을 검증합니다");
|
||||||
|
await user.click(screen.getByRole("button", { name: "선택지 추가" }));
|
||||||
|
expect(screen.getByLabelText("선택지 1 제목")).toHaveValue("");
|
||||||
|
expect(screen.getByLabelText("선택지 1 설명")).toHaveValue("");
|
||||||
|
|
||||||
|
await user.selectOptions(screen.getByLabelText("질문 상태"), "RESOLVED");
|
||||||
|
expect(screen.getByLabelText("해결 요약")).toHaveValue("");
|
||||||
|
expect(screen.getByLabelText("해결 근거")).toHaveValue("");
|
||||||
|
expect(screen.getByLabelText("근거 링크 문구")).toHaveValue("");
|
||||||
|
expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent(
|
||||||
|
"저장되지 않음",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders source not-found and retry surfaces for document loading", async () => {
|
||||||
|
const unknown = renderEditor("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "Studio 화면을 찾을 수 없습니다" }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(screen.getByRole("link", { name: "작업본으로 돌아가기" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/studio/documents",
|
||||||
|
);
|
||||||
|
unknown.unmount();
|
||||||
|
|
||||||
|
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||||
|
const getDocument = vi
|
||||||
|
.fn<StudioGateway["getDocument"]>()
|
||||||
|
.mockRejectedValueOnce(new Error("offline"))
|
||||||
|
.mockImplementation((id, options) => base.getDocument(id, options));
|
||||||
|
renderEditor(FIXTURE_IDS.redisAdapterCase, { ...base, getDocument });
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||||
|
"문서를 불러오지 못했습니다offline",
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||||
|
await waitFor(() => expect(screen.getByLabelText("제목")).toBeVisible());
|
||||||
|
expect(getDocument).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user