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