fix: 릴리즈 목록에서 편집 흔적을 걷어내고, 타입 검사가 실제로 돌게 한다

앞 커밋에서 편집 폼만 떼어 내고 그것을 움직이던 상태와 핸들러를 목록에 남겨 두었다.
`GuardedStudioLink` import 는 빠졌고 `navigate` 는 아예 정의된 적이 없다. 운영에서
릴리즈 목록이 ReferenceError 로 비었다.

`npx tsc --noEmit` 이 통과했기 때문에 이것을 못 봤다. 루트 tsconfig 는 `"files": []` 에
project references 만 나열하므로 그 명령은 한 파일도 검사하지 않고 성공한다 — 실제 검사는
`npm run check:types` 가 여섯 개 프로젝트를 돌며 한다. 그 명령으로 돌리자 이 저장소에
남아 있던 다른 오류도 함께 드러났다:

- `CatalogEntry` 는 `contracts/studio/contract.ts` 가 export 하지 않는다 (project-editor,
  home-focus-editor). 문서 편집기처럼 generated 에서 지역 타입으로 뽑는다.
- 라우트 파라미터는 `unknown` 으로 들어온다. `params.id` 를 그대로 문자열 자리에 넘기고
  있었다.
- `message("route.auth.returnTo", …)` 는 파라미터를 받는 키로 등록되지 않았다.
- `ReleaseIndexItem` 에는 `summary` 가 없다 — 목록 행은 마크다운을 싣지 않는다. 대신
  공개 경로를 적는다.

