feat: port TechLog Studio validation workflow
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# Task 12 report
|
||||
|
||||
## Mapping
|
||||
|
||||
- Source editor save workflow → pending/success announcements, fresh per-command idempotency keys, retry after request failure, and revision-conflict state without replacing the local draft.
|
||||
- Source guarded Studio links/provider/dialog → all internal Studio anchors use the guarded `<a>` DOM; dirty navigation offers stay, discard, and save-then-navigate with modal focus/trigger restoration and native `beforeunload` protection.
|
||||
- Source validation report/screen → saved-version validation gates, current/stale freshness copy, error-before-warning issue order, exact JSON-pointer editor anchors, retry/not-found surfaces, and abortable route reads.
|
||||
- Source Public Preview screen → missing/current/stale/expired states, exact next-action labels, idempotent preview creation, retry/not-found surfaces, and the shared typed `PublicRecordRenderer`.
|
||||
- Route pages → existing route-input codecs supply the validation/preview document ID; no publish/history/snapshot behavior was pulled forward.
|
||||
- Task 10/11 seam → provider gained only dirty-navigation/time state, editor gained the save callback, and existing Studio links were switched to the newly available source guarded-link component.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
- RED: `corepack pnpm exec vitest run tests/features/tech-log/studio-save-navigation.test.tsx tests/features/tech-log/studio-validation-preview.test.tsx` exited 1 before collection at the intentionally missing `guarded-studio-link.tsx` and `public-preview-screen.tsx` imports (2 failed files, 0 tests).
|
||||
- GREEN: the two workflow suites plus `tests/features/tech-log/mock-studio-gateway.test.ts` passed 3 files / 22 tests.
|
||||
- Focused seam regression: those three files plus the existing Studio shell, screen, and editor suites passed 6 files / 35 tests.
|
||||
- Scope check: `git diff --check` passed.
|
||||
|
||||
## Files
|
||||
|
||||
- Added guarded link, unsaved dialog, beforeunload hook, validation report/screen, Public Preview screen, and validation/preview route pages under `src/features/tech-log/presentation/studio/`.
|
||||
- Extended the Task 10 provider/context and Task 11 editor/status rail; updated existing Studio internal link consumers to use the source guard.
|
||||
- Added `studio-save-navigation.test.tsx` and `studio-validation-preview.test.tsx`.
|
||||
|
||||
## SHA
|
||||
|
||||
- Base: `59332659752ee17c095471d06e7f0fc8b00c89b4`.
|
||||
- Implementation: the commit containing this report, titled `feat: port TechLog Studio validation workflow` (final SHA recorded in the Task 12 handoff).
|
||||
|
||||
## Deferred
|
||||
|
||||
- Publish, republish, unpublish, publication history, warning acknowledgement, and immutable publication snapshots remain deferred to Task 13.
|
||||
- Broad browser-security, app/test types, lint, build, architecture, visual, and full-suite gates remain deferred to integrated Task 14 by user direction.
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
@@ -7,7 +6,9 @@ import type {
|
||||
WorkingCopy,
|
||||
WorkingCopyInput,
|
||||
} from "../../../contracts/studio/contract.ts";
|
||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||
import { DocumentEditor, type DocumentEditorController } from "./document-editor.tsx";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
@@ -24,7 +25,7 @@ function inputOf(document: WorkingCopy): WorkingCopyInput {
|
||||
export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
const studio = useStudio();
|
||||
const session = useStudioEditorSession();
|
||||
const { begin, clear, editor, updateDraft } = session;
|
||||
const { begin, clear, editor, setStatus, updateDraft } = session;
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
const requestKey = `${documentId}:${retryKey}`;
|
||||
const [result, setResult] = useState<{
|
||||
@@ -66,6 +67,40 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
return () => request.abort();
|
||||
}, [begin, clear, documentId, requestKey, studio.gateway]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const current = editor;
|
||||
if (
|
||||
!current ||
|
||||
current.status === "CLEAN" ||
|
||||
current.status === "SAVING" ||
|
||||
current.status === "CONFLICT"
|
||||
) return;
|
||||
setStatus("SAVING");
|
||||
try {
|
||||
const detail = await studio.gateway.saveDocument(
|
||||
current.documentId,
|
||||
{
|
||||
expectedVersion: current.saved.version,
|
||||
document: current.draft,
|
||||
},
|
||||
{ idempotencyKey: createLocalId("studio-editor-save") },
|
||||
);
|
||||
begin(detail.document, inputOf(detail.document));
|
||||
studio.setRequestAnnouncement(
|
||||
`버전 ${detail.document.version}으로 저장했습니다.`,
|
||||
);
|
||||
} catch (error) {
|
||||
const conflict = isStudioGatewayError(error) &&
|
||||
error.code === "VERSION_CONFLICT";
|
||||
setStatus(conflict ? "CONFLICT" : "DIRTY");
|
||||
studio.setRequestAnnouncement(
|
||||
isStudioGatewayError(error)
|
||||
? error.problem.detail
|
||||
: "저장하지 못했습니다.",
|
||||
);
|
||||
}
|
||||
}, [begin, editor, setStatus, studio]);
|
||||
|
||||
const controller = useMemo<DocumentEditorController | null>(() => {
|
||||
if (!editor || editor.documentId !== documentId) return null;
|
||||
return {
|
||||
@@ -78,8 +113,9 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
replace(draft) {
|
||||
if (draft.kind === editor.draft.kind) updateDraft(draft);
|
||||
},
|
||||
save,
|
||||
};
|
||||
}, [documentId, editor, updateDraft]);
|
||||
}, [documentId, editor, save, updateDraft]);
|
||||
|
||||
const currentResult = result?.key === requestKey ? result : null;
|
||||
const problem = currentResult?.problem ?? null;
|
||||
@@ -92,7 +128,9 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
<p className="studio-eyebrow">404</p>
|
||||
<h1>Studio 화면을 찾을 수 없습니다</h1>
|
||||
<p>이 작업본은 현재 Studio 세션에 없습니다.</p>
|
||||
<Link to="/studio/documents">작업본으로 돌아가기</Link>
|
||||
<GuardedStudioLink href="/studio/documents">
|
||||
작업본으로 돌아가기
|
||||
</GuardedStudioLink>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export type DocumentEditorController = {
|
||||
status: StudioEditorStatus;
|
||||
update(patch: Partial<WorkingCopyInput>): void;
|
||||
replace(draft: WorkingCopyInput): void;
|
||||
save?: () => Promise<void>;
|
||||
save(): Promise<void>;
|
||||
};
|
||||
|
||||
export function DocumentEditor({ controller, catalog }: { controller: DocumentEditorController; catalog: CatalogEntry[] }) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import type { ListDocumentsQuery } from "../../../application/ports/studio-gateway.ts";
|
||||
import type { DocumentPage } from "../../../contracts/studio/contract.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
|
||||
const kindLabel = {
|
||||
CASE: "Case",
|
||||
@@ -94,9 +94,9 @@ export function DocumentList() {
|
||||
세션에 있는 Case, Reference, Question을 찾고 다음 작업으로 이동합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Link className="studio-primary-action" to="/studio/documents/new">
|
||||
<GuardedStudioLink className="studio-primary-action" href="/studio/documents/new">
|
||||
새 문서
|
||||
</Link>
|
||||
</GuardedStudioLink>
|
||||
</header>
|
||||
<section className="studio-document-tools" aria-label="작업본 검색과 필터">
|
||||
<form role="search" onSubmit={submitSearch}>
|
||||
@@ -174,9 +174,9 @@ export function DocumentList() {
|
||||
<p className="studio-row-label">{kindLabel[item.kind]}</p>
|
||||
<div className="studio-document-title">
|
||||
<h2>
|
||||
<Link to={`/studio/documents/${item.id}/edit`}>
|
||||
<GuardedStudioLink href={`/studio/documents/${item.id}/edit`}>
|
||||
{item.title || "제목 없는 작업본"}
|
||||
</Link>
|
||||
</GuardedStudioLink>
|
||||
</h2>
|
||||
<p>{item.project?.label ?? "프로젝트 미지정"}</p>
|
||||
</div>
|
||||
@@ -207,7 +207,9 @@ export function DocumentList() {
|
||||
<section className="studio-empty-state">
|
||||
<h2>조건에 맞는 작업본이 없습니다</h2>
|
||||
<p>검색어 또는 필터를 바꾸거나 새 문서를 만드세요.</p>
|
||||
<Link to="/studio/documents/new">새 문서 만들기</Link>
|
||||
<GuardedStudioLink href="/studio/documents/new">
|
||||
새 문서 만들기
|
||||
</GuardedStudioLink>
|
||||
</section>
|
||||
)}
|
||||
{page.nextCursor ? (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import type { DocumentEditorController } from "./document-editor.tsx";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
|
||||
const labels = {
|
||||
CLEAN: "저장됨",
|
||||
@@ -16,8 +15,8 @@ export function DocumentStatusRail({ controller }: { controller: DocumentEditorC
|
||||
<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>
|
||||
<button type="button" onClick={() => { void controller.save(); }} disabled={controller.status === "CLEAN" || controller.status === "SAVING" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
||||
<GuardedStudioLink className="studio-editor-next-link" href={`/studio/documents/${controller.saved.id}/validation`}>저장본 검증</GuardedStudioLink>
|
||||
{controller.status === "CONFLICT" ? <p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p> : <p>불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.</p>}
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
AnchorHTMLAttributes,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
type GuardedStudioLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
href: string;
|
||||
};
|
||||
|
||||
function isPlainPrimaryClick(event: ReactMouseEvent<HTMLAnchorElement>): boolean {
|
||||
return event.button === 0 &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey;
|
||||
}
|
||||
|
||||
export function GuardedStudioLink({
|
||||
href,
|
||||
onClick,
|
||||
...props
|
||||
}: GuardedStudioLinkProps) {
|
||||
const { navigateInternal } = useStudio();
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
href={href}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
if (event.defaultPrevented || !isPlainPrimaryClick(event)) return;
|
||||
if (
|
||||
event.currentTarget.hasAttribute("download") ||
|
||||
(event.currentTarget.target && event.currentTarget.target !== "_self")
|
||||
) return;
|
||||
if (href.startsWith("#")) return;
|
||||
const target = new URL(href, window.location.href);
|
||||
if (target.origin !== window.location.origin) return;
|
||||
if (
|
||||
target.pathname !== "/studio" &&
|
||||
!target.pathname.startsWith("/studio/")
|
||||
) return;
|
||||
event.preventDefault();
|
||||
navigateInternal(
|
||||
`${target.pathname}${target.search}${target.hash}`,
|
||||
event.currentTarget,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
isStudioGatewayError,
|
||||
type StudioGatewayError,
|
||||
} from "../../../application/ports/studio-gateway-error.ts";
|
||||
import type {
|
||||
PreviewDetail,
|
||||
WorkingCopyDetail,
|
||||
} from "../../../contracts/studio/contract.ts";
|
||||
import { deriveValidationState } from "../../../domain/studio/document-state.ts";
|
||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
|
||||
type LoadedPreview = {
|
||||
detail: WorkingCopyDetail;
|
||||
preview: PreviewDetail | null;
|
||||
};
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return typeof error === "object" &&
|
||||
error !== null &&
|
||||
"name" in error &&
|
||||
error.name === "AbortError";
|
||||
}
|
||||
|
||||
function usableValidation(detail: WorkingCopyDetail, now: Date) {
|
||||
const validation = detail.currentValidation;
|
||||
return validation &&
|
||||
deriveValidationState({ ...detail, now }).freshness === "CURRENT" &&
|
||||
validation.status !== "INVALID"
|
||||
? validation
|
||||
: null;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
timeZone: "Asia/Seoul",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function resolvePreviewEvidenceAsset(key: string) {
|
||||
if (key !== "fetch-strategy-boundary") {
|
||||
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 PublicPreviewScreen({ documentId }: { documentId: string }) {
|
||||
const studio = useStudio();
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
const [loaded, setLoaded] = useState<LoadedPreview | null>(null);
|
||||
const [problem, setProblem] = useState<StudioGatewayError | Error | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const sequence = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const request = new AbortController();
|
||||
void Promise.all([
|
||||
studio.gateway.getDocument(documentId, { signal: request.signal }),
|
||||
studio.gateway.getCurrentPreview(
|
||||
documentId,
|
||||
{ signal: request.signal },
|
||||
).catch((error: unknown) => {
|
||||
if (
|
||||
isStudioGatewayError(error) &&
|
||||
error.code === "PREVIEW_NOT_FOUND"
|
||||
) return null;
|
||||
throw error;
|
||||
}),
|
||||
]).then(
|
||||
([detail, preview]) => {
|
||||
if (request.signal.aborted) return;
|
||||
setLoaded({ detail, preview });
|
||||
setLoading(false);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (request.signal.aborted || isAbortError(error)) return;
|
||||
setProblem(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Public Preview를 불러오지 못했습니다."),
|
||||
);
|
||||
setLoading(false);
|
||||
},
|
||||
);
|
||||
return () => request.abort();
|
||||
}, [documentId, retryKey, studio.gateway]);
|
||||
|
||||
const retry = () => {
|
||||
setLoading(true);
|
||||
setProblem(null);
|
||||
setRetryKey((value) => value + 1);
|
||||
};
|
||||
|
||||
const createPreview = async () => {
|
||||
if (!loaded || creating) return;
|
||||
const validation = usableValidation(loaded.detail, studio.now());
|
||||
if (!validation) return;
|
||||
setCreating(true);
|
||||
setFeedback("");
|
||||
try {
|
||||
const preview = await studio.gateway.createPreview(
|
||||
documentId,
|
||||
{
|
||||
expectedVersion: loaded.detail.document.version,
|
||||
validationId: validation.validationId,
|
||||
},
|
||||
{
|
||||
idempotencyKey: createLocalId(
|
||||
`studio-preview-${++sequence.current}`,
|
||||
),
|
||||
},
|
||||
);
|
||||
setLoaded((current) => current
|
||||
? {
|
||||
...current,
|
||||
preview: {
|
||||
preview,
|
||||
state: "CURRENT",
|
||||
currentDocumentVersion: current.detail.document.version,
|
||||
currentValidationId: validation.validationId,
|
||||
},
|
||||
}
|
||||
: current);
|
||||
setFeedback("Public Preview를 만들었습니다.");
|
||||
studio.setRequestAnnouncement(
|
||||
"현재 저장 버전의 Public Preview를 만들었습니다.",
|
||||
);
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) return;
|
||||
setFeedback(
|
||||
isStudioGatewayError(error)
|
||||
? error.problem.detail
|
||||
: "Public Preview를 만들지 못했습니다. 다시 시도해 주세요.",
|
||||
);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="studio-workflow-loading" role="status">
|
||||
Public Preview를 확인하고 있습니다.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (
|
||||
problem &&
|
||||
isStudioGatewayError(problem) &&
|
||||
problem.code === "DOCUMENT_NOT_FOUND"
|
||||
) {
|
||||
return (
|
||||
<section className="studio-route-state">
|
||||
<p className="studio-eyebrow">404</p>
|
||||
<h1>작업본을 찾을 수 없습니다</h1>
|
||||
<p>이 작업본은 현재 Studio 세션에 없습니다.</p>
|
||||
<GuardedStudioLink href="/studio/documents">
|
||||
작업본으로 돌아가기
|
||||
</GuardedStudioLink>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (problem || !loaded) {
|
||||
return (
|
||||
<section className="studio-route-state" role="alert">
|
||||
<p className="studio-eyebrow">PREVIEW ERROR</p>
|
||||
<h1>Public Preview를 불러오지 못했습니다</h1>
|
||||
<p>
|
||||
{problem && isStudioGatewayError(problem)
|
||||
? problem.problem.detail
|
||||
: problem?.message}
|
||||
</p>
|
||||
<button type="button" onClick={retry}>다시 시도</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const validation = usableValidation(loaded.detail, studio.now());
|
||||
const previewDetail = loaded.preview;
|
||||
const state = previewDetail?.state ?? "NONE";
|
||||
const message = state === "CURRENT"
|
||||
? "현재 저장 버전의 Public Preview입니다."
|
||||
: state === "STALE"
|
||||
? "저장본보다 이전에 만든 Public Preview입니다."
|
||||
: state === "EXPIRED"
|
||||
? "이 Public Preview는 만료되었습니다."
|
||||
: "아직 만든 Public Preview가 없습니다";
|
||||
const canCreate = Boolean(validation) &&
|
||||
state !== "CURRENT" &&
|
||||
state !== "EXPIRED";
|
||||
|
||||
return (
|
||||
<div className="studio-preview-screen">
|
||||
<header
|
||||
className={`studio-preview-toolbar ${previewDetail ? "studio-preview-toolbar--compact" : ""}`}
|
||||
>
|
||||
<div>
|
||||
<p className="studio-eyebrow">
|
||||
PUBLIC PREVIEW · VERSION {loaded.detail.document.version}
|
||||
</p>
|
||||
{previewDetail ? (
|
||||
<p className="studio-preview-title">Public Preview</p>
|
||||
) : (
|
||||
<h1>Public Preview</h1>
|
||||
)}
|
||||
<p>{loaded.detail.document.title}</p>
|
||||
</div>
|
||||
<GuardedStudioLink
|
||||
className="studio-workflow-quiet-link"
|
||||
href={`/studio/documents/${documentId}/validation`}
|
||||
>
|
||||
검증 결과 보기
|
||||
</GuardedStudioLink>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className={`studio-preview-state studio-preview-state--${state.toLowerCase()}`}
|
||||
aria-label="Public Preview 상태"
|
||||
>
|
||||
<div><strong>{state}</strong><p>{message}</p></div>
|
||||
<div className="studio-workflow-actions">
|
||||
{canCreate ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void createPreview(); }}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating
|
||||
? "생성 중…"
|
||||
: state === "NONE"
|
||||
? "Public Preview 만들기"
|
||||
: "Public Preview 다시 만들기"}
|
||||
</button>
|
||||
) : null}
|
||||
{state === "CURRENT" ? (
|
||||
<GuardedStudioLink href={`/studio/documents/${documentId}/publish`}>
|
||||
게시 준비로 이동
|
||||
</GuardedStudioLink>
|
||||
) : null}
|
||||
{!canCreate && state !== "CURRENT" ? (
|
||||
<GuardedStudioLink href={`/studio/documents/${documentId}/validation`}>
|
||||
검증 화면으로 이동
|
||||
</GuardedStudioLink>
|
||||
) : null}
|
||||
<GuardedStudioLink href={`/studio/documents/${documentId}/edit`}>
|
||||
편집으로 돌아가기
|
||||
</GuardedStudioLink>
|
||||
</div>
|
||||
</section>
|
||||
{feedback ? (
|
||||
<p className="studio-workflow-feedback" role="status">{feedback}</p>
|
||||
) : null}
|
||||
|
||||
{previewDetail ? (
|
||||
<>
|
||||
<dl className="studio-preview-metadata">
|
||||
<div>
|
||||
<dt>Preview 버전</dt>
|
||||
<dd>{previewDetail.preview.previewVersion}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>생성</dt>
|
||||
<dd>{formatDateTime(previewDetail.preview.createdAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>만료</dt>
|
||||
<dd>{formatDateTime(previewDetail.preview.expiresAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="studio-preview-public-frame">
|
||||
<PublicRecordRenderer
|
||||
model={previewDetail.preview.renderModel}
|
||||
embedded
|
||||
resolveEvidenceAsset={resolvePreviewEvidenceAsset}
|
||||
resolvePublishedLabel={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<section className="studio-workflow-empty-panel">
|
||||
<h2>공개 레이아웃을 아직 만들지 않았습니다</h2>
|
||||
<p>현재 검증 결과를 기준으로 Public 화면과 같은 문서를 생성합니다.</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import type { StudioDashboard as StudioDashboardData } from "../../../contracts/studio/contract.ts";
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
|
||||
type DocumentSummary = components["schemas"]["DocumentSummary"];
|
||||
|
||||
@@ -27,9 +27,9 @@ function WorkRow({ item }: Readonly<{ item: DocumentSummary }>) {
|
||||
<p className="studio-row-label">{kindLabel[item.kind]}</p>
|
||||
<div>
|
||||
<h3>
|
||||
<Link to={`/studio/documents/${item.id}/edit`}>
|
||||
<GuardedStudioLink href={`/studio/documents/${item.id}/edit`}>
|
||||
{item.title || "제목 없는 작업본"}
|
||||
</Link>
|
||||
</GuardedStudioLink>
|
||||
</h3>
|
||||
<p>
|
||||
{item.project?.label ?? "프로젝트 미지정"} · {actionLabel[item.nextAction]}
|
||||
@@ -59,7 +59,7 @@ function WorkSection({
|
||||
<section className="studio-work-section">
|
||||
<div className="studio-section-title">
|
||||
<h2>{title}</h2>
|
||||
<Link to={href}>전체 보기</Link>
|
||||
<GuardedStudioLink href={href}>전체 보기</GuardedStudioLink>
|
||||
</div>
|
||||
<div className="studio-work-list">
|
||||
{items.length ? (
|
||||
@@ -105,9 +105,9 @@ export function StudioDashboard() {
|
||||
<h1>작업 흐름</h1>
|
||||
<p>작성 중인 기록을 이어서 정리하고 검증·게시 흐름으로 연결합니다.</p>
|
||||
</div>
|
||||
<Link className="studio-primary-action" to="/studio/documents/new">
|
||||
<GuardedStudioLink className="studio-primary-action" href="/studio/documents/new">
|
||||
새 문서
|
||||
</Link>
|
||||
</GuardedStudioLink>
|
||||
</header>
|
||||
{error ? (
|
||||
<p className="studio-screen-error" role="alert">
|
||||
@@ -160,7 +160,9 @@ export function StudioDashboard() {
|
||||
<section className="studio-work-section">
|
||||
<div className="studio-section-title">
|
||||
<h2>최근 게시</h2>
|
||||
<Link to="/studio/publications">게시 기록 보기</Link>
|
||||
<GuardedStudioLink href="/studio/publications">
|
||||
게시 기록 보기
|
||||
</GuardedStudioLink>
|
||||
</div>
|
||||
<div className="studio-work-list">
|
||||
{dashboard.recentPublications.length ? (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useId, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
@@ -27,13 +28,13 @@ function StudioNavigation({
|
||||
return (
|
||||
<nav aria-label={label}>
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
<GuardedStudioLink
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
href={item.href}
|
||||
aria-current={item.active(currentPath) ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</GuardedStudioLink>
|
||||
))}
|
||||
<a href="/">공개 사이트 보기</a>
|
||||
</nav>
|
||||
@@ -46,13 +47,13 @@ export function StudioHeader({ currentPath }: Readonly<{ currentPath: string }>)
|
||||
return (
|
||||
<header className="studio-header">
|
||||
<div className="studio-header-inner">
|
||||
<Link
|
||||
<GuardedStudioLink
|
||||
className="studio-wordmark"
|
||||
to="/studio"
|
||||
href="/studio"
|
||||
aria-label="TechLog Studio"
|
||||
>
|
||||
TechLog <span>Studio</span>
|
||||
</Link>
|
||||
</GuardedStudioLink>
|
||||
<div className="studio-desktop-navigation">
|
||||
<StudioNavigation currentPath={currentPath} label="Studio 주 탐색" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type UnsavedLeaveDialogProps = {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
message: string;
|
||||
onStay(): void;
|
||||
onDiscard(): void;
|
||||
onSaveThenNavigate(): Promise<boolean>;
|
||||
};
|
||||
|
||||
export function UnsavedLeaveDialog({
|
||||
open,
|
||||
saving,
|
||||
message,
|
||||
onStay,
|
||||
onDiscard,
|
||||
onSaveThenNavigate,
|
||||
}: UnsavedLeaveDialogProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const stayRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
dialog.showModal();
|
||||
stayRef.current?.focus();
|
||||
} else if (!open && dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const closeAndStay = () => {
|
||||
dialogRef.current?.close();
|
||||
onStay();
|
||||
};
|
||||
const closeAndDiscard = () => {
|
||||
dialogRef.current?.close();
|
||||
onDiscard();
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="studio-unsaved-dialog"
|
||||
aria-labelledby="studio-unsaved-title"
|
||||
aria-describedby="studio-unsaved-description"
|
||||
aria-hidden={open ? undefined : true}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
if (!saving) closeAndStay();
|
||||
}}
|
||||
>
|
||||
<div className="studio-dialog-body">
|
||||
<p className="studio-eyebrow">UNSAVED CHANGES</p>
|
||||
<h2 id="studio-unsaved-title">저장하지 않은 변경</h2>
|
||||
<p id="studio-unsaved-description">
|
||||
현재 입력을 처리한 뒤 다른 Studio 화면으로 이동합니다.
|
||||
</p>
|
||||
{message ? (
|
||||
<p className="studio-dialog-error" role="alert">{message}</p>
|
||||
) : null}
|
||||
<div className="studio-dialog-actions">
|
||||
<button
|
||||
ref={stayRef}
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={closeAndStay}
|
||||
>
|
||||
이 페이지에 머무르기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={closeAndDiscard}
|
||||
>
|
||||
변경 버리기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
void onSaveThenNavigate().then((saved) => {
|
||||
if (saved) dialogRef.current?.close();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{saving ? "저장 중" : "저장 후 이동"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useBeforeUnload(enabled: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const protectDraft = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", protectDraft);
|
||||
return () => window.removeEventListener("beforeunload", protectDraft);
|
||||
}, [enabled]);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ValidationReport as ValidationReportModel } from "../../../contracts/studio/contract.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
|
||||
const severityLabel = { ERROR: "오류", WARNING: "경고" } as const;
|
||||
|
||||
function pointerSegments(path: string): string[] {
|
||||
return path
|
||||
.split("/")
|
||||
.slice(1)
|
||||
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function validationFieldTarget(path: string): string {
|
||||
const segments = pointerSegments(path).map((segment) =>
|
||||
segment.replace(/[^a-zA-Z0-9_-]/g, "-")
|
||||
);
|
||||
return segments.length
|
||||
? `studio-field-${segments.join("-")}`
|
||||
: "studio-edit-panel";
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
timeZone: "Asia/Seoul",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function ValidationReport({
|
||||
documentId,
|
||||
report,
|
||||
current,
|
||||
}: {
|
||||
documentId: string;
|
||||
report: ValidationReportModel;
|
||||
current: boolean;
|
||||
}) {
|
||||
const issues = report.issues.toSorted((left, right) => {
|
||||
const rank = { ERROR: 0, WARNING: 1 } as const;
|
||||
return rank[left.severity] - rank[right.severity];
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
className="studio-workflow-report"
|
||||
aria-labelledby="studio-validation-report-title"
|
||||
>
|
||||
<div className="studio-workflow-section-heading">
|
||||
<div>
|
||||
<p className="studio-eyebrow">VALIDATION REPORT</p>
|
||||
<h2 id="studio-validation-report-title">검증 결과</h2>
|
||||
</div>
|
||||
<span
|
||||
className={`studio-workflow-status studio-workflow-status--${report.status.toLowerCase()}`}
|
||||
>
|
||||
{report.status}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="studio-workflow-metadata">
|
||||
<div><dt>대상 버전</dt><dd>{report.validatedVersion}</dd></div>
|
||||
<div><dt>상태</dt><dd>{current ? "현재" : "이전 결과"}</dd></div>
|
||||
<div><dt>검증 시각</dt><dd>{formatDateTime(report.validatedAt)}</dd></div>
|
||||
<div><dt>유효 시각</dt><dd>{formatDateTime(report.validUntil)}</dd></div>
|
||||
</dl>
|
||||
{issues.length ? (
|
||||
<ol className="studio-workflow-issue-list" aria-label="검증 항목">
|
||||
{issues.map((issue, index) => (
|
||||
<li key={`${issue.severity}-${issue.code}-${issue.path}-${index}`}>
|
||||
<GuardedStudioLink
|
||||
href={`/studio/documents/${documentId}/edit#${validationFieldTarget(issue.path)}`}
|
||||
>
|
||||
<span className="studio-workflow-issue-severity">
|
||||
{severityLabel[issue.severity]}
|
||||
</span>
|
||||
<span className="studio-workflow-issue-body">
|
||||
<strong>{issue.message}</strong>
|
||||
<small>{issue.code} · {issue.path || "/"}</small>
|
||||
</span>
|
||||
<span aria-hidden="true">편집 →</span>
|
||||
</GuardedStudioLink>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="studio-workflow-empty-issues">오류와 경고가 없습니다.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
isStudioGatewayError,
|
||||
type StudioGatewayError,
|
||||
} from "../../../application/ports/studio-gateway-error.ts";
|
||||
import type {
|
||||
ValidationReport as ValidationReportModel,
|
||||
WorkingCopyDetail,
|
||||
} from "../../../contracts/studio/contract.ts";
|
||||
import { deriveValidationState } from "../../../domain/studio/document-state.ts";
|
||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { ValidationReport } from "./validation-report.tsx";
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return typeof error === "object" &&
|
||||
error !== null &&
|
||||
"name" in error &&
|
||||
error.name === "AbortError";
|
||||
}
|
||||
|
||||
function isCurrent(
|
||||
detail: WorkingCopyDetail,
|
||||
report: ValidationReportModel | null,
|
||||
now: Date,
|
||||
): boolean {
|
||||
if (!report) return false;
|
||||
return deriveValidationState({
|
||||
...detail,
|
||||
currentValidation: report,
|
||||
now,
|
||||
}).freshness === "CURRENT";
|
||||
}
|
||||
|
||||
export function ValidationScreen({ documentId }: { documentId: string }) {
|
||||
const studio = useStudio();
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
const [detail, setDetail] = useState<WorkingCopyDetail | null>(null);
|
||||
const [problem, setProblem] = useState<StudioGatewayError | Error | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [completion, setCompletion] = useState("");
|
||||
const sequence = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const request = new AbortController();
|
||||
void studio.gateway.getDocument(documentId, { signal: request.signal }).then(
|
||||
(next) => {
|
||||
if (request.signal.aborted) return;
|
||||
setDetail(next);
|
||||
setLoading(false);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (request.signal.aborted || isAbortError(error)) return;
|
||||
setProblem(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("검증 화면을 불러오지 못했습니다."),
|
||||
);
|
||||
setLoading(false);
|
||||
},
|
||||
);
|
||||
return () => request.abort();
|
||||
}, [documentId, retryKey, studio.gateway]);
|
||||
|
||||
const retry = () => {
|
||||
setLoading(true);
|
||||
setProblem(null);
|
||||
setRetryKey((value) => value + 1);
|
||||
};
|
||||
|
||||
const validate = async () => {
|
||||
if (!detail || validating) return;
|
||||
setValidating(true);
|
||||
setCompletion("");
|
||||
try {
|
||||
const report = await studio.gateway.validateDocument(
|
||||
documentId,
|
||||
{ expectedVersion: detail.document.version },
|
||||
{
|
||||
idempotencyKey: createLocalId(
|
||||
`studio-validation-${++sequence.current}`,
|
||||
),
|
||||
},
|
||||
);
|
||||
setDetail((current) => current
|
||||
? { ...current, currentValidation: report }
|
||||
: current);
|
||||
setCompletion("검증이 완료되었습니다.");
|
||||
studio.setRequestAnnouncement("저장된 문서 검증이 완료되었습니다.");
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) return;
|
||||
setCompletion(
|
||||
isStudioGatewayError(error)
|
||||
? error.problem.detail
|
||||
: "검증하지 못했습니다. 다시 시도해 주세요.",
|
||||
);
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="studio-workflow-loading" role="status">
|
||||
저장된 문서를 확인하고 있습니다.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (
|
||||
problem &&
|
||||
isStudioGatewayError(problem) &&
|
||||
problem.code === "DOCUMENT_NOT_FOUND"
|
||||
) {
|
||||
return (
|
||||
<section className="studio-route-state">
|
||||
<p className="studio-eyebrow">404</p>
|
||||
<h1>작업본을 찾을 수 없습니다</h1>
|
||||
<p>이 작업본은 현재 Studio 세션에 없습니다.</p>
|
||||
<GuardedStudioLink href="/studio/documents">
|
||||
작업본으로 돌아가기
|
||||
</GuardedStudioLink>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (problem || !detail) {
|
||||
return (
|
||||
<section className="studio-route-state" role="alert">
|
||||
<p className="studio-eyebrow">VALIDATION ERROR</p>
|
||||
<h1>검증 화면을 불러오지 못했습니다</h1>
|
||||
<p>
|
||||
{problem && isStudioGatewayError(problem)
|
||||
? problem.problem.detail
|
||||
: problem?.message}
|
||||
</p>
|
||||
<button type="button" onClick={retry}>다시 시도</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const report = detail.currentValidation;
|
||||
const current = isCurrent(detail, report, studio.now());
|
||||
const ready = current && report?.status !== "INVALID";
|
||||
const gateTitle = !report
|
||||
? "아직 검증하지 않았습니다"
|
||||
: !current
|
||||
? "저장 후 다시 검증해야 합니다"
|
||||
: report.status === "INVALID"
|
||||
? "검증 오류를 수정해야 합니다"
|
||||
: report.status === "WARNINGS"
|
||||
? "경고를 확인하고 Preview를 만들 수 있습니다"
|
||||
: "현재 저장 버전의 검증을 통과했습니다";
|
||||
|
||||
return (
|
||||
<div className="studio-workflow-screen">
|
||||
<header className="studio-workflow-heading">
|
||||
<div>
|
||||
<p className="studio-eyebrow">
|
||||
DOCUMENT VALIDATION · VERSION {detail.document.version}
|
||||
</p>
|
||||
<h1>저장본 검증</h1>
|
||||
<p>{detail.document.title}</p>
|
||||
</div>
|
||||
<GuardedStudioLink
|
||||
className="studio-workflow-quiet-link"
|
||||
href={`/studio/documents/${documentId}/edit`}
|
||||
>
|
||||
편집으로 돌아가기
|
||||
</GuardedStudioLink>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className={`studio-workflow-gate ${ready ? "studio-workflow-gate--ready" : "studio-workflow-gate--blocked"}`}
|
||||
aria-labelledby="studio-validation-gate-title"
|
||||
>
|
||||
<div>
|
||||
<p className="studio-eyebrow">WORKFLOW GATE</p>
|
||||
<h2 id="studio-validation-gate-title">{gateTitle}</h2>
|
||||
<p>
|
||||
검증은 화면의 임시 입력이 아닌 서버에 저장된 버전{" "}
|
||||
{detail.document.version}을 기준으로 실행합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="studio-workflow-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void validate(); }}
|
||||
disabled={validating}
|
||||
>
|
||||
{validating ? "검증 중…" : report ? "다시 검증" : "검증하기"}
|
||||
</button>
|
||||
{ready ? (
|
||||
<GuardedStudioLink href={`/studio/documents/${documentId}/preview`}>
|
||||
Public Preview 만들기
|
||||
</GuardedStudioLink>
|
||||
) : null}
|
||||
{current && report?.status === "INVALID" ? (
|
||||
<GuardedStudioLink href={`/studio/documents/${documentId}/edit`}>
|
||||
편집 화면에서 수정
|
||||
</GuardedStudioLink>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
{completion ? (
|
||||
<p className="studio-workflow-feedback" role="status">{completion}</p>
|
||||
) : null}
|
||||
{report ? (
|
||||
<ValidationReport
|
||||
documentId={documentId}
|
||||
report={report}
|
||||
current={current}
|
||||
/>
|
||||
) : (
|
||||
<section className="studio-workflow-empty-panel">
|
||||
<h2>검증 기록이 없습니다</h2>
|
||||
<p>
|
||||
저장된 버전을 검증하면 공개 전 오류와 경고가 여기에 정리됩니다.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { PublicPreviewScreen } from "../components/public-preview-screen.tsx";
|
||||
|
||||
export function DocumentPreviewPage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_STUDIO_DOCUMENT_PREVIEW">();
|
||||
return <PublicPreviewScreen documentId={String(params.id)} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { ValidationScreen } from "../components/validation-screen.tsx";
|
||||
|
||||
export function DocumentValidationPage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_STUDIO_DOCUMENT_VALIDATION">();
|
||||
return <ValidationScreen documentId={String(params.id)} />;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { GuardedStudioLink } from "../components/guarded-studio-link.tsx";
|
||||
|
||||
export function StudioNotFoundPage() {
|
||||
return (
|
||||
@@ -6,7 +6,9 @@ export function StudioNotFoundPage() {
|
||||
<p className="studio-eyebrow">404</p>
|
||||
<h1>Studio 화면을 찾을 수 없습니다</h1>
|
||||
<p>주소를 확인하거나 작업본 목록에서 다시 시작하세요.</p>
|
||||
<Link to="/studio/documents">작업본으로 돌아가기</Link>
|
||||
<GuardedStudioLink href="/studio/documents">
|
||||
작업본으로 돌아가기
|
||||
</GuardedStudioLink>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
||||
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||
import type {
|
||||
WorkingCopy,
|
||||
@@ -16,13 +18,24 @@ import {
|
||||
type StudioEditorState,
|
||||
type StudioEditorStatus,
|
||||
} from "./use-studio.ts";
|
||||
import { UnsavedLeaveDialog } from "./components/unsaved-leave-dialog.tsx";
|
||||
import { useBeforeUnload } from "./components/use-before-unload.ts";
|
||||
|
||||
type StudioProviderProps = Readonly<{
|
||||
children: ReactNode;
|
||||
createGateway: () => StudioGateway;
|
||||
now?: () => Date;
|
||||
navigate?: (href: string) => void;
|
||||
}>;
|
||||
|
||||
function inputOf(document: WorkingCopy): WorkingCopyInput {
|
||||
const input: Record<string, unknown> = { ...document };
|
||||
delete input.id;
|
||||
delete input.version;
|
||||
delete input.updatedAt;
|
||||
return input as WorkingCopyInput;
|
||||
}
|
||||
|
||||
function defaultNavigate(href: string): void {
|
||||
window.history.pushState({}, "", href);
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
@@ -31,12 +44,31 @@ function defaultNavigate(href: string): void {
|
||||
export function StudioProvider({
|
||||
children,
|
||||
createGateway,
|
||||
now = () => new Date("2026-08-14T01:00:00.000Z"),
|
||||
navigate = defaultNavigate,
|
||||
}: StudioProviderProps) {
|
||||
const [gateway] = useState<StudioGateway>(() => createGateway());
|
||||
const [editor, setEditor] = useState<StudioEditorState | null>(null);
|
||||
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
||||
const [requestAnnouncement, setRequestAnnouncement] = useState("");
|
||||
const navigateInternal = useCallback((href: string) => navigate(href), [navigate]);
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
const saveSequence = useRef(0);
|
||||
|
||||
const unsafe = editor?.status === "DIRTY" || editor?.status === "CONFLICT";
|
||||
useBeforeUnload(unsafe);
|
||||
|
||||
const navigateInternal = useCallback((
|
||||
href: string,
|
||||
trigger?: HTMLElement | null,
|
||||
) => {
|
||||
if (!unsafe) {
|
||||
navigate(href);
|
||||
return;
|
||||
}
|
||||
triggerRef.current = trigger ?? null;
|
||||
setPendingHref(href);
|
||||
setRequestAnnouncement("");
|
||||
}, [navigate, unsafe]);
|
||||
const beginEditor = useCallback((saved: WorkingCopy, draft: WorkingCopyInput) => {
|
||||
setEditor({ documentId: saved.id, saved, draft, status: "CLEAN" });
|
||||
}, []);
|
||||
@@ -47,9 +79,71 @@ export function StudioProvider({
|
||||
setEditor((current) => current ? { ...current, status } : current);
|
||||
}, []);
|
||||
const clearEditor = useCallback(() => setEditor(null), []);
|
||||
|
||||
const stay = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
triggerRef.current = null;
|
||||
setPendingHref(null);
|
||||
setRequestAnnouncement("");
|
||||
queueMicrotask(() => trigger?.focus());
|
||||
}, []);
|
||||
|
||||
const discard = useCallback(() => {
|
||||
const href = pendingHref;
|
||||
setEditor((current) => current
|
||||
? { ...current, draft: inputOf(current.saved), status: "CLEAN" }
|
||||
: current);
|
||||
setPendingHref(null);
|
||||
setRequestAnnouncement("");
|
||||
triggerRef.current = null;
|
||||
if (href) navigate(href);
|
||||
}, [navigate, pendingHref]);
|
||||
|
||||
const saveThenNavigate = useCallback(async (): Promise<boolean> => {
|
||||
const current = editor;
|
||||
const href = pendingHref;
|
||||
if (!current || !href || current.status === "SAVING") return false;
|
||||
setEditor({ ...current, status: "SAVING" });
|
||||
try {
|
||||
const detail = await gateway.saveDocument(
|
||||
current.documentId,
|
||||
{
|
||||
expectedVersion: current.saved.version,
|
||||
document: current.draft,
|
||||
},
|
||||
{ idempotencyKey: `studio-local-save-${++saveSequence.current}` },
|
||||
);
|
||||
setEditor({
|
||||
documentId: detail.document.id,
|
||||
saved: detail.document,
|
||||
draft: inputOf(detail.document),
|
||||
status: "CLEAN",
|
||||
});
|
||||
setPendingHref(null);
|
||||
setRequestAnnouncement(`버전 ${detail.document.version}으로 저장했습니다.`);
|
||||
triggerRef.current = null;
|
||||
navigate(href);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setEditor({
|
||||
...current,
|
||||
status: isStudioGatewayError(error) && error.code === "VERSION_CONFLICT"
|
||||
? "CONFLICT"
|
||||
: "DIRTY",
|
||||
});
|
||||
setRequestAnnouncement(
|
||||
isStudioGatewayError(error)
|
||||
? error.problem.detail
|
||||
: "저장하지 못했습니다. 다시 시도해 주세요.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}, [editor, gateway, navigate, pendingHref]);
|
||||
|
||||
const value = useMemo<StudioContextValue>(
|
||||
() => ({
|
||||
gateway,
|
||||
now,
|
||||
editor,
|
||||
requestAnnouncement,
|
||||
setRequestAnnouncement,
|
||||
@@ -65,6 +159,7 @@ export function StudioProvider({
|
||||
editor,
|
||||
gateway,
|
||||
navigateInternal,
|
||||
now,
|
||||
requestAnnouncement,
|
||||
setEditorStatus,
|
||||
updateEditorDraft,
|
||||
@@ -73,7 +168,17 @@ export function StudioProvider({
|
||||
|
||||
return (
|
||||
<StudioContext.Provider value={value}>
|
||||
<div className="studio-app">{children}</div>
|
||||
<div className="studio-app">
|
||||
{children}
|
||||
<UnsavedLeaveDialog
|
||||
open={pendingHref !== null}
|
||||
saving={editor?.status === "SAVING"}
|
||||
message={requestAnnouncement}
|
||||
onStay={stay}
|
||||
onDiscard={discard}
|
||||
onSaveThenNavigate={saveThenNavigate}
|
||||
/>
|
||||
</div>
|
||||
</StudioContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,10 +17,11 @@ export type StudioEditorState = Readonly<{
|
||||
|
||||
export type StudioContextValue = Readonly<{
|
||||
gateway: StudioGateway;
|
||||
now(): Date;
|
||||
editor: StudioEditorState | null;
|
||||
requestAnnouncement: string;
|
||||
setRequestAnnouncement(message: string): void;
|
||||
navigateInternal(href: string): void;
|
||||
navigateInternal(href: string, trigger?: HTMLElement | null): void;
|
||||
beginEditor(saved: WorkingCopy, draft: WorkingCopyInput): void;
|
||||
updateEditorDraft(draft: WorkingCopyInput): void;
|
||||
setEditorStatus(status: StudioEditorStatus): void;
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useEffect } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, 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 { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
|
||||
import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
|
||||
import type {
|
||||
WorkingCopy,
|
||||
WorkingCopyInput,
|
||||
WorkingCopyDetail,
|
||||
} from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||
import { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.tsx";
|
||||
import { GuardedStudioLink } from "../../../src/features/tech-log/presentation/studio/components/guarded-studio-link.tsx";
|
||||
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
|
||||
import {
|
||||
useStudio,
|
||||
useStudioEditorSession,
|
||||
} from "../../../src/features/tech-log/presentation/studio/use-studio.ts";
|
||||
|
||||
const originalShowModal = HTMLDialogElement.prototype.showModal;
|
||||
const originalClose = HTMLDialogElement.prototype.close;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
|
||||
configurable: true,
|
||||
value(this: HTMLDialogElement) {
|
||||
this.setAttribute("open", "");
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLDialogElement.prototype, "close", {
|
||||
configurable: true,
|
||||
value(this: HTMLDialogElement) {
|
||||
this.removeAttribute("open");
|
||||
this.dispatchEvent(new Event("close"));
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
|
||||
configurable: true,
|
||||
value: originalShowModal,
|
||||
});
|
||||
Object.defineProperty(HTMLDialogElement.prototype, "close", {
|
||||
configurable: true,
|
||||
value: originalClose,
|
||||
});
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((nextResolve, nextReject) => {
|
||||
resolve = nextResolve;
|
||||
reject = nextReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function inputOf(document: WorkingCopy): WorkingCopyInput {
|
||||
const { id, version, updatedAt, ...input } = document;
|
||||
void id;
|
||||
void version;
|
||||
void updatedAt;
|
||||
return input;
|
||||
}
|
||||
|
||||
function Announcement() {
|
||||
const { requestAnnouncement } = useStudio();
|
||||
return <p data-testid="announcement" aria-live="polite">{requestAnnouncement}</p>;
|
||||
}
|
||||
|
||||
function renderEditor(documentId: string, gateway: StudioGateway) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[`/studio/documents/${documentId}/edit`]}>
|
||||
<StudioProvider createGateway={() => gateway}>
|
||||
<DocumentEditorScreen documentId={documentId} />
|
||||
<Announcement />
|
||||
</StudioProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function DirtyNavigationProbe({ saved }: { saved: WorkingCopy }) {
|
||||
const { begin, updateDraft } = useStudioEditorSession();
|
||||
useEffect(() => {
|
||||
begin(saved, inputOf(saved));
|
||||
updateDraft({ ...inputOf(saved), title: "바뀐 제목" });
|
||||
}, [begin, saved, updateDraft]);
|
||||
return <GuardedStudioLink href="/studio/documents">작업본</GuardedStudioLink>;
|
||||
}
|
||||
|
||||
async function getSavedDocument(gateway: StudioGateway) {
|
||||
return (await gateway.getDocument(FIXTURE_IDS.redisAdapterCase)).document;
|
||||
}
|
||||
|
||||
describe("TechLog Studio save workflow", () => {
|
||||
it("shows save pending and success states and creates a fresh idempotency key for each command", async () => {
|
||||
const user = userEvent.setup();
|
||||
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const first = deferred<void>();
|
||||
const keys: string[] = [];
|
||||
let calls = 0;
|
||||
const gateway = {
|
||||
...base,
|
||||
saveDocument(...args: Parameters<StudioGateway["saveDocument"]>) {
|
||||
keys.push(args[2].idempotencyKey);
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? first.promise.then(() => base.saveDocument(...args))
|
||||
: base.saveDocument(...args);
|
||||
},
|
||||
} satisfies StudioGateway;
|
||||
renderEditor(FIXTURE_IDS.redisAdapterCase, gateway);
|
||||
|
||||
await user.clear(await screen.findByLabelText("제목"));
|
||||
await user.type(screen.getByLabelText("제목"), "첫 저장 제목");
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장 중…");
|
||||
expect(screen.getByRole("button", { name: "저장 중…" })).toBeDisabled();
|
||||
|
||||
first.resolve();
|
||||
await waitFor(() => expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장됨"));
|
||||
expect(screen.getByTestId("announcement")).toHaveTextContent(
|
||||
"버전 5으로 저장했습니다.",
|
||||
);
|
||||
|
||||
await user.clear(screen.getByLabelText("제목"));
|
||||
await user.type(screen.getByLabelText("제목"), "두 번째 저장 제목");
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
await waitFor(() => expect(keys).toHaveLength(2));
|
||||
expect(keys[0]).toBeTruthy();
|
||||
expect(keys[1]).toBeTruthy();
|
||||
expect(keys[1]).not.toBe(keys[0]);
|
||||
});
|
||||
|
||||
it("keeps the local draft and disables overwrite after a revision conflict", async () => {
|
||||
const user = userEvent.setup();
|
||||
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
renderEditor(FIXTURE_IDS.conflictCase, gateway);
|
||||
|
||||
await user.clear(await screen.findByLabelText("제목"));
|
||||
await user.type(screen.getByLabelText("제목"), "내 충돌 초안");
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.",
|
||||
);
|
||||
expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장 충돌");
|
||||
expect(screen.getByLabelText("제목")).toHaveValue("내 충돌 초안");
|
||||
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("surfaces a save error and retries with a new user-command key without losing input", async () => {
|
||||
const user = userEvent.setup();
|
||||
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const keys: string[] = [];
|
||||
const retryable = new StudioGatewayError({
|
||||
type: "https://techlog.local/problems/studio-unavailable",
|
||||
title: "STUDIO_UNAVAILABLE",
|
||||
status: 503,
|
||||
detail: "Studio가 잠시 응답하지 않습니다.",
|
||||
code: "STUDIO_UNAVAILABLE",
|
||||
retryable: true,
|
||||
});
|
||||
const saveDocument = vi
|
||||
.fn<StudioGateway["saveDocument"]>()
|
||||
.mockImplementationOnce((_id, _command, options) => {
|
||||
keys.push(options.idempotencyKey);
|
||||
return Promise.reject(retryable);
|
||||
})
|
||||
.mockImplementation((...args) => {
|
||||
keys.push(args[2].idempotencyKey);
|
||||
return base.saveDocument(...args);
|
||||
});
|
||||
renderEditor(FIXTURE_IDS.redisAdapterCase, { ...base, saveDocument });
|
||||
|
||||
await user.clear(await screen.findByLabelText("제목"));
|
||||
await user.type(screen.getByLabelText("제목"), "오류 뒤에도 남는 초안");
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("announcement")).toHaveTextContent(
|
||||
"Studio가 잠시 응답하지 않습니다.",
|
||||
));
|
||||
expect(screen.getByLabelText("제목")).toHaveValue("오류 뒤에도 남는 초안");
|
||||
expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장되지 않음");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
await waitFor(() => expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장됨"));
|
||||
expect(saveDocument).toHaveBeenCalledTimes(2);
|
||||
expect(keys[1]).not.toBe(keys[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TechLog Studio dirty navigation", () => {
|
||||
it("opens the source modal dialog, protects browser unload, and restores focus when staying", async () => {
|
||||
const user = userEvent.setup();
|
||||
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const destinations: string[] = [];
|
||||
const saved = await getSavedDocument(gateway);
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/studio/documents/${saved.id}/edit`]}>
|
||||
<StudioProvider createGateway={() => gateway} navigate={(href) => destinations.push(href)}>
|
||||
<DirtyNavigationProbe saved={saved} />
|
||||
</StudioProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const unload = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(unload);
|
||||
expect(unload.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
const trigger = screen.getByRole("link", { name: "작업본" });
|
||||
trigger.focus();
|
||||
await user.click(trigger);
|
||||
const dialog = screen.getByRole("dialog", { name: "저장하지 않은 변경" });
|
||||
expect(dialog).toHaveAttribute("open");
|
||||
expect(screen.getByRole("button", { name: "이 페이지에 머무르기" })).toHaveFocus();
|
||||
expect(screen.getAllByRole("button").map((button) => button.textContent)).toEqual([
|
||||
"이 페이지에 머무르기",
|
||||
"변경 버리기",
|
||||
"저장 후 이동",
|
||||
]);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "이 페이지에 머무르기" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(trigger).toHaveFocus();
|
||||
expect(destinations).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards the draft and follows the pending internal destination", async () => {
|
||||
const user = userEvent.setup();
|
||||
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const destinations: string[] = [];
|
||||
const saved = await getSavedDocument(gateway);
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/studio/documents/${saved.id}/edit`]}>
|
||||
<StudioProvider createGateway={() => gateway} navigate={(href) => destinations.push(href)}>
|
||||
<DirtyNavigationProbe saved={saved} />
|
||||
</StudioProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("link", { name: "작업본" }));
|
||||
await user.click(screen.getByRole("button", { name: "변경 버리기" }));
|
||||
expect(destinations).toEqual(["/studio/documents"]);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
const unload = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(unload);
|
||||
expect(unload.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("saves the draft before following the pending destination", async () => {
|
||||
const user = userEvent.setup();
|
||||
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const pending = deferred<WorkingCopyDetail>();
|
||||
let key = "";
|
||||
const gateway = {
|
||||
...base,
|
||||
saveDocument(...args: Parameters<StudioGateway["saveDocument"]>) {
|
||||
key = args[2].idempotencyKey;
|
||||
return pending.promise;
|
||||
},
|
||||
} satisfies StudioGateway;
|
||||
const destinations: string[] = [];
|
||||
const saved = await getSavedDocument(base);
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/studio/documents/${saved.id}/edit`]}>
|
||||
<StudioProvider createGateway={() => gateway} navigate={(href) => destinations.push(href)}>
|
||||
<DirtyNavigationProbe saved={saved} />
|
||||
</StudioProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("link", { name: "작업본" }));
|
||||
await user.click(screen.getByRole("button", { name: "저장 후 이동" }));
|
||||
expect(screen.getByRole("button", { name: "저장 중" })).toBeDisabled();
|
||||
expect(destinations).toEqual([]);
|
||||
|
||||
pending.resolve({
|
||||
...(await base.getDocument(saved.id)),
|
||||
document: { ...saved, version: saved.version + 1, title: "바뀐 제목" },
|
||||
});
|
||||
await waitFor(() => expect(destinations).toEqual(["/studio/documents"]));
|
||||
expect(key).toBeTruthy();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterAll, afterEach, beforeAll, 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 { PublicPreviewScreen } from "../../../src/features/tech-log/presentation/studio/components/public-preview-screen.tsx";
|
||||
import { ValidationScreen } from "../../../src/features/tech-log/presentation/studio/components/validation-screen.tsx";
|
||||
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
|
||||
|
||||
class NoopIntersectionObserver implements IntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = "0px";
|
||||
readonly scrollMargin = "0px";
|
||||
readonly thresholds = [0];
|
||||
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return [];
|
||||
}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal("IntersectionObserver", NoopIntersectionObserver);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function renderStudio(
|
||||
child: React.ReactNode,
|
||||
gateway: StudioGateway = createTechLogFeatureInstalledInput().input.createStudioGateway(),
|
||||
) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/studio"]}>
|
||||
<StudioProvider createGateway={() => gateway}>{child}</StudioProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TechLog Studio validation workflow", () => {
|
||||
it("reruns stale saved validation, reports exact freshness copy, and creates a new key per command", async () => {
|
||||
const user = userEvent.setup();
|
||||
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const keys: string[] = [];
|
||||
const gateway = {
|
||||
...base,
|
||||
validateDocument(...args: Parameters<StudioGateway["validateDocument"]>) {
|
||||
keys.push(args[2].idempotencyKey);
|
||||
return base.validateDocument(...args);
|
||||
},
|
||||
} satisfies StudioGateway;
|
||||
renderStudio(<ValidationScreen documentId={FIXTURE_IDS.fetchJoinCase} />, gateway);
|
||||
|
||||
expect(await screen.findByText("저장 후 다시 검증해야 합니다")).toBeVisible();
|
||||
expect(screen.getByText("이전 결과")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "다시 검증" }));
|
||||
|
||||
expect(await screen.findByText("검증이 완료되었습니다.")).toBeVisible();
|
||||
expect(screen.getByText("현재")).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "Public Preview 만들기" })).toHaveAttribute(
|
||||
"href",
|
||||
`/studio/documents/${FIXTURE_IDS.fetchJoinCase}/preview`,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "다시 검증" }));
|
||||
await waitFor(() => expect(keys).toHaveLength(2));
|
||||
expect(keys[0]).toBeTruthy();
|
||||
expect(keys[1]).not.toBe(keys[0]);
|
||||
});
|
||||
|
||||
it("orders validation issues and links each JSON pointer to the affected editor field", async () => {
|
||||
const user = userEvent.setup();
|
||||
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const view = renderStudio(
|
||||
<ValidationScreen documentId={FIXTURE_IDS.edgeTokenQuestion} />,
|
||||
gateway,
|
||||
);
|
||||
|
||||
const issue = await screen.findByText("사실이 필요합니다.");
|
||||
expect(issue.closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
`/studio/documents/${FIXTURE_IDS.edgeTokenQuestion}/edit#studio-field-facts`,
|
||||
);
|
||||
expect(screen.getByText("검증 오류를 수정해야 합니다")).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "편집 화면에서 수정" })).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "다시 검증" }));
|
||||
const warning = await screen.findByText("선택지 두 개를 권장합니다.");
|
||||
expect(warning.closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
`/studio/documents/${FIXTURE_IDS.edgeTokenQuestion}/edit#studio-field-options`,
|
||||
);
|
||||
view.unmount();
|
||||
|
||||
renderStudio(<DocumentEditorScreen documentId={FIXTURE_IDS.edgeTokenQuestion} />, gateway);
|
||||
await screen.findByRole("heading", { level: 1, name: "문서 편집" });
|
||||
expect(document.querySelector("#studio-field-facts")).toBeInTheDocument();
|
||||
expect(document.querySelector("#studio-field-options")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("aborts a route-obsolete validation read without surfacing an abort error", async () => {
|
||||
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
let firstSignal: AbortSignal | undefined;
|
||||
const getDocument = vi
|
||||
.fn<StudioGateway["getDocument"]>()
|
||||
.mockImplementationOnce((_id, options) => {
|
||||
firstSignal = options?.signal;
|
||||
return new Promise((_resolve, reject) => {
|
||||
options?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
|
||||
});
|
||||
})
|
||||
.mockImplementation((id, options) => base.getDocument(id, options));
|
||||
const gateway = { ...base, getDocument };
|
||||
const view = renderStudio(
|
||||
<ValidationScreen documentId={FIXTURE_IDS.fetchJoinCase} />,
|
||||
gateway,
|
||||
);
|
||||
await waitFor(() => expect(firstSignal).toBeDefined());
|
||||
|
||||
view.rerender(
|
||||
<MemoryRouter initialEntries={["/studio"]}>
|
||||
<StudioProvider createGateway={() => gateway}>
|
||||
<ValidationScreen documentId={FIXTURE_IDS.stateNonceReference} />
|
||||
</StudioProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(firstSignal?.aborted).toBe(true));
|
||||
expect(await screen.findByText("현재 저장 버전의 검증을 통과했습니다")).toBeVisible();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TechLog Studio Public Preview workflow", () => {
|
||||
it("creates a missing preview, shows the current state, and renders the shared Public document", async () => {
|
||||
const user = userEvent.setup();
|
||||
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const keys: string[] = [];
|
||||
const gateway = {
|
||||
...base,
|
||||
createPreview(...args: Parameters<StudioGateway["createPreview"]>) {
|
||||
keys.push(args[2].idempotencyKey);
|
||||
return base.createPreview(...args);
|
||||
},
|
||||
} satisfies StudioGateway;
|
||||
const view = renderStudio(
|
||||
<PublicPreviewScreen documentId={FIXTURE_IDS.stateNonceReference} />,
|
||||
gateway,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("아직 만든 Public Preview가 없습니다")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Public Preview 만들기" }));
|
||||
|
||||
expect(await screen.findByText("현재 저장 버전의 Public Preview입니다.")).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "게시 준비로 이동" })).toHaveAttribute(
|
||||
"href",
|
||||
`/studio/documents/${FIXTURE_IDS.stateNonceReference}/publish`,
|
||||
);
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Authorization Code Flow에서 state와 nonce의 경계" })).toBeVisible();
|
||||
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
|
||||
expect(view.container.querySelector("main main")).toBeNull();
|
||||
expect(view.container.querySelector(".public-record-embedded")).toBeInTheDocument();
|
||||
expect(keys).toHaveLength(1);
|
||||
expect(keys[0]).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps a stale preview inspectable and points to validation as the exact next action", async () => {
|
||||
renderStudio(<PublicPreviewScreen documentId={FIXTURE_IDS.fetchJoinCase} />);
|
||||
|
||||
expect(await screen.findByText("저장본보다 이전에 만든 Public Preview입니다.")).toBeVisible();
|
||||
expect(screen.getByText("STALE")).toBeVisible();
|
||||
expect(screen.getByRole("heading", { level: 1, name: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가" })).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "검증 화면으로 이동" })).toHaveAttribute(
|
||||
"href",
|
||||
`/studio/documents/${FIXTURE_IDS.fetchJoinCase}/validation`,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Public Preview 다시 만들기" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps an expired preview inspectable and blocks recreation until validation", async () => {
|
||||
renderStudio(<PublicPreviewScreen documentId={FIXTURE_IDS.expiredPreviewCase} />);
|
||||
|
||||
expect(await screen.findByText("이 Public Preview는 만료되었습니다.")).toBeVisible();
|
||||
expect(screen.getByText("EXPIRED")).toBeVisible();
|
||||
expect(screen.getByRole("heading", { level: 1, name: "만료 미리보기 예시" })).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "검증 화면으로 이동" })).toHaveAttribute(
|
||||
"href",
|
||||
`/studio/documents/${FIXTURE_IDS.expiredPreviewCase}/validation`,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows retryable preview-load errors and retries the read", async () => {
|
||||
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const getDocument = vi
|
||||
.fn<StudioGateway["getDocument"]>()
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockImplementation((id, options) => base.getDocument(id, options));
|
||||
renderStudio(
|
||||
<PublicPreviewScreen documentId={FIXTURE_IDS.stateNonceReference} />,
|
||||
{ ...base, getDocument },
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Public Preview를 불러오지 못했습니다offline",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
expect(await screen.findByText("아직 만든 Public Preview가 없습니다")).toBeVisible();
|
||||
expect(getDocument).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("blocks an expired validation until validation runs again", async () => {
|
||||
renderStudio(<ValidationScreen documentId={FIXTURE_IDS.expiredPreviewCase} />);
|
||||
|
||||
expect(await screen.findByText("저장 후 다시 검증해야 합니다")).toBeVisible();
|
||||
expect(screen.queryByRole("link", { name: "Public Preview 만들기" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the source not-found state for unknown document ids", async () => {
|
||||
renderStudio(
|
||||
<ValidationScreen documentId="99999999-9999-4999-8999-999999999999" />,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "작업본을 찾을 수 없습니다" })).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "작업본으로 돌아가기" })).toHaveAttribute(
|
||||
"href",
|
||||
"/studio/documents",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user