feat: add the Studio release editor and point the footer at the changelog
The public site shipped a Releases page and a footer link to a release, and neither could ever have content: the read path existed, the write path did not. This adds the seven release operations to the contract contribution and the gateway, and a Studio screen that can actually write one. The editor is six markdown fields rather than one, because that is what the contract models and what a release note is — why, what, what a reader notices, what it leaves in the code, how it was verified, what is still open. Publishing is separate from saving: a draft saves in any state, but the public query keys on PUBLISHED alone, so publish is where completeness is demanded. No new CSS. The screen reuses the document editor's field classes and the working-copy list's row classes, so it inherits Studio's spacing and type instead of introducing a second look. The footer previously linked `/releases/0.1.0` — a version that did not exist, so the link 404'd, and one that would have gone stale at 0.2.0 anyway. It now points at the changelog index, which is the only place that knows what the latest release is and which reads correctly when there are none. Route inventory, navigation order, message catalog, manual accessibility evidence, artifact baseline, and the pinned gate-shape digest all move with the new route. The digest was recomputed by first reproducing the previous constant from the previous gates.json, so the computation is known to be the one it was pinned under.
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
import type {
|
||||
ReleaseEditResponse,
|
||||
ReleaseIndexItem,
|
||||
ReleaseUpdateRequest,
|
||||
} from "../../../contracts/management/contract.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
/**
|
||||
* 릴리즈 작성.
|
||||
*
|
||||
* <p>공개 사이트에 변경 기록 화면과 푸터 링크가 있는데 둘 다 비어 있었다 — 읽기는 처음부터
|
||||
* 있었고 쓰는 곳이 없었다. 그래서 이 화면의 범위는 목록·작성·발행이다.
|
||||
*
|
||||
* <p>본문이 마크다운 여섯 칸으로 나뉜 것은 계약의 모양이자 릴리즈 노트의 성격이다. 한 칸짜리
|
||||
* 자유 서술이었다면 "검증을 안 썼다"를 아무도 알아채지 못한다.
|
||||
*
|
||||
* <p>새 CSS 를 만들지 않는다 — 문서 편집기가 쓰는 field 클래스와 작업본 목록의 행 클래스를 그대로
|
||||
* 재사용하므로 Studio 의 나머지와 같은 간격·타이포·색을 따른다.
|
||||
*/
|
||||
|
||||
/** 백엔드 {@code UpdateReleaseUseCase.CHANGE_TYPES} 와 같은 집합이다. */
|
||||
const CHANGE_TYPES = [
|
||||
{ value: "FEATURE", label: "기능" },
|
||||
{ value: "FIX", label: "수정" },
|
||||
{ value: "REFACTOR", label: "구조" },
|
||||
{ value: "DOCS", label: "문서" },
|
||||
{ value: "INFRA", label: "인프라" },
|
||||
{ value: "BREAKING", label: "호환 깨짐" },
|
||||
] as const;
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: "reasonMarkdown", label: "왜 바꿨나" },
|
||||
{ key: "changesMarkdown", label: "무엇을 바꿨나" },
|
||||
{ key: "userImpactMarkdown", label: "사용자에게 달라지는 것" },
|
||||
{ key: "implementationImpactMarkdown", label: "구현에 남는 것" },
|
||||
{ key: "verificationMarkdown", label: "어떻게 검증했나" },
|
||||
{ key: "knownLimitationsMarkdown", label: "아직 못 한 것" },
|
||||
] as const;
|
||||
|
||||
type Draft = Readonly<{
|
||||
expectedVersion: number;
|
||||
versionLabel: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
releasedOn: string;
|
||||
changeTypes: readonly string[];
|
||||
reasonMarkdown: string;
|
||||
changesMarkdown: string;
|
||||
userImpactMarkdown: string;
|
||||
implementationImpactMarkdown: string;
|
||||
verificationMarkdown: string;
|
||||
knownLimitationsMarkdown: string;
|
||||
}>;
|
||||
|
||||
function toDraft(release: ReleaseEditResponse): Draft {
|
||||
return {
|
||||
expectedVersion: release.version,
|
||||
versionLabel: release.versionLabel,
|
||||
title: release.title,
|
||||
summary: release.summary ?? "",
|
||||
releasedOn: release.releasedOn ?? "",
|
||||
changeTypes: release.changeTypes ?? [],
|
||||
reasonMarkdown: release.reasonMarkdown ?? "",
|
||||
changesMarkdown: release.changesMarkdown ?? "",
|
||||
userImpactMarkdown: release.userImpactMarkdown ?? "",
|
||||
implementationImpactMarkdown: release.implementationImpactMarkdown ?? "",
|
||||
verificationMarkdown: release.verificationMarkdown ?? "",
|
||||
knownLimitationsMarkdown: release.knownLimitationsMarkdown ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Readonly<Record<string, string>> = {
|
||||
DRAFT: "작성 중",
|
||||
PUBLISHED: "공개",
|
||||
ARCHIVED: "보관",
|
||||
};
|
||||
|
||||
export function ReleaseManager() {
|
||||
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||
const [releases, setReleases] = useState<ReleaseIndexItem[] | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
|
||||
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
void managementGateway.listReleases(0, 50).then(
|
||||
(page) => {
|
||||
if (cancelled) return;
|
||||
setReleases(page.items ?? []);
|
||||
setLoading(false);
|
||||
},
|
||||
() => {
|
||||
if (cancelled) return;
|
||||
setError("릴리즈를 불러오지 못했습니다.");
|
||||
setLoading(false);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [managementGateway, generation]);
|
||||
|
||||
// 선택된 릴리즈의 본문은 목록에 없다 — 목록 행은 마크다운을 싣지 않으므로 따로 읽는다.
|
||||
useEffect(() => {
|
||||
if (selectedId === null) {
|
||||
setDraft(null);
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
void managementGateway.getRelease(selectedId).then(
|
||||
(release) => {
|
||||
if (!cancelled) setDraft(toDraft(release));
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) setError("릴리즈를 불러오지 못했습니다.");
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [managementGateway, selectedId, generation]);
|
||||
|
||||
const update = (patch: Partial<Draft>) =>
|
||||
setDraft((current) => (current === null ? current : { ...current, ...patch }));
|
||||
|
||||
const submitNew = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
const title = newTitle.trim();
|
||||
if (!title) {
|
||||
setError("릴리즈 제목을 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await managementGateway.createRelease(title);
|
||||
setNewTitle("");
|
||||
setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`);
|
||||
setSelectedId(created.id);
|
||||
reload();
|
||||
} catch {
|
||||
setError("릴리즈를 만들지 못했습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (pending || draft === null || selectedId === null) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const body: ReleaseUpdateRequest = {
|
||||
expectedVersion: draft.expectedVersion,
|
||||
versionLabel: draft.versionLabel.trim(),
|
||||
title: draft.title.trim(),
|
||||
summary: draft.summary,
|
||||
changeTypes: [...draft.changeTypes],
|
||||
changesMarkdown: draft.changesMarkdown,
|
||||
verificationMarkdown: draft.verificationMarkdown,
|
||||
...(draft.releasedOn ? { releasedOn: draft.releasedOn } : {}),
|
||||
reasonMarkdown: draft.reasonMarkdown,
|
||||
userImpactMarkdown: draft.userImpactMarkdown,
|
||||
implementationImpactMarkdown: draft.implementationImpactMarkdown,
|
||||
knownLimitationsMarkdown: draft.knownLimitationsMarkdown,
|
||||
} as ReleaseUpdateRequest;
|
||||
const saved = await managementGateway.updateRelease(selectedId, body);
|
||||
setDraft(toDraft(saved));
|
||||
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("저장하지 못했습니다. 같은 버전이 이미 있거나 다른 곳에서 먼저 수정되었을 수 있습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const publish = async () => {
|
||||
if (pending || draft === null || selectedId === null) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const published = await managementGateway.publishRelease(selectedId, draft.expectedVersion);
|
||||
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
|
||||
reload();
|
||||
} catch {
|
||||
// 발행은 저장보다 요구가 많다. 무엇이 비었는지는 서버가 알고 있지만, 그 목록을 그대로
|
||||
// 옮기려면 오류 details 를 읽는 화면이 필요하다 — 여기서는 필수 항목을 그대로 안내한다.
|
||||
setError(
|
||||
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const archive = async () => {
|
||||
if (pending || draft === null || selectedId === null) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.archiveRelease(selectedId, draft.expectedVersion);
|
||||
setRequestAnnouncement("릴리즈를 공개에서 내렸습니다.");
|
||||
reload();
|
||||
} catch {
|
||||
setError("공개에서 내리지 못했습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (release: ReleaseIndexItem) => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.deleteRelease(release.id, release.version);
|
||||
if (selectedId === release.id) setSelectedId(null);
|
||||
setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("삭제하지 못했습니다. 공개된 릴리즈는 삭제 대신 공개에서 내려야 합니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleChangeType = (value: string) => {
|
||||
if (draft === null) return;
|
||||
const next = draft.changeTypes.includes(value)
|
||||
? draft.changeTypes.filter((entry) => entry !== value)
|
||||
: [...draft.changeTypes, value];
|
||||
update({ changeTypes: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-page studio-documents-page">
|
||||
<header className="studio-page-top">
|
||||
<div className="studio-page-heading">
|
||||
<p className="studio-eyebrow">RELEASES</p>
|
||||
<h1>릴리즈</h1>
|
||||
<p>무엇을 왜 바꿨는지 버전으로 묶어 공개 변경 기록에 남깁니다.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="studio-document-tools" aria-label="릴리즈 만들기">
|
||||
<form onSubmit={submitNew}>
|
||||
<label htmlFor="release-new-title">새 릴리즈</label>
|
||||
<div>
|
||||
<input
|
||||
id="release-new-title"
|
||||
type="text"
|
||||
value={newTitle}
|
||||
placeholder="릴리즈 제목"
|
||||
onChange={(event) => setNewTitle(event.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={pending}>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<p className="studio-screen-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="studio-empty-inline">불러오는 중입니다.</p>
|
||||
) : (releases?.length ?? 0) === 0 ? (
|
||||
<p className="studio-empty-inline">아직 릴리즈가 없습니다.</p>
|
||||
) : (
|
||||
<section aria-label="릴리즈 목록">
|
||||
<p className="studio-result-count">{releases?.length}개의 릴리즈</p>
|
||||
<div className="studio-document-list">
|
||||
{(releases ?? []).map((release) => (
|
||||
<article
|
||||
key={release.id}
|
||||
className="studio-document-row"
|
||||
aria-current={selectedId === release.id ? "true" : undefined}
|
||||
>
|
||||
<div className="studio-document-title">
|
||||
<h2>{release.title}</h2>
|
||||
<p>
|
||||
{release.versionLabel}
|
||||
{release.releasedOn ? ` · ${release.releasedOn}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>상태</dt>
|
||||
<dd>{STATUS_LABELS[release.workflowStatus] ?? release.workflowStatus}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => setSelectedId(selectedId === release.id ? null : release.id)}
|
||||
>
|
||||
{selectedId === release.id ? "닫기" : "편집"}
|
||||
</button>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void remove(release)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{draft === null ? null : (
|
||||
<section aria-label="릴리즈 편집">
|
||||
<h2 className="studio-section-title">{draft.title || "릴리즈"} 편집</h2>
|
||||
<div className="studio-field-grid">
|
||||
<label className="studio-field">
|
||||
<span>버전</span>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.versionLabel}
|
||||
placeholder="0.1.0"
|
||||
onChange={(event) => update({ versionLabel: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>제목</span>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.title}
|
||||
onChange={(event) => update({ title: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>공개일</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draft.releasedOn}
|
||||
onChange={(event) => update({ releasedOn: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>한 줄 요약</span>
|
||||
<textarea
|
||||
value={draft.summary}
|
||||
onChange={(event) => update({ summary: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset className="studio-field-grid">
|
||||
<legend className="studio-field">
|
||||
<span>변경 유형</span>
|
||||
</legend>
|
||||
{CHANGE_TYPES.map((changeType) => (
|
||||
<label key={changeType.value} className="studio-field studio-field--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.changeTypes.includes(changeType.value)}
|
||||
onChange={() => toggleChangeType(changeType.value)}
|
||||
/>
|
||||
<span>{changeType.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<div className="studio-field-grid">
|
||||
{SECTIONS.map((section) => (
|
||||
<label key={section.key} className="studio-field studio-field--wide">
|
||||
<span>{section.label}</span>
|
||||
<textarea
|
||||
value={draft[section.key]}
|
||||
onChange={(event) => update({ [section.key]: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="studio-create-footer">
|
||||
<button
|
||||
className="studio-primary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
공개
|
||||
</button>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void archive()}
|
||||
>
|
||||
공개에서 내리기
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ReleaseManager } from "../components/release-manager.tsx";
|
||||
|
||||
export function StudioReleasesPage() {
|
||||
return <ReleaseManager />;
|
||||
}
|
||||
Reference in New Issue
Block a user