목록의 남은 로직도 정리한다. 새 릴리즈를 만들면 그 행을 `aria-current` 로 짚어 준다 —
목록이 길면 어느 것이 새것인지 알기 어렵다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
This commit is contained in:
DongHyeonka
2026-08-24 01:47:41 +09:00
co-authored by Claude Opus 5
parent 84d72c4f60
commit e9b866184a
5 changed files with 35 additions and 117 deletions
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState, type FormEvent } from "react"; import { useCallback, useEffect, useState, type FormEvent } from "react";
import type { CatalogEntry } from "../../../contracts/studio/contract.ts"; import type { components } from "../../../contracts/studio/generated.ts";
import type { import type {
HomeFocusResponse, HomeFocusResponse,
ProjectIndexItem, ProjectIndexItem,
@@ -9,6 +9,8 @@ import { managementFailureMessage } from "../../../application/ports/management-
import { GuardedStudioLink } from "./guarded-studio-link.tsx"; import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { useStudio } from "../use-studio.ts"; import { useStudio } from "../use-studio.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
/** /**
* 공개 홈의 "지금 집중하는 것" 을 정하는 화면. * 공개 홈의 "지금 집중하는 것" 을 정하는 화면.
* *
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState, type FormEvent } from "react"; import { useCallback, useEffect, useState, type FormEvent } from "react";
import type { CatalogEntry } from "../../../contracts/studio/contract.ts"; import type { components } from "../../../contracts/studio/generated.ts";
import type { import type {
ProjectActivityResponse, ProjectActivityResponse,
ProjectEditResponse, ProjectEditResponse,
@@ -12,6 +12,13 @@ import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { slugFromName } from "./slug-from-name.ts"; import { slugFromName } from "./slug-from-name.ts";
import { useStudio } from "../use-studio.ts"; import { useStudio } from "../use-studio.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
/** 경로 파라미터는 `unknown` 으로 들어온다 — 라우터가 코덱을 통과시킨 값이라도 타입은 좁혀 써야 한다. */
function routeId(value: unknown): string {
return typeof value === "string" ? value : "";
}
/** `project_phase_check` 와 같은 집합이다 — 화면이 더 많은 값을 보여 주면 저장이 제약에서 터진다. */ /** `project_phase_check` 와 같은 집합이다 — 화면이 더 많은 값을 보여 주면 저장이 제약에서 터진다. */
const PHASES = [ const PHASES = [
{ value: "RESEARCH", label: "조사" }, { value: "RESEARCH", label: "조사" },
@@ -99,7 +106,7 @@ function toDraft(project: ProjectEditResponse): Draft {
*/ */
export function ProjectEditor() { export function ProjectEditor() {
const { params } = useRouteInput<"TECH_LOG_STUDIO_PROJECT_EDIT">(); const { params } = useRouteInput<"TECH_LOG_STUDIO_PROJECT_EDIT">();
const projectId = params.id; const projectId = routeId(params.id);
const { gateway, managementGateway, setRequestAnnouncement } = useStudio(); const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
const [project, setProject] = useState<ProjectEditResponse | null>(null); const [project, setProject] = useState<ProjectEditResponse | null>(null);
@@ -71,9 +71,20 @@ function toDraft(release: ReleaseEditResponse): Draft {
}; };
} }
const STATUS_LABELS: Readonly<Record<string, string>> = {
DRAFT: "작성 중",
PUBLISHED: "공개",
ARCHIVED: "보관",
};
/** 경로 파라미터는 `unknown` 으로 들어온다 — 라우터가 코덱을 통과시킨 값이라도 타입은 좁혀 써야 한다. */
function routeId(value: unknown): string {
return typeof value === "string" ? value : "";
}
export function ReleaseEditor() { export function ReleaseEditor() {
const { params } = useRouteInput<"TECH_LOG_STUDIO_RELEASE_EDIT">(); const { params } = useRouteInput<"TECH_LOG_STUDIO_RELEASE_EDIT">();
const releaseId = params.id; const releaseId = routeId(params.id);
const { managementGateway, setRequestAnnouncement } = useStudio(); const { managementGateway, setRequestAnnouncement } = useStudio();
const [draft, setDraft] = useState<Draft | null>(null); const [draft, setDraft] = useState<Draft | null>(null);
@@ -6,6 +6,7 @@ import type {
ReleaseUpdateRequest, ReleaseUpdateRequest,
} from "../../../contracts/management/contract.ts"; } from "../../../contracts/management/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts"; import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { useStudio } from "../use-studio.ts"; import { useStudio } from "../use-studio.ts";
/** /**
@@ -81,13 +82,13 @@ const STATUS_LABELS: Readonly<Record<string, string>> = {
export function ReleaseManager() { export function ReleaseManager() {
const { managementGateway, setRequestAnnouncement } = useStudio(); const { managementGateway, setRequestAnnouncement } = useStudio();
const [releases, setReleases] = useState<ReleaseIndexItem[] | null>(null); 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 [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [generation, setGeneration] = useState(0); const [generation, setGeneration] = useState(0);
const [newTitle, setNewTitle] = useState(""); const [newTitle, setNewTitle] = useState("");
/* 방금 만든 릴리즈를 목록에서 짚어 준다 — 목록이 길면 어느 것이 새것인지 알기 어렵다. */
const [createdId, setCreatedId] = useState<string | null>(null);
const reload = useCallback(() => setGeneration((value) => value + 1), []); const reload = useCallback(() => setGeneration((value) => value + 1), []);
@@ -112,29 +113,6 @@ export function ReleaseManager() {
}; };
}, [managementGateway, generation]); }, [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) => { const submitNew = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
if (pending) return; if (pending) return;
@@ -148,9 +126,11 @@ export function ReleaseManager() {
try { try {
const created = await managementGateway.createRelease(title); const created = await managementGateway.createRelease(title);
setNewTitle(""); setNewTitle("");
setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`); setRequestAnnouncement(
// 만들자마자 편집할 것이 분명하므로 그 화면으로 보낸다. `릴리즈 ${title} 초안을 만들었습니다. 목록에서 편집을 눌러 내용을 채웁니다.`,
navigate(`/studio/releases/${created.id}`); );
setCreatedId(created.id);
reload();
} catch (error) { } catch (error) {
setError(managementFailureMessage(error, "릴리즈를 만들지 못했습니다.")); setError(managementFailureMessage(error, "릴리즈를 만들지 못했습니다."));
} finally { } finally {
@@ -158,82 +138,6 @@ export function ReleaseManager() {
} }
}; };
const requestOf = (current: NonNullable<typeof draft>): ReleaseUpdateRequest =>
({
expectedVersion: current.expectedVersion,
versionLabel: current.versionLabel.trim(),
title: current.title.trim(),
summary: current.summary,
changeTypes: [...current.changeTypes],
changesMarkdown: current.changesMarkdown,
verificationMarkdown: current.verificationMarkdown,
...(current.releasedOn ? { releasedOn: current.releasedOn } : {}),
reasonMarkdown: current.reasonMarkdown,
userImpactMarkdown: current.userImpactMarkdown,
implementationImpactMarkdown: current.implementationImpactMarkdown,
knownLimitationsMarkdown: current.knownLimitationsMarkdown,
}) as ReleaseUpdateRequest;
const save = async () => {
if (pending || draft === null || selectedId === null) return;
setPending(true);
setError("");
try {
const saved = await managementGateway.updateRelease(selectedId, requestOf(draft));
setDraft(toDraft(saved));
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
reload();
} catch (error) {
setError(managementFailureMessage(error, "저장하지 못했습니다."));
} finally {
setPending(false);
}
};
const publish = async () => {
if (pending || draft === null || selectedId === null) return;
setPending(true);
setError("");
try {
/*
먼저 저장한다. 발행은 서버에 저장된 릴리즈를 검사하는데(`PublishReleaseUseCase`), 예전에는
저장하지 않고 발행만 불렀다 — 화면의 칸을 다 채우고 공개를 눌러도 서버 쪽은 여전히 빈
초안이라 "모두 채워져야 합니다" 가 떴다. 채웠는데 안 된다는 말이 나온 이유가 이것이다.
*/
const saved = await managementGateway.updateRelease(selectedId, requestOf(draft));
setDraft(toDraft(saved));
const published = await managementGateway.publishRelease(selectedId, saved.version);
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
reload();
} catch (error) {
// 무엇이 모자란지는 서버가 안다. 예전에는 그 답을 버리고 필수 항목을 전부 나열했는데,
// 그러면 이미 채운 칸까지 비었다고 말하게 된다.
setError(
managementFailureMessage(
error,
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
),
);
} 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 (error) {
setError(managementFailureMessage(error, "공개에서 내리지 못했습니다."));
} finally {
setPending(false);
}
};
const remove = async (release: ReleaseIndexItem) => { const remove = async (release: ReleaseIndexItem) => {
if (pending) return; if (pending) return;
setPending(true); setPending(true);
@@ -249,14 +153,6 @@ export function ReleaseManager() {
} }
}; };
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 ( return (
<div className="studio-page studio-documents-page"> <div className="studio-page studio-documents-page">
<header className="studio-page-top"> <header className="studio-page-top">
@@ -303,6 +199,7 @@ export function ReleaseManager() {
<article <article
key={release.id} key={release.id}
className="studio-document-row" className="studio-document-row"
aria-current={createdId === release.id ? "true" : undefined}
> >
{/* {/*
첫 칸의 유형 라벨은 목록 격자의 첫 열이다. 빠뜨리면 제목이 그 자리로 당겨져 첫 칸의 유형 라벨은 목록 격자의 첫 열이다. 빠뜨리면 제목이 그 자리로 당겨져
@@ -311,7 +208,7 @@ export function ReleaseManager() {
<p className="studio-row-label">RELEASE</p> <p className="studio-row-label">RELEASE</p>
<div className="studio-document-title"> <div className="studio-document-title">
<h2>{release.title}</h2> <h2>{release.title}</h2>
<p>{release.summary || "요약 없음"}</p> <p>{release.publication.canonicalPath ?? "아직 공개하지 않음"}</p>
</div> </div>
<dl> <dl>
<div> <div>
@@ -19,6 +19,7 @@ export type MessageParameters = Readonly<{
"action.alertCloseNamed": { title: string }; "action.alertCloseNamed": { title: string };
"route.loadingNamed": { title: string }; "route.loadingNamed": { title: string };
"route.documentTitle": { title: string; appName: string }; "route.documentTitle": { title: string; appName: string };
"route.auth.returnTo": { path: string };
"template.supportReference": { reference: string }; "template.supportReference": { reference: string };
"form.remaining": { count: number }; "form.remaining": { count: number };
"boot.supportReference": { reference: string }; "boot.supportReference": { reference: string };