feat: let Studio create the topics and projects publishing requires
Publishing needs a topic and nothing could create one. The backend now owns that surface; this is its consumer — the management contract vendored, a gateway over its nine operations, and one Studio screen that lists, creates, and deletes topics and projects. The screen adds no CSS. It reuses the classes the working-copy list already uses, so it inherits Studio's spacing, type, and colour rather than growing a second visual vocabulary beside them. Scope stops at list/create/delete: renaming, phase changes, and visibility are implemented in the backend and declared in the contract, but their screens are a separate design. Two real defects surfaced while making the public port async, and both would have shipped: The search page and the header search dialog shared a query key. With an empty query, `["tech-log","search",""]` was identical for both, so react-query handed one surface the other's cache — different shapes — and the page died reading a field that was not there. Keys now name the surface. The explore filter's selects are uncontrolled and read `defaultValue`, which React applies once. Their options arrive later now, so the first render had nothing to match and the value stayed empty: a topic in the URL no longer showed as selected. The form key includes whether the catalog has arrived, so it remounts with the options present. Controlled inputs would be the other answer, but this form submits to build a URL — the URL owns the value. The route brought its own bookkeeping: a build chunk, a manual accessibility evidence file, and the CI artifact baseline that counts them. The gate pins a digest of its own shape precisely so a new route cannot slip in without that count being reviewed. Test harnesses that render public screens now assemble the query providers and await the settled paint, because the screens they render became async.
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
import type { ProjectIndexItem, TopicEdit } from "../../../contracts/management/contract.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
/**
|
||||
* 주제와 프로젝트 관리.
|
||||
*
|
||||
* <p>이 화면이 존재하는 이유는 문서 발행이 주제를 요구하는데 주제를 만들 곳이 없었기 때문이다.
|
||||
* 그래서 범위를 목록·생성·삭제로 끊었다 — 편집(이름 변경, 단계 전환, 공개 범위)은 계약에 있고
|
||||
* 백엔드도 구현돼 있으나, 그 화면은 별도 설계가 필요하다.
|
||||
*
|
||||
* <p>새 CSS 를 만들지 않는다. 작업본 목록이 쓰는 클래스만 재사용하므로 이 화면은 Studio 의
|
||||
* 나머지와 같은 간격·타이포·색을 그대로 따른다.
|
||||
*/
|
||||
export function TaxonomyManager() {
|
||||
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||
const [topics, setTopics] = useState<TopicEdit[] | null>(null);
|
||||
const [projects, setProjects] = useState<ProjectIndexItem[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
const [topicName, setTopicName] = useState("");
|
||||
const [topicSlug, setTopicSlug] = useState("");
|
||||
const [projectTitle, setProjectTitle] = useState("");
|
||||
|
||||
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
void Promise.all([managementGateway.listTopics(), managementGateway.listProjects(0, 50)]).then(
|
||||
([topicList, projectPage]) => {
|
||||
if (cancelled) return;
|
||||
setTopics(topicList);
|
||||
setProjects(projectPage.items ?? []);
|
||||
setLoading(false);
|
||||
},
|
||||
() => {
|
||||
if (cancelled) return;
|
||||
setError("주제와 프로젝트를 불러오지 못했습니다.");
|
||||
setLoading(false);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [managementGateway, generation]);
|
||||
|
||||
/**
|
||||
* slug 를 비워 두면 이름에서 만든다. 한글 이름이 흔한데 slug 는 ASCII 만 받으므로, 비운 채로
|
||||
* 저장하면 서버가 422 로 거절한다 — 사용자가 규칙을 몰라도 되도록 여기서 채운다.
|
||||
*/
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/gu, "-")
|
||||
.replace(/^-+|-+$/gu, "");
|
||||
|
||||
const submitTopic = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
const name = topicName.trim();
|
||||
const slug = slugify(topicSlug || topicName);
|
||||
if (!name || !slug) {
|
||||
setError("주제 이름과 slug 를 입력해 주세요. slug 는 영문·숫자·하이픈만 가능합니다.");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.createTopic({ name, slug } as TopicEdit);
|
||||
setTopicName("");
|
||||
setTopicSlug("");
|
||||
setRequestAnnouncement(`주제 ${name} 을(를) 만들었습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("주제를 만들지 못했습니다. 같은 이름이나 slug 가 이미 있을 수 있습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitProject = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
const title = projectTitle.trim();
|
||||
if (!title) {
|
||||
setError("프로젝트 이름을 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.createProject(title);
|
||||
setProjectTitle("");
|
||||
setRequestAnnouncement(`프로젝트 ${title} 을(를) 만들었습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("프로젝트를 만들지 못했습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTopic = async (topic: TopicEdit) => {
|
||||
if (pending || topic.id === undefined || topic.version === undefined) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.deleteTopic(topic.id, topic.version);
|
||||
setRequestAnnouncement(`주제 ${topic.name} 을(를) 삭제했습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("주제를 삭제하지 못했습니다. 이 주제를 쓰는 기록이 있을 수 있습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeProject = async (project: ProjectIndexItem) => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.deleteProject(project.id, project.version);
|
||||
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 삭제했습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("프로젝트를 삭제하지 못했습니다. 연결된 기록이 있을 수 있습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-page studio-documents-page">
|
||||
<header className="studio-page-top">
|
||||
<div className="studio-page-heading">
|
||||
<p className="studio-eyebrow">TAXONOMY</p>
|
||||
<h1>주제와 프로젝트</h1>
|
||||
<p>문서를 게시하려면 주제가 필요합니다. 여기서 만들고 정리합니다.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="studio-document-tools" aria-label="주제 만들기">
|
||||
<form onSubmit={submitTopic}>
|
||||
<label htmlFor="taxonomy-topic-name">새 주제</label>
|
||||
<div>
|
||||
<input
|
||||
id="taxonomy-topic-name"
|
||||
type="text"
|
||||
value={topicName}
|
||||
placeholder="주제 이름"
|
||||
onChange={(event) => setTopicName(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={topicSlug}
|
||||
placeholder="slug (비우면 이름에서 생성)"
|
||||
aria-label="주제 slug"
|
||||
onChange={(event) => setTopicSlug(event.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={pending}>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form onSubmit={submitProject}>
|
||||
<label htmlFor="taxonomy-project-title">새 프로젝트</label>
|
||||
<div>
|
||||
<input
|
||||
id="taxonomy-project-title"
|
||||
type="text"
|
||||
value={projectTitle}
|
||||
placeholder="프로젝트 이름"
|
||||
onChange={(event) => setProjectTitle(event.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={pending}>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{loading ? (
|
||||
<p className="studio-loading" role="status">
|
||||
주제와 프로젝트를 불러오는 중입니다.
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="studio-screen-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!loading && topics ? (
|
||||
<>
|
||||
<p className="studio-result-count">{topics.length}개의 주제</p>
|
||||
{topics.length ? (
|
||||
<div className="studio-document-list">
|
||||
{topics.map((topic) => (
|
||||
<article className="studio-document-row" key={topic.id ?? topic.slug}>
|
||||
<p className="studio-row-label">TOPIC</p>
|
||||
<div className="studio-document-title">
|
||||
<h2>{topic.name}</h2>
|
||||
<p>{topic.slug}</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>상태</dt>
|
||||
<dd>{topic.status === "ARCHIVED" ? "보관" : "사용 중"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void removeTopic(topic)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="studio-empty">아직 주제가 없습니다. 위에서 하나 만들어 주세요.</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!loading && projects ? (
|
||||
<>
|
||||
<p className="studio-result-count">{projects.length}개의 프로젝트</p>
|
||||
{projects.length ? (
|
||||
<div className="studio-document-list">
|
||||
{projects.map((project) => (
|
||||
<article className="studio-document-row" key={project.id}>
|
||||
<p className="studio-row-label">PROJECT</p>
|
||||
<div className="studio-document-title">
|
||||
<h2>{project.name}</h2>
|
||||
<p>{project.currentObjective ?? "목표 미지정"}</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>단계</dt>
|
||||
<dd>{project.phase}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>공개</dt>
|
||||
<dd>{project.targetVisibility}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void removeProject(project)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="studio-empty">아직 프로젝트가 없습니다.</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TaxonomyManager } from "../components/taxonomy-manager.tsx";
|
||||
|
||||
export function TaxonomyPage() {
|
||||
return <TaxonomyManager />;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
||||
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
|
||||
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||
import type { ManagementGateway } from "../../application/ports/management-gateway.ts";
|
||||
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
|
||||
import type {
|
||||
WorkingCopy,
|
||||
@@ -29,6 +30,7 @@ type StudioProviderProps = Readonly<{
|
||||
// Optional so test harnesses that only exercise the document gateway keep
|
||||
// working unchanged. `StudioShell` always supplies one in the running app.
|
||||
createAssetGateway?: () => StudioAssetGateway;
|
||||
createManagementGateway: () => ManagementGateway;
|
||||
resolvePublishedLabel?: ResolvePublishedLabel;
|
||||
now?: () => Date;
|
||||
navigate?: (href: string) => void;
|
||||
@@ -53,6 +55,7 @@ export function StudioProvider({
|
||||
children,
|
||||
createGateway,
|
||||
createAssetGateway,
|
||||
createManagementGateway,
|
||||
resolvePublishedLabel = missingPublishedLabel,
|
||||
now = () => new Date("2026-08-14T01:00:00.000Z"),
|
||||
navigate = defaultNavigate,
|
||||
@@ -64,6 +67,7 @@ export function StudioProvider({
|
||||
const [assetGateway] = useState<StudioAssetGateway | null>(
|
||||
() => createAssetGateway?.() ?? null,
|
||||
);
|
||||
const [managementGateway] = useState<ManagementGateway>(() => createManagementGateway());
|
||||
const [editor, setEditor] = useState<StudioEditorState | null>(null);
|
||||
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
||||
const [requestAnnouncement, setRequestAnnouncement] = useState("");
|
||||
@@ -159,6 +163,7 @@ export function StudioProvider({
|
||||
const value = useMemo<StudioContextValue>(
|
||||
() => ({
|
||||
gateway,
|
||||
managementGateway,
|
||||
assetGateway,
|
||||
resolvePublishedLabel,
|
||||
now,
|
||||
@@ -177,6 +182,7 @@ export function StudioProvider({
|
||||
clearEditor,
|
||||
editor,
|
||||
gateway,
|
||||
managementGateway,
|
||||
navigateInternal,
|
||||
now,
|
||||
requestAnnouncement,
|
||||
|
||||
@@ -58,6 +58,10 @@ export function StudioShell({ children }: StudioShellProps) {
|
||||
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioGateway(),
|
||||
[application],
|
||||
);
|
||||
const createManagementGateway = useCallback(
|
||||
() => application.features.get(TECH_LOG_FEATURE_ID).createManagementGateway(),
|
||||
[application],
|
||||
);
|
||||
const createAssetGateway = useCallback(
|
||||
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioAssetGateway(),
|
||||
[application],
|
||||
@@ -92,6 +96,7 @@ export function StudioShell({ children }: StudioShellProps) {
|
||||
key={generation}
|
||||
createGateway={createGateway}
|
||||
createAssetGateway={createAssetGateway}
|
||||
createManagementGateway={createManagementGateway}
|
||||
resolvePublishedLabel={resolvePublishedLabel}
|
||||
navigate={navigateInternal}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
|
||||
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||
import type { ManagementGateway } from "../../application/ports/management-gateway.ts";
|
||||
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
|
||||
import type {
|
||||
WorkingCopy,
|
||||
@@ -23,6 +24,9 @@ export type StudioContextValue = Readonly<{
|
||||
// `createAssetGateway` prop. `StudioShell` — the real app path — always
|
||||
// supplies one, so production code sees this populated.
|
||||
assetGateway: StudioAssetGateway | null;
|
||||
// 주제·프로젝트 관리. `assetGateway` 와 같은 이유로 nullable 이 아니다 — 이 표면은
|
||||
// MOCK 소스가 없어 항상 HTTP 이고, 없는 경우가 존재하지 않는다.
|
||||
managementGateway: ManagementGateway;
|
||||
resolvePublishedLabel: ResolvePublishedLabel;
|
||||
now(): Date;
|
||||
editor: StudioEditorState | null;
|
||||
|
||||
Reference in New Issue
Block a user