feat: port TechLog Studio shell and indexes
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
# Task 10 report
|
||||||
|
|
||||||
|
## Mapping
|
||||||
|
|
||||||
|
- Source Studio provider/runtime shell and header → application-input-created, provider-scoped gateway; React Router navigation; native Public link; persisted `pageshow` generation reset.
|
||||||
|
- Source dashboard → exact workspace heading, totals, workflow sections, row labels, links, loading and error copy.
|
||||||
|
- Source document list → exact search/filter/list/empty surfaces plus cursor pagination, retry, and abort of obsolete requests.
|
||||||
|
- Source new-document form → exact type cards/copy, session gateway creation, announcement, and editor redirect.
|
||||||
|
- Source Studio not-found → in-shell 404 surface; Studio routes remain public with no auth UI.
|
||||||
|
|
||||||
|
## TDD evidence
|
||||||
|
|
||||||
|
- RED: `corepack pnpm exec vitest run tests/features/tech-log/studio-shell-smoke.test.tsx tests/features/tech-log/studio-screens-smoke.test.tsx` failed both suites at missing Studio presentation imports (exit 1).
|
||||||
|
- GREEN: the same command passed 2 files / 8 tests.
|
||||||
|
- Focused regression: both Studio suites plus `tests/features/tech-log/runtime-composition.test.ts` passed 3 files / 10 tests.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Added the 12 Task 10 Studio provider/runtime/shell/component/page files under `src/features/tech-log/presentation/studio/`.
|
||||||
|
- Added `studio-shell-smoke.test.tsx` and `studio-screens-smoke.test.tsx`.
|
||||||
|
|
||||||
|
## SHA
|
||||||
|
|
||||||
|
- Base: `2b6fa42620136c3edb1506f907ce79c2251d1316`.
|
||||||
|
- Implementation: the commit containing this report, titled `feat: port TechLog Studio shell and indexes` (final SHA recorded in the Task 10 handoff).
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- Task 11 editor screens and Task 12 dirty-leave/save/validation dialogs remain intentionally deferred.
|
||||||
|
- Broad architecture, type, lint, build, security, and visual gates remain deferred to Task 14 by user direction.
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
const kindLabel = {
|
||||||
|
CASE: "Case",
|
||||||
|
REFERENCE: "Reference",
|
||||||
|
QUESTION: "Question",
|
||||||
|
} as const;
|
||||||
|
const publicationLabel = {
|
||||||
|
NEVER_PUBLISHED: "게시 전",
|
||||||
|
PUBLISHED: "게시 중",
|
||||||
|
UNPUBLISHED: "게시 취소",
|
||||||
|
} as const;
|
||||||
|
const nextLabel = {
|
||||||
|
CONTINUE_EDITING: "작성 계속",
|
||||||
|
VALIDATE: "검증하기",
|
||||||
|
FIX_VALIDATION: "오류 수정",
|
||||||
|
CREATE_PREVIEW: "미리보기",
|
||||||
|
PUBLISH: "게시하기",
|
||||||
|
NONE: "완료",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return error instanceof DOMException && error.name === "AbortError";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentList() {
|
||||||
|
const { gateway } = useStudio();
|
||||||
|
const [searchDraft, setSearchDraft] = useState("");
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [kind, setKind] = useState<ListDocumentsQuery["kind"]>();
|
||||||
|
const [status, setStatus] =
|
||||||
|
useState<ListDocumentsQuery["publicationStatus"]>();
|
||||||
|
const [cursor, setCursor] = useState<string>();
|
||||||
|
const [page, setPage] = useState<DocumentPage | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [retryGeneration, setRetryGeneration] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
void gateway
|
||||||
|
.listDocuments(
|
||||||
|
{
|
||||||
|
...(q ? { q } : {}),
|
||||||
|
...(kind ? { kind } : {}),
|
||||||
|
...(status ? { publicationStatus: status } : {}),
|
||||||
|
...(cursor ? { cursor } : {}),
|
||||||
|
limit: 20,
|
||||||
|
},
|
||||||
|
{ signal: controller.signal },
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
(value) => {
|
||||||
|
setPage(value);
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
(reason: unknown) => {
|
||||||
|
if (isAbortError(reason)) return;
|
||||||
|
setError("작업본을 불러오지 못했습니다.");
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [cursor, gateway, kind, q, retryGeneration, status]);
|
||||||
|
|
||||||
|
const changeFilter = (next: {
|
||||||
|
kind?: ListDocumentsQuery["kind"];
|
||||||
|
status?: ListDocumentsQuery["publicationStatus"];
|
||||||
|
}) => {
|
||||||
|
if ("kind" in next) setKind(next.kind);
|
||||||
|
if ("status" in next) setStatus(next.status);
|
||||||
|
setCursor(undefined);
|
||||||
|
};
|
||||||
|
const submitSearch = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setQ(searchDraft.trim());
|
||||||
|
setCursor(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="studio-page studio-documents-page">
|
||||||
|
<header className="studio-page-top">
|
||||||
|
<div className="studio-page-heading">
|
||||||
|
<p className="studio-eyebrow">WORKING COPIES</p>
|
||||||
|
<h1>작업본</h1>
|
||||||
|
<p>
|
||||||
|
세션에 있는 Case, Reference, Question을 찾고 다음 작업으로 이동합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link className="studio-primary-action" to="/studio/documents/new">
|
||||||
|
새 문서
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
<section className="studio-document-tools" aria-label="작업본 검색과 필터">
|
||||||
|
<form role="search" onSubmit={submitSearch}>
|
||||||
|
<label htmlFor="studio-search">검색</label>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
id="studio-search"
|
||||||
|
type="search"
|
||||||
|
value={searchDraft}
|
||||||
|
placeholder="제목, 요약, slug"
|
||||||
|
onChange={(event) => setSearchDraft(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit">검색</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<label>
|
||||||
|
종류
|
||||||
|
<select
|
||||||
|
value={kind ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
changeFilter({
|
||||||
|
kind: (event.target.value || undefined) as ListDocumentsQuery["kind"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="CASE">Case</option>
|
||||||
|
<option value="REFERENCE">Reference</option>
|
||||||
|
<option value="QUESTION">Question</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
상태
|
||||||
|
<select
|
||||||
|
value={status ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
changeFilter({
|
||||||
|
status: (event.target.value || undefined) as ListDocumentsQuery["publicationStatus"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="NEVER_PUBLISHED">게시 전</option>
|
||||||
|
<option value="PUBLISHED">게시 중</option>
|
||||||
|
<option value="UNPUBLISHED">게시 취소</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
{loading ? (
|
||||||
|
<p className="studio-loading" role="status">
|
||||||
|
작업본을 불러오는 중입니다.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<>
|
||||||
|
<p className="studio-screen-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="studio-secondary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRetryGeneration((current) => current + 1)}
|
||||||
|
>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{page && !loading && !error ? (
|
||||||
|
<>
|
||||||
|
<p className="studio-result-count">{page.items.length}개의 작업본</p>
|
||||||
|
{page.items.length ? (
|
||||||
|
<div className="studio-document-list">
|
||||||
|
{page.items.map((item) => (
|
||||||
|
<article className="studio-document-row" key={item.id}>
|
||||||
|
<p className="studio-row-label">{kindLabel[item.kind]}</p>
|
||||||
|
<div className="studio-document-title">
|
||||||
|
<h2>
|
||||||
|
<Link to={`/studio/documents/${item.id}/edit`}>
|
||||||
|
{item.title || "제목 없는 작업본"}
|
||||||
|
</Link>
|
||||||
|
</h2>
|
||||||
|
<p>{item.project?.label ?? "프로젝트 미지정"}</p>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>상태</dt>
|
||||||
|
<dd>{publicationLabel[item.publicationStatus]}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>다음</dt>
|
||||||
|
<dd>{nextLabel[item.nextAction]}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>수정</dt>
|
||||||
|
<dd>
|
||||||
|
<time dateTime={item.updatedAt}>
|
||||||
|
{new Date(item.updatedAt).toLocaleDateString("ko-KR", {
|
||||||
|
timeZone: "Asia/Seoul",
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<section className="studio-empty-state">
|
||||||
|
<h2>조건에 맞는 작업본이 없습니다</h2>
|
||||||
|
<p>검색어 또는 필터를 바꾸거나 새 문서를 만드세요.</p>
|
||||||
|
<Link to="/studio/documents/new">새 문서 만들기</Link>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{page.nextCursor ? (
|
||||||
|
<button
|
||||||
|
className="studio-secondary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCursor(page.nextCursor ?? undefined)}
|
||||||
|
>
|
||||||
|
다음 작업본
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CreateDocumentInput,
|
||||||
|
RecordKind,
|
||||||
|
} from "../../../contracts/studio/contract.ts";
|
||||||
|
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||||
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
|
||||||
|
const types = [
|
||||||
|
{
|
||||||
|
kind: "CASE",
|
||||||
|
title: "Case",
|
||||||
|
description: "문제를 재현하고 검증한 결론을 기록합니다.",
|
||||||
|
fields: "문제 · 결론 · 환경 · 재현 · 본문",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "REFERENCE",
|
||||||
|
title: "Reference",
|
||||||
|
description: "반복해서 적용할 기술 기준을 정리합니다.",
|
||||||
|
fields: "목적 · 규칙 · 적용 조건 · 예외 · 예시",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "QUESTION",
|
||||||
|
title: "Question",
|
||||||
|
description: "아직 닫히지 않은 판단과 다음 검증을 관리합니다.",
|
||||||
|
fields: "상태 · 사실 · 가정 · 미지수 · 선택지",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function emptyDocument(kind: RecordKind): CreateDocumentInput {
|
||||||
|
const common = {
|
||||||
|
title: "",
|
||||||
|
slug: "" as const,
|
||||||
|
summary: "",
|
||||||
|
topicId: null,
|
||||||
|
projectId: null,
|
||||||
|
relations: [],
|
||||||
|
};
|
||||||
|
if (kind === "CASE") {
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
kind,
|
||||||
|
problem: "",
|
||||||
|
conclusion: "",
|
||||||
|
environment: "",
|
||||||
|
reproduction: "",
|
||||||
|
lastVerifiedOn: null,
|
||||||
|
bodyMarkdown: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (kind === "REFERENCE") {
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
kind,
|
||||||
|
purpose: "",
|
||||||
|
rules: [],
|
||||||
|
applyWhen: [],
|
||||||
|
exceptions: [],
|
||||||
|
examples: [],
|
||||||
|
verifiedOn: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
kind,
|
||||||
|
questionStatus: null,
|
||||||
|
facts: [],
|
||||||
|
assumptions: [],
|
||||||
|
unknowns: [],
|
||||||
|
constraints: [],
|
||||||
|
options: [],
|
||||||
|
nextValidation: "",
|
||||||
|
resolution: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return error instanceof DOMException && error.name === "AbortError";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewDocumentForm() {
|
||||||
|
const { gateway, navigateInternal, setRequestAnnouncement } = useStudio();
|
||||||
|
const [kind, setKind] = useState<RecordKind>("CASE");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const locked = useRef(false);
|
||||||
|
const activeRequest = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => () => activeRequest.current?.abort(), []);
|
||||||
|
|
||||||
|
const submit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (locked.current) return;
|
||||||
|
locked.current = true;
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeRequest.current = controller;
|
||||||
|
try {
|
||||||
|
const document = await gateway.createDocument(emptyDocument(kind), {
|
||||||
|
idempotencyKey: createLocalId("studio-create"),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
setRequestAnnouncement(
|
||||||
|
`${types.find((type) => type.kind === kind)?.title} 작업본을 만들었습니다.`,
|
||||||
|
);
|
||||||
|
navigateInternal(`/studio/documents/${document.id}/edit`);
|
||||||
|
} catch (reason) {
|
||||||
|
if (!isAbortError(reason)) {
|
||||||
|
setError("작업본을 만들지 못했습니다. 다시 시도해 주세요.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (activeRequest.current === controller) activeRequest.current = null;
|
||||||
|
locked.current = false;
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="studio-page studio-new-page">
|
||||||
|
<header className="studio-page-heading">
|
||||||
|
<p className="studio-eyebrow">NEW WORKING COPY</p>
|
||||||
|
<h1>새 문서</h1>
|
||||||
|
<p>
|
||||||
|
목적에 맞는 기록 종류를 선택하면 빈 작업본을 만들고 바로 편집을 시작합니다.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
void submit(event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<fieldset className="studio-type-list">
|
||||||
|
<legend>문서 종류</legend>
|
||||||
|
{types.map((type) => (
|
||||||
|
<label key={type.kind}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="kind"
|
||||||
|
value={type.kind}
|
||||||
|
checked={kind === type.kind}
|
||||||
|
onChange={() => setKind(type.kind)}
|
||||||
|
/>
|
||||||
|
<strong>{type.title}</strong>
|
||||||
|
<span>{type.description}</span>
|
||||||
|
<small>{type.fields}</small>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
<div className="studio-create-footer">
|
||||||
|
<button className="studio-primary-button" type="submit" disabled={pending}>
|
||||||
|
{pending ? "만드는 중…" : "작업본 만들기"}
|
||||||
|
</button>
|
||||||
|
<p>이 화면의 작업본은 현재 Studio 세션에서만 유지됩니다.</p>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p className="studio-screen-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
type DocumentSummary = components["schemas"]["DocumentSummary"];
|
||||||
|
|
||||||
|
const kindLabel = {
|
||||||
|
CASE: "Case",
|
||||||
|
REFERENCE: "Reference",
|
||||||
|
QUESTION: "Question",
|
||||||
|
} as const;
|
||||||
|
const actionLabel = {
|
||||||
|
CONTINUE_EDITING: "작성 계속",
|
||||||
|
VALIDATE: "검증하기",
|
||||||
|
FIX_VALIDATION: "오류 수정",
|
||||||
|
CREATE_PREVIEW: "미리보기 만들기",
|
||||||
|
PUBLISH: "게시하기",
|
||||||
|
NONE: "게시 완료",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function WorkRow({ item }: Readonly<{ item: DocumentSummary }>) {
|
||||||
|
return (
|
||||||
|
<article className="studio-work-row">
|
||||||
|
<p className="studio-row-label">{kindLabel[item.kind]}</p>
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
<Link to={`/studio/documents/${item.id}/edit`}>
|
||||||
|
{item.title || "제목 없는 작업본"}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
{item.project?.label ?? "프로젝트 미지정"} · {actionLabel[item.nextAction]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<time dateTime={item.updatedAt}>
|
||||||
|
{new Date(item.updatedAt).toLocaleDateString("ko-KR", {
|
||||||
|
timeZone: "Asia/Seoul",
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkSection({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
href,
|
||||||
|
empty,
|
||||||
|
}: Readonly<{
|
||||||
|
title: string;
|
||||||
|
items: DocumentSummary[];
|
||||||
|
href: string;
|
||||||
|
empty: string;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<section className="studio-work-section">
|
||||||
|
<div className="studio-section-title">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<Link to={href}>전체 보기</Link>
|
||||||
|
</div>
|
||||||
|
<div className="studio-work-list">
|
||||||
|
{items.length ? (
|
||||||
|
items.map((item) => <WorkRow key={item.id} item={item} />)
|
||||||
|
) : (
|
||||||
|
<p className="studio-empty-inline">{empty}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return error instanceof DOMException && error.name === "AbortError";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StudioDashboard() {
|
||||||
|
const { gateway } = useStudio();
|
||||||
|
const [dashboard, setDashboard] = useState<StudioDashboardData | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
void gateway.getDashboard({ signal: controller.signal }).then(
|
||||||
|
(value) => setDashboard(value),
|
||||||
|
(reason: unknown) => {
|
||||||
|
if (!isAbortError(reason)) setError("작업 흐름을 불러오지 못했습니다.");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [gateway]);
|
||||||
|
|
||||||
|
const validationItems =
|
||||||
|
dashboard?.continueWriting.filter((item) =>
|
||||||
|
["VALIDATE", "FIX_VALIDATION", "CREATE_PREVIEW"].includes(item.nextAction),
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="studio-page studio-dashboard-page">
|
||||||
|
<header className="studio-page-top">
|
||||||
|
<div className="studio-page-heading">
|
||||||
|
<p className="studio-eyebrow">WORKSPACE</p>
|
||||||
|
<h1>작업 흐름</h1>
|
||||||
|
<p>작성 중인 기록을 이어서 정리하고 검증·게시 흐름으로 연결합니다.</p>
|
||||||
|
</div>
|
||||||
|
<Link className="studio-primary-action" to="/studio/documents/new">
|
||||||
|
새 문서
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
{error ? (
|
||||||
|
<p className="studio-screen-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{!dashboard && !error ? (
|
||||||
|
<p className="studio-loading" role="status">
|
||||||
|
Studio 요약을 불러오는 중입니다.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{dashboard ? (
|
||||||
|
<>
|
||||||
|
<section className="studio-summary-grid" aria-label="Studio 요약">
|
||||||
|
<div>
|
||||||
|
<span>전체 작업본</span>
|
||||||
|
<strong>{dashboard.totals.documents}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>검증할 기록</span>
|
||||||
|
<strong>{validationItems.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>게시 준비</span>
|
||||||
|
<strong>{dashboard.totals.readyToPublish}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>게시 기록</span>
|
||||||
|
<strong>{dashboard.totals.publications}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<WorkSection
|
||||||
|
title="이어서 작성"
|
||||||
|
items={dashboard.continueWriting}
|
||||||
|
href="/studio/documents"
|
||||||
|
empty="이어서 작성할 문서가 없습니다."
|
||||||
|
/>
|
||||||
|
<WorkSection
|
||||||
|
title="검증과 미리보기"
|
||||||
|
items={validationItems}
|
||||||
|
href="/studio/documents"
|
||||||
|
empty="현재 검증할 문서가 없습니다."
|
||||||
|
/>
|
||||||
|
<WorkSection
|
||||||
|
title="게시 준비"
|
||||||
|
items={dashboard.readyToPublish}
|
||||||
|
href="/studio/documents"
|
||||||
|
empty="게시 준비가 끝난 문서가 없습니다."
|
||||||
|
/>
|
||||||
|
<section className="studio-work-section">
|
||||||
|
<div className="studio-section-title">
|
||||||
|
<h2>최근 게시</h2>
|
||||||
|
<Link to="/studio/publications">게시 기록 보기</Link>
|
||||||
|
</div>
|
||||||
|
<div className="studio-work-list">
|
||||||
|
{dashboard.recentPublications.length ? (
|
||||||
|
dashboard.recentPublications.map((item) => (
|
||||||
|
<article
|
||||||
|
className="studio-work-row"
|
||||||
|
key={item.event.publicationEventId}
|
||||||
|
>
|
||||||
|
<p className="studio-row-label">
|
||||||
|
{item.event.type === "UNPUBLISHED"
|
||||||
|
? "게시 취소"
|
||||||
|
: item.event.type === "REPUBLISHED"
|
||||||
|
? "재게시"
|
||||||
|
: "게시"}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<h3>{item.document.title}</h3>
|
||||||
|
<p>{item.document.project?.label ?? "프로젝트 미지정"}</p>
|
||||||
|
</div>
|
||||||
|
<time dateTime={item.event.occurredAt}>
|
||||||
|
{new Date(item.event.occurredAt).toLocaleDateString("ko-KR", {
|
||||||
|
timeZone: "Asia/Seoul",
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="studio-empty-inline">아직 게시 기록이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useId, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{
|
||||||
|
href: "/studio/documents",
|
||||||
|
label: "작업본",
|
||||||
|
active: (path: string) =>
|
||||||
|
path.startsWith("/studio/documents") && path !== "/studio/documents/new",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/studio/publications",
|
||||||
|
label: "게시 기록",
|
||||||
|
active: (path: string) => path.startsWith("/studio/publications"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/studio/documents/new",
|
||||||
|
label: "새 문서",
|
||||||
|
active: (path: string) => path === "/studio/documents/new",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function StudioNavigation({
|
||||||
|
currentPath,
|
||||||
|
label,
|
||||||
|
}: Readonly<{ currentPath: string; label: string }>) {
|
||||||
|
return (
|
||||||
|
<nav aria-label={label}>
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
to={item.href}
|
||||||
|
aria-current={item.active(currentPath) ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<a href="/">공개 사이트 보기</a>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StudioHeader({ currentPath }: Readonly<{ currentPath: string }>) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const mobileId = useId();
|
||||||
|
return (
|
||||||
|
<header className="studio-header">
|
||||||
|
<div className="studio-header-inner">
|
||||||
|
<Link
|
||||||
|
className="studio-wordmark"
|
||||||
|
to="/studio"
|
||||||
|
aria-label="TechLog Studio"
|
||||||
|
>
|
||||||
|
TechLog <span>Studio</span>
|
||||||
|
</Link>
|
||||||
|
<div className="studio-desktop-navigation">
|
||||||
|
<StudioNavigation currentPath={currentPath} label="Studio 주 탐색" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="studio-menu-trigger"
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={mobileId}
|
||||||
|
onClick={() => setOpen((current) => !current)}
|
||||||
|
>
|
||||||
|
{open ? "Studio 메뉴 닫기" : "Studio 메뉴 열기"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id={mobileId} className="studio-mobile-navigation" hidden={!open}>
|
||||||
|
<StudioNavigation currentPath={currentPath} label="Studio 모바일 탐색" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { DocumentList } from "../components/document-list.tsx";
|
||||||
|
|
||||||
|
export function DocumentsPage() {
|
||||||
|
return <DocumentList />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { NewDocumentForm } from "../components/new-document-form.tsx";
|
||||||
|
|
||||||
|
export function NewDocumentPage() {
|
||||||
|
return <NewDocumentForm />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { StudioDashboard } from "../components/studio-dashboard.tsx";
|
||||||
|
|
||||||
|
export function StudioHomePage() {
|
||||||
|
return <StudioDashboard />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export function StudioNotFoundPage() {
|
||||||
|
return (
|
||||||
|
<section className="studio-route-state">
|
||||||
|
<p className="studio-eyebrow">404</p>
|
||||||
|
<h1>Studio 화면을 찾을 수 없습니다</h1>
|
||||||
|
<p>주소를 확인하거나 작업본 목록에서 다시 시작하세요.</p>
|
||||||
|
<Link to="/studio/documents">작업본으로 돌아가기</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||||
|
import { StudioContext, type StudioContextValue } from "./use-studio.ts";
|
||||||
|
|
||||||
|
type StudioProviderProps = Readonly<{
|
||||||
|
children: ReactNode;
|
||||||
|
createGateway: () => StudioGateway;
|
||||||
|
navigate?: (href: string) => void;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function defaultNavigate(href: string): void {
|
||||||
|
window.history.pushState({}, "", href);
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StudioProvider({
|
||||||
|
children,
|
||||||
|
createGateway,
|
||||||
|
navigate = defaultNavigate,
|
||||||
|
}: StudioProviderProps) {
|
||||||
|
const [gateway] = useState<StudioGateway>(() => createGateway());
|
||||||
|
const [requestAnnouncement, setRequestAnnouncement] = useState("");
|
||||||
|
const navigateInternal = useCallback((href: string) => navigate(href), [navigate]);
|
||||||
|
const value = useMemo<StudioContextValue>(
|
||||||
|
() => ({
|
||||||
|
gateway,
|
||||||
|
requestAnnouncement,
|
||||||
|
setRequestAnnouncement,
|
||||||
|
navigateInternal,
|
||||||
|
}),
|
||||||
|
[gateway, navigateInternal, requestAnnouncement],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StudioContext.Provider value={value}>
|
||||||
|
<div className="studio-app">{children}</div>
|
||||||
|
</StudioContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Component, Fragment, type ReactNode } from "react";
|
||||||
|
|
||||||
|
export class StudioRuntimeBoundary extends Component<
|
||||||
|
Readonly<{ children: ReactNode }>,
|
||||||
|
Readonly<{ failed: boolean; resetKey: number }>
|
||||||
|
> {
|
||||||
|
state = { failed: false, resetKey: 0 };
|
||||||
|
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { failed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private retry = () => {
|
||||||
|
this.setState((current) => ({
|
||||||
|
failed: false,
|
||||||
|
resetKey: current.resetKey + 1,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.failed) {
|
||||||
|
return (
|
||||||
|
<div className="studio-app">
|
||||||
|
<header className="studio-header">
|
||||||
|
<div className="studio-header-inner">
|
||||||
|
<a className="studio-wordmark" href="/studio">
|
||||||
|
TechLog <span>Studio</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main id="main-content" className="studio-main">
|
||||||
|
<section className="studio-route-state" role="alert">
|
||||||
|
<p className="studio-eyebrow">STUDIO ERROR</p>
|
||||||
|
<h1>Studio 화면을 불러오지 못했습니다</h1>
|
||||||
|
<p>현재 세션을 초기화한 뒤 다시 시작하세요.</p>
|
||||||
|
<button type="button" onClick={this.retry}>
|
||||||
|
다시 시작
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Fragment key={this.state.resetKey}>{this.props.children}</Fragment>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useApplication } from "../../../../presentation/providers/application-provider.tsx";
|
||||||
|
import { TECH_LOG_FEATURE_ID } from "../../application/tech-log-feature-input.ts";
|
||||||
|
import { StudioHeader } from "./components/studio-header.tsx";
|
||||||
|
import { StudioProvider } from "./studio-provider.tsx";
|
||||||
|
import { StudioRuntimeBoundary } from "./studio-runtime-boundary.tsx";
|
||||||
|
import { useStudio } from "./use-studio.ts";
|
||||||
|
|
||||||
|
type StudioShellProps = Readonly<{ children: ReactNode }>;
|
||||||
|
|
||||||
|
function StudioFrame({ children }: StudioShellProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
const { requestAnnouncement } = useStudio();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<a className="studio-skip-link" href="#main-content">
|
||||||
|
본문으로 건너뛰기
|
||||||
|
</a>
|
||||||
|
<StudioHeader currentPath={location.pathname} />
|
||||||
|
<main id="main-content" className="studio-main" tabIndex={-1}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<p
|
||||||
|
className="studio-visually-hidden"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
{requestAnnouncement}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StudioShell({ children }: StudioShellProps) {
|
||||||
|
const application = useApplication();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [generation, setGeneration] = useState(0);
|
||||||
|
const navigateInternal = useCallback(
|
||||||
|
(href: string) => {
|
||||||
|
void navigate(href);
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
const createGateway = useCallback(
|
||||||
|
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioGateway(),
|
||||||
|
[application],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const resetPersistedSession = (event: PageTransitionEvent) => {
|
||||||
|
if (event.persisted) setGeneration((current) => current + 1);
|
||||||
|
};
|
||||||
|
window.addEventListener("pageshow", resetPersistedSession);
|
||||||
|
return () => window.removeEventListener("pageshow", resetPersistedSession);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StudioRuntimeBoundary key={generation}>
|
||||||
|
<StudioProvider
|
||||||
|
key={generation}
|
||||||
|
createGateway={createGateway}
|
||||||
|
navigate={navigateInternal}
|
||||||
|
>
|
||||||
|
<StudioFrame>{children}</StudioFrame>
|
||||||
|
</StudioProvider>
|
||||||
|
</StudioRuntimeBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||||
|
|
||||||
|
export type StudioContextValue = Readonly<{
|
||||||
|
gateway: StudioGateway;
|
||||||
|
requestAnnouncement: string;
|
||||||
|
setRequestAnnouncement(message: string): void;
|
||||||
|
navigateInternal(href: string): void;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const StudioContext = createContext<StudioContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useStudio(): StudioContextValue {
|
||||||
|
const context = useContext(StudioContext);
|
||||||
|
if (!context) throw new Error("useStudio must be used within StudioProvider");
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||||
|
import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
|
||||||
|
import type { DocumentPage } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||||
|
import { DocumentList } from "../../../src/features/tech-log/presentation/studio/components/document-list.tsx";
|
||||||
|
import { NewDocumentForm } from "../../../src/features/tech-log/presentation/studio/components/new-document-form.tsx";
|
||||||
|
import { StudioDashboard } from "../../../src/features/tech-log/presentation/studio/components/studio-dashboard.tsx";
|
||||||
|
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
|
||||||
|
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
||||||
|
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
function LocationProbe() {
|
||||||
|
return <output aria-label="현재 경로">{useLocation().pathname}</output>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouterStudioProvider({
|
||||||
|
children,
|
||||||
|
gateway,
|
||||||
|
}: Readonly<{ children: ReactNode; gateway: StudioGateway }>) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<StudioProvider
|
||||||
|
createGateway={() => gateway}
|
||||||
|
navigate={(href) => {
|
||||||
|
void navigate(href);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</StudioProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScreen(children: ReactNode, gateway: StudioGateway) {
|
||||||
|
const input = createTechLogFeatureInstalledInput().input;
|
||||||
|
return render(
|
||||||
|
<ApplicationProvider
|
||||||
|
application={createTestApplication({
|
||||||
|
featureInputs: { "tech-log": { ...input, createStudioGateway: () => gateway } },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<MemoryRouter initialEntries={["/studio"]}>
|
||||||
|
<RouterStudioProvider gateway={gateway}>{children}</RouterStudioProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</ApplicationProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TechLog Studio index screens", () => {
|
||||||
|
it("shows the source dashboard totals, workflow sections, status links, and sample rows", async () => {
|
||||||
|
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||||
|
|
||||||
|
const { container } = renderScreen(<StudioDashboard />, gateway);
|
||||||
|
|
||||||
|
const summary = await screen.findByLabelText("Studio 요약");
|
||||||
|
expect(summary).toHaveTextContent("전체 작업본7");
|
||||||
|
expect(summary).toHaveTextContent("검증할 기록5");
|
||||||
|
expect(summary).toHaveTextContent("게시 준비0");
|
||||||
|
expect(summary).toHaveTextContent("게시 기록4");
|
||||||
|
expect(screen.getByRole("heading", { level: 1, name: "작업 흐름" })).toBeVisible();
|
||||||
|
expect(
|
||||||
|
Array.from(container.querySelectorAll(".studio-section-title a"), (link) => [
|
||||||
|
link.textContent,
|
||||||
|
link.getAttribute("href"),
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
["전체 보기", "/studio/documents"],
|
||||||
|
["전체 보기", "/studio/documents"],
|
||||||
|
["전체 보기", "/studio/documents"],
|
||||||
|
["게시 기록 보기", "/studio/publications"],
|
||||||
|
]);
|
||||||
|
expect(screen.getAllByText("컬렉션 Fetch Join과 페이징은 왜 충돌하는가").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("searches and filters documents and renders the source empty state", async () => {
|
||||||
|
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||||
|
renderScreen(<DocumentList />, gateway);
|
||||||
|
|
||||||
|
expect(await screen.findByText("7개의 작업본")).toBeVisible();
|
||||||
|
fireEvent.change(screen.getByLabelText("검색"), { target: { value: "Fetch Join" } });
|
||||||
|
fireEvent.submit(screen.getByRole("search"));
|
||||||
|
expect(await screen.findByText("1개의 작업본")).toBeVisible();
|
||||||
|
expect(screen.getByText("컬렉션 Fetch Join과 페이징은 왜 충돌하는가")).toBeVisible();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("종류"), { target: { value: "QUESTION" } });
|
||||||
|
expect(await screen.findByText("0개의 작업본")).toBeVisible();
|
||||||
|
expect(screen.getByRole("heading", { level: 2, name: "조건에 맞는 작업본이 없습니다" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels an obsolete list request and advances cursor pagination", async () => {
|
||||||
|
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||||
|
const all = await base.listDocuments({ limit: 20 });
|
||||||
|
let obsoleteSignal: AbortSignal | undefined;
|
||||||
|
const firstPage: DocumentPage = { items: all.items.slice(0, 1), nextCursor: "next-page" };
|
||||||
|
const secondPage: DocumentPage = { items: all.items.slice(1, 2), nextCursor: null };
|
||||||
|
const listDocuments = vi.fn<StudioGateway["listDocuments"]>()
|
||||||
|
.mockImplementationOnce((_query, { signal } = {}) => {
|
||||||
|
obsoleteSignal = signal;
|
||||||
|
return new Promise(() => {});
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce(firstPage)
|
||||||
|
.mockResolvedValueOnce(secondPage);
|
||||||
|
const gateway = { ...base, listDocuments } satisfies StudioGateway;
|
||||||
|
renderScreen(<DocumentList />, gateway);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("종류"), { target: { value: "CASE" } });
|
||||||
|
await waitFor(() => expect(obsoleteSignal?.aborted).toBe(true));
|
||||||
|
expect(await screen.findByText("1개의 작업본")).toBeVisible();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "다음 작업본" }));
|
||||||
|
await waitFor(() => expect(listDocuments).toHaveBeenCalledTimes(3));
|
||||||
|
expect(screen.getByText(secondPage.items[0]!.title)).toBeVisible();
|
||||||
|
expect(screen.queryByRole("button", { name: "다음 작업본" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a retry action after a list failure and recovers", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||||
|
const recovered = await base.listDocuments({ limit: 20 });
|
||||||
|
const gateway = {
|
||||||
|
...base,
|
||||||
|
listDocuments: vi.fn<StudioGateway["listDocuments"]>()
|
||||||
|
.mockRejectedValueOnce(new Error("offline"))
|
||||||
|
.mockResolvedValueOnce(recovered),
|
||||||
|
} satisfies StudioGateway;
|
||||||
|
renderScreen(<DocumentList />, gateway);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent("작업본을 불러오지 못했습니다.");
|
||||||
|
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("7개의 작업본")).toBeVisible();
|
||||||
|
expect(gateway.listDocuments).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the selected Question in the same session and redirects to its editor", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||||
|
renderScreen(
|
||||||
|
<>
|
||||||
|
<NewDocumentForm />
|
||||||
|
<LocationProbe />
|
||||||
|
</>,
|
||||||
|
gateway,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("radio", { name: /Question/ }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "작업본 만들기" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByLabelText("현재 경로")).toHaveTextContent(/^\/studio\/documents\/[0-9a-f-]+\/edit$/));
|
||||||
|
expect((await gateway.listDocuments({ kind: "QUESTION" })).items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { Outlet, RouterProvider, createMemoryRouter } from "react-router-dom";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||||
|
import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
|
||||||
|
import { StudioHomePage } from "../../../src/features/tech-log/presentation/studio/pages/studio-home-page.tsx";
|
||||||
|
import { StudioNotFoundPage } from "../../../src/features/tech-log/presentation/studio/pages/studio-not-found-page.tsx";
|
||||||
|
import { StudioShell } from "../../../src/features/tech-log/presentation/studio/studio-shell.tsx";
|
||||||
|
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
||||||
|
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
function renderStudio(
|
||||||
|
initialEntry: string,
|
||||||
|
createStudioGateway: () => StudioGateway,
|
||||||
|
) {
|
||||||
|
const router = createMemoryRouter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: "/studio",
|
||||||
|
element: (
|
||||||
|
<StudioShell>
|
||||||
|
<Outlet />
|
||||||
|
</StudioShell>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <StudioHomePage /> },
|
||||||
|
{ path: "documents", element: <p>작업본 라우트</p> },
|
||||||
|
{ path: "documents/new", element: <p>새 문서 라우트</p> },
|
||||||
|
{ path: "publications", element: <p>게시 기록 라우트</p> },
|
||||||
|
{ path: "*", element: <StudioNotFoundPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ initialEntries: [initialEntry] },
|
||||||
|
);
|
||||||
|
const installed = createTechLogFeatureInstalledInput().input;
|
||||||
|
const application = createTestApplication({
|
||||||
|
featureInputs: {
|
||||||
|
"tech-log": { ...installed, createStudioGateway },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const view = render(
|
||||||
|
<ApplicationProvider application={application}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</ApplicationProvider>,
|
||||||
|
);
|
||||||
|
return { ...view, router };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TechLog Studio shell", () => {
|
||||||
|
it("creates one application-provided gateway for child navigation and exposes the source header", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||||
|
const createGateway = vi.fn(() => gateway);
|
||||||
|
|
||||||
|
const { container, router } = renderStudio("/studio", createGateway);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { level: 1, name: "작업 흐름" })).toBeVisible();
|
||||||
|
expect(createGateway).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
Array.from(
|
||||||
|
container.querySelectorAll<HTMLAnchorElement>(".studio-desktop-navigation a"),
|
||||||
|
(link) => [link.textContent, link.getAttribute("href")],
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
["작업본", "/studio/documents"],
|
||||||
|
["게시 기록", "/studio/publications"],
|
||||||
|
["새 문서", "/studio/documents/new"],
|
||||||
|
["공개 사이트 보기", "/"],
|
||||||
|
]);
|
||||||
|
expect(screen.getByRole("link", { name: "공개 사이트 보기" })).toHaveAttribute("href", "/");
|
||||||
|
expect(screen.queryByText(/로그인|sign in/i)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(within(screen.getByRole("navigation", { name: "Studio 주 탐색" })).getByRole("link", { name: "작업본" }));
|
||||||
|
|
||||||
|
expect(router.state.location.pathname).toBe("/studio/documents");
|
||||||
|
expect(screen.getByText("작업본 라우트")).toBeVisible();
|
||||||
|
expect(createGateway).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.getAllByRole("link", { name: "작업본" })[0]).toHaveAttribute("aria-current", "page");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recreates the provider generation and cancels obsolete work after a persisted pageshow", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let firstSignal: AbortSignal | undefined;
|
||||||
|
const first = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||||
|
const second = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||||
|
vi.spyOn(first, "getDashboard").mockImplementation(({ signal } = {}) => {
|
||||||
|
firstSignal = signal;
|
||||||
|
return new Promise(() => {});
|
||||||
|
});
|
||||||
|
const secondDashboard = vi.spyOn(second, "getDashboard");
|
||||||
|
const createGateway = vi.fn()
|
||||||
|
.mockReturnValueOnce(first)
|
||||||
|
.mockReturnValueOnce(second);
|
||||||
|
|
||||||
|
renderStudio("/studio", createGateway);
|
||||||
|
await waitFor(() => expect(firstSignal).toBeDefined());
|
||||||
|
await user.click(screen.getByRole("button", { name: "Studio 메뉴 열기" }));
|
||||||
|
expect(screen.getByRole("button", { name: "Studio 메뉴 닫기" })).toBeVisible();
|
||||||
|
|
||||||
|
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(createGateway).toHaveBeenCalledTimes(2));
|
||||||
|
expect(firstSignal?.aborted).toBe(true);
|
||||||
|
expect(screen.getByRole("button", { name: "Studio 메뉴 열기" })).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(await screen.findByRole("heading", { level: 1, name: "작업 흐름" })).toBeVisible();
|
||||||
|
expect(secondDashboard).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unknown Studio routes inside the Studio shell without authentication UI", () => {
|
||||||
|
const createGateway = vi.fn(
|
||||||
|
() => createTechLogFeatureInstalledInput().input.createStudioGateway(),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderStudio("/studio/does-not-exist", createGateway);
|
||||||
|
|
||||||
|
expect(screen.getByRole("banner")).toHaveClass("studio-header");
|
||||||
|
expect(screen.getByRole("heading", { level: 1, name: "Studio 화면을 찾을 수 없습니다" })).toBeVisible();
|
||||||
|
expect(screen.getByRole("link", { name: "작업본으로 돌아가기" })).toHaveAttribute("href", "/studio/documents");
|
||||||
|
expect(screen.queryByText(/로그인|sign in/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user