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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
|
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
|
||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
@@ -7,7 +6,9 @@ import type {
|
|||||||
WorkingCopy,
|
WorkingCopy,
|
||||||
WorkingCopyInput,
|
WorkingCopyInput,
|
||||||
} from "../../../contracts/studio/contract.ts";
|
} from "../../../contracts/studio/contract.ts";
|
||||||
|
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||||
import { DocumentEditor, type DocumentEditorController } from "./document-editor.tsx";
|
import { DocumentEditor, type DocumentEditorController } from "./document-editor.tsx";
|
||||||
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
||||||
|
|
||||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
@@ -24,7 +25,7 @@ function inputOf(document: WorkingCopy): WorkingCopyInput {
|
|||||||
export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||||
const studio = useStudio();
|
const studio = useStudio();
|
||||||
const session = useStudioEditorSession();
|
const session = useStudioEditorSession();
|
||||||
const { begin, clear, editor, updateDraft } = session;
|
const { begin, clear, editor, setStatus, updateDraft } = session;
|
||||||
const [retryKey, setRetryKey] = useState(0);
|
const [retryKey, setRetryKey] = useState(0);
|
||||||
const requestKey = `${documentId}:${retryKey}`;
|
const requestKey = `${documentId}:${retryKey}`;
|
||||||
const [result, setResult] = useState<{
|
const [result, setResult] = useState<{
|
||||||
@@ -66,6 +67,40 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
|||||||
return () => request.abort();
|
return () => request.abort();
|
||||||
}, [begin, clear, documentId, requestKey, studio.gateway]);
|
}, [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>(() => {
|
const controller = useMemo<DocumentEditorController | null>(() => {
|
||||||
if (!editor || editor.documentId !== documentId) return null;
|
if (!editor || editor.documentId !== documentId) return null;
|
||||||
return {
|
return {
|
||||||
@@ -78,8 +113,9 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
|||||||
replace(draft) {
|
replace(draft) {
|
||||||
if (draft.kind === editor.draft.kind) updateDraft(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 currentResult = result?.key === requestKey ? result : null;
|
||||||
const problem = currentResult?.problem ?? null;
|
const problem = currentResult?.problem ?? null;
|
||||||
@@ -92,7 +128,9 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
|||||||
<p className="studio-eyebrow">404</p>
|
<p className="studio-eyebrow">404</p>
|
||||||
<h1>Studio 화면을 찾을 수 없습니다</h1>
|
<h1>Studio 화면을 찾을 수 없습니다</h1>
|
||||||
<p>이 작업본은 현재 Studio 세션에 없습니다.</p>
|
<p>이 작업본은 현재 Studio 세션에 없습니다.</p>
|
||||||
<Link to="/studio/documents">작업본으로 돌아가기</Link>
|
<GuardedStudioLink href="/studio/documents">
|
||||||
|
작업본으로 돌아가기
|
||||||
|
</GuardedStudioLink>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export type DocumentEditorController = {
|
|||||||
status: StudioEditorStatus;
|
status: StudioEditorStatus;
|
||||||
update(patch: Partial<WorkingCopyInput>): void;
|
update(patch: Partial<WorkingCopyInput>): void;
|
||||||
replace(draft: WorkingCopyInput): void;
|
replace(draft: WorkingCopyInput): void;
|
||||||
save?: () => Promise<void>;
|
save(): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DocumentEditor({ controller, catalog }: { controller: DocumentEditorController; catalog: CatalogEntry[] }) {
|
export function DocumentEditor({ controller, catalog }: { controller: DocumentEditorController; catalog: CatalogEntry[] }) {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState, type FormEvent } from "react";
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import type { ListDocumentsQuery } from "../../../application/ports/studio-gateway.ts";
|
import type { ListDocumentsQuery } from "../../../application/ports/studio-gateway.ts";
|
||||||
import type { DocumentPage } from "../../../contracts/studio/contract.ts";
|
import type { DocumentPage } from "../../../contracts/studio/contract.ts";
|
||||||
import { useStudio } from "../use-studio.ts";
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
|
||||||
const kindLabel = {
|
const kindLabel = {
|
||||||
CASE: "Case",
|
CASE: "Case",
|
||||||
@@ -94,9 +94,9 @@ export function DocumentList() {
|
|||||||
세션에 있는 Case, Reference, Question을 찾고 다음 작업으로 이동합니다.
|
세션에 있는 Case, Reference, Question을 찾고 다음 작업으로 이동합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link className="studio-primary-action" to="/studio/documents/new">
|
<GuardedStudioLink className="studio-primary-action" href="/studio/documents/new">
|
||||||
새 문서
|
새 문서
|
||||||
</Link>
|
</GuardedStudioLink>
|
||||||
</header>
|
</header>
|
||||||
<section className="studio-document-tools" aria-label="작업본 검색과 필터">
|
<section className="studio-document-tools" aria-label="작업본 검색과 필터">
|
||||||
<form role="search" onSubmit={submitSearch}>
|
<form role="search" onSubmit={submitSearch}>
|
||||||
@@ -174,9 +174,9 @@ export function DocumentList() {
|
|||||||
<p className="studio-row-label">{kindLabel[item.kind]}</p>
|
<p className="studio-row-label">{kindLabel[item.kind]}</p>
|
||||||
<div className="studio-document-title">
|
<div className="studio-document-title">
|
||||||
<h2>
|
<h2>
|
||||||
<Link to={`/studio/documents/${item.id}/edit`}>
|
<GuardedStudioLink href={`/studio/documents/${item.id}/edit`}>
|
||||||
{item.title || "제목 없는 작업본"}
|
{item.title || "제목 없는 작업본"}
|
||||||
</Link>
|
</GuardedStudioLink>
|
||||||
</h2>
|
</h2>
|
||||||
<p>{item.project?.label ?? "프로젝트 미지정"}</p>
|
<p>{item.project?.label ?? "프로젝트 미지정"}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,7 +207,9 @@ export function DocumentList() {
|
|||||||
<section className="studio-empty-state">
|
<section className="studio-empty-state">
|
||||||
<h2>조건에 맞는 작업본이 없습니다</h2>
|
<h2>조건에 맞는 작업본이 없습니다</h2>
|
||||||
<p>검색어 또는 필터를 바꾸거나 새 문서를 만드세요.</p>
|
<p>검색어 또는 필터를 바꾸거나 새 문서를 만드세요.</p>
|
||||||
<Link to="/studio/documents/new">새 문서 만들기</Link>
|
<GuardedStudioLink href="/studio/documents/new">
|
||||||
|
새 문서 만들기
|
||||||
|
</GuardedStudioLink>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{page.nextCursor ? (
|
{page.nextCursor ? (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import type { DocumentEditorController } from "./document-editor.tsx";
|
import type { DocumentEditorController } from "./document-editor.tsx";
|
||||||
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
|
||||||
const labels = {
|
const labels = {
|
||||||
CLEAN: "저장됨",
|
CLEAN: "저장됨",
|
||||||
@@ -16,8 +15,8 @@ export function DocumentStatusRail({ controller }: { controller: DocumentEditorC
|
|||||||
<h2 id="studio-document-status-title">작업 상태</h2>
|
<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>
|
<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>
|
<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>
|
<button type="button" onClick={() => { void controller.save(); }} disabled={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>
|
<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>}
|
{controller.status === "CONFLICT" ? <p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p> : <p>불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.</p>}
|
||||||
</aside>
|
</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 { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import type { StudioDashboard as StudioDashboardData } from "../../../contracts/studio/contract.ts";
|
import type { StudioDashboard as StudioDashboardData } from "../../../contracts/studio/contract.ts";
|
||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import { useStudio } from "../use-studio.ts";
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
|
||||||
type DocumentSummary = components["schemas"]["DocumentSummary"];
|
type DocumentSummary = components["schemas"]["DocumentSummary"];
|
||||||
|
|
||||||
@@ -27,9 +27,9 @@ function WorkRow({ item }: Readonly<{ item: DocumentSummary }>) {
|
|||||||
<p className="studio-row-label">{kindLabel[item.kind]}</p>
|
<p className="studio-row-label">{kindLabel[item.kind]}</p>
|
||||||
<div>
|
<div>
|
||||||
<h3>
|
<h3>
|
||||||
<Link to={`/studio/documents/${item.id}/edit`}>
|
<GuardedStudioLink href={`/studio/documents/${item.id}/edit`}>
|
||||||
{item.title || "제목 없는 작업본"}
|
{item.title || "제목 없는 작업본"}
|
||||||
</Link>
|
</GuardedStudioLink>
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
{item.project?.label ?? "프로젝트 미지정"} · {actionLabel[item.nextAction]}
|
{item.project?.label ?? "프로젝트 미지정"} · {actionLabel[item.nextAction]}
|
||||||
@@ -59,7 +59,7 @@ function WorkSection({
|
|||||||
<section className="studio-work-section">
|
<section className="studio-work-section">
|
||||||
<div className="studio-section-title">
|
<div className="studio-section-title">
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
<Link to={href}>전체 보기</Link>
|
<GuardedStudioLink href={href}>전체 보기</GuardedStudioLink>
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-work-list">
|
<div className="studio-work-list">
|
||||||
{items.length ? (
|
{items.length ? (
|
||||||
@@ -105,9 +105,9 @@ export function StudioDashboard() {
|
|||||||
<h1>작업 흐름</h1>
|
<h1>작업 흐름</h1>
|
||||||
<p>작성 중인 기록을 이어서 정리하고 검증·게시 흐름으로 연결합니다.</p>
|
<p>작성 중인 기록을 이어서 정리하고 검증·게시 흐름으로 연결합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<Link className="studio-primary-action" to="/studio/documents/new">
|
<GuardedStudioLink className="studio-primary-action" href="/studio/documents/new">
|
||||||
새 문서
|
새 문서
|
||||||
</Link>
|
</GuardedStudioLink>
|
||||||
</header>
|
</header>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="studio-screen-error" role="alert">
|
<p className="studio-screen-error" role="alert">
|
||||||
@@ -160,7 +160,9 @@ export function StudioDashboard() {
|
|||||||
<section className="studio-work-section">
|
<section className="studio-work-section">
|
||||||
<div className="studio-section-title">
|
<div className="studio-section-title">
|
||||||
<h2>최근 게시</h2>
|
<h2>최근 게시</h2>
|
||||||
<Link to="/studio/publications">게시 기록 보기</Link>
|
<GuardedStudioLink href="/studio/publications">
|
||||||
|
게시 기록 보기
|
||||||
|
</GuardedStudioLink>
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-work-list">
|
<div className="studio-work-list">
|
||||||
{dashboard.recentPublications.length ? (
|
{dashboard.recentPublications.length ? (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{
|
{
|
||||||
@@ -27,13 +28,13 @@ function StudioNavigation({
|
|||||||
return (
|
return (
|
||||||
<nav aria-label={label}>
|
<nav aria-label={label}>
|
||||||
{navigation.map((item) => (
|
{navigation.map((item) => (
|
||||||
<Link
|
<GuardedStudioLink
|
||||||
key={item.href}
|
key={item.href}
|
||||||
to={item.href}
|
href={item.href}
|
||||||
aria-current={item.active(currentPath) ? "page" : undefined}
|
aria-current={item.active(currentPath) ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</GuardedStudioLink>
|
||||||
))}
|
))}
|
||||||
<a href="/">공개 사이트 보기</a>
|
<a href="/">공개 사이트 보기</a>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -46,13 +47,13 @@ export function StudioHeader({ currentPath }: Readonly<{ currentPath: string }>)
|
|||||||
return (
|
return (
|
||||||
<header className="studio-header">
|
<header className="studio-header">
|
||||||
<div className="studio-header-inner">
|
<div className="studio-header-inner">
|
||||||
<Link
|
<GuardedStudioLink
|
||||||
className="studio-wordmark"
|
className="studio-wordmark"
|
||||||
to="/studio"
|
href="/studio"
|
||||||
aria-label="TechLog Studio"
|
aria-label="TechLog Studio"
|
||||||
>
|
>
|
||||||
TechLog <span>Studio</span>
|
TechLog <span>Studio</span>
|
||||||
</Link>
|
</GuardedStudioLink>
|
||||||
<div className="studio-desktop-navigation">
|
<div className="studio-desktop-navigation">
|
||||||
<StudioNavigation currentPath={currentPath} label="Studio 주 탐색" />
|
<StudioNavigation currentPath={currentPath} label="Studio 주 탐색" />
|
||||||
</div>
|
</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() {
|
export function StudioNotFoundPage() {
|
||||||
return (
|
return (
|
||||||
@@ -6,7 +6,9 @@ export function StudioNotFoundPage() {
|
|||||||
<p className="studio-eyebrow">404</p>
|
<p className="studio-eyebrow">404</p>
|
||||||
<h1>Studio 화면을 찾을 수 없습니다</h1>
|
<h1>Studio 화면을 찾을 수 없습니다</h1>
|
||||||
<p>주소를 확인하거나 작업본 목록에서 다시 시작하세요.</p>
|
<p>주소를 확인하거나 작업본 목록에서 다시 시작하세요.</p>
|
||||||
<Link to="/studio/documents">작업본으로 돌아가기</Link>
|
<GuardedStudioLink href="/studio/documents">
|
||||||
|
작업본으로 돌아가기
|
||||||
|
</GuardedStudioLink>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
|
import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
||||||
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||||
import type {
|
import type {
|
||||||
WorkingCopy,
|
WorkingCopy,
|
||||||
@@ -16,13 +18,24 @@ import {
|
|||||||
type StudioEditorState,
|
type StudioEditorState,
|
||||||
type StudioEditorStatus,
|
type StudioEditorStatus,
|
||||||
} from "./use-studio.ts";
|
} from "./use-studio.ts";
|
||||||
|
import { UnsavedLeaveDialog } from "./components/unsaved-leave-dialog.tsx";
|
||||||
|
import { useBeforeUnload } from "./components/use-before-unload.ts";
|
||||||
|
|
||||||
type StudioProviderProps = Readonly<{
|
type StudioProviderProps = Readonly<{
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
createGateway: () => StudioGateway;
|
createGateway: () => StudioGateway;
|
||||||
|
now?: () => Date;
|
||||||
navigate?: (href: string) => void;
|
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 {
|
function defaultNavigate(href: string): void {
|
||||||
window.history.pushState({}, "", href);
|
window.history.pushState({}, "", href);
|
||||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||||
@@ -31,12 +44,31 @@ function defaultNavigate(href: string): void {
|
|||||||
export function StudioProvider({
|
export function StudioProvider({
|
||||||
children,
|
children,
|
||||||
createGateway,
|
createGateway,
|
||||||
|
now = () => new Date("2026-08-14T01:00:00.000Z"),
|
||||||
navigate = defaultNavigate,
|
navigate = defaultNavigate,
|
||||||
}: StudioProviderProps) {
|
}: StudioProviderProps) {
|
||||||
const [gateway] = useState<StudioGateway>(() => createGateway());
|
const [gateway] = useState<StudioGateway>(() => createGateway());
|
||||||
const [editor, setEditor] = useState<StudioEditorState | null>(null);
|
const [editor, setEditor] = useState<StudioEditorState | null>(null);
|
||||||
|
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
||||||
const [requestAnnouncement, setRequestAnnouncement] = useState("");
|
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) => {
|
const beginEditor = useCallback((saved: WorkingCopy, draft: WorkingCopyInput) => {
|
||||||
setEditor({ documentId: saved.id, saved, draft, status: "CLEAN" });
|
setEditor({ documentId: saved.id, saved, draft, status: "CLEAN" });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -47,9 +79,71 @@ export function StudioProvider({
|
|||||||
setEditor((current) => current ? { ...current, status } : current);
|
setEditor((current) => current ? { ...current, status } : current);
|
||||||
}, []);
|
}, []);
|
||||||
const clearEditor = useCallback(() => setEditor(null), []);
|
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>(
|
const value = useMemo<StudioContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
gateway,
|
gateway,
|
||||||
|
now,
|
||||||
editor,
|
editor,
|
||||||
requestAnnouncement,
|
requestAnnouncement,
|
||||||
setRequestAnnouncement,
|
setRequestAnnouncement,
|
||||||
@@ -65,6 +159,7 @@ export function StudioProvider({
|
|||||||
editor,
|
editor,
|
||||||
gateway,
|
gateway,
|
||||||
navigateInternal,
|
navigateInternal,
|
||||||
|
now,
|
||||||
requestAnnouncement,
|
requestAnnouncement,
|
||||||
setEditorStatus,
|
setEditorStatus,
|
||||||
updateEditorDraft,
|
updateEditorDraft,
|
||||||
@@ -73,7 +168,17 @@ export function StudioProvider({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<StudioContext.Provider value={value}>
|
<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>
|
</StudioContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ export type StudioEditorState = Readonly<{
|
|||||||
|
|
||||||
export type StudioContextValue = Readonly<{
|
export type StudioContextValue = Readonly<{
|
||||||
gateway: StudioGateway;
|
gateway: StudioGateway;
|
||||||
|
now(): Date;
|
||||||
editor: StudioEditorState | null;
|
editor: StudioEditorState | null;
|
||||||
requestAnnouncement: string;
|
requestAnnouncement: string;
|
||||||
setRequestAnnouncement(message: string): void;
|
setRequestAnnouncement(message: string): void;
|
||||||
navigateInternal(href: string): void;
|
navigateInternal(href: string, trigger?: HTMLElement | null): void;
|
||||||
beginEditor(saved: WorkingCopy, draft: WorkingCopyInput): void;
|
beginEditor(saved: WorkingCopy, draft: WorkingCopyInput): void;
|
||||||
updateEditorDraft(draft: WorkingCopyInput): void;
|
updateEditorDraft(draft: WorkingCopyInput): void;
|
||||||
setEditorStatus(status: StudioEditorStatus): 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