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:
co-authored by
Claude Opus 5
parent
84d72c4f60
commit
e9b866184a
@@ -1,6 +1,6 @@
|
||||
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 {
|
||||
HomeFocusResponse,
|
||||
ProjectIndexItem,
|
||||
@@ -9,6 +9,8 @@ import { managementFailureMessage } from "../../../application/ports/management-
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
|
||||
/**
|
||||
* 공개 홈의 "지금 집중하는 것" 을 정하는 화면.
|
||||
*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 {
|
||||
ProjectActivityResponse,
|
||||
ProjectEditResponse,
|
||||
@@ -12,6 +12,13 @@ import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { slugFromName } from "./slug-from-name.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` 와 같은 집합이다 — 화면이 더 많은 값을 보여 주면 저장이 제약에서 터진다. */
|
||||
const PHASES = [
|
||||
{ value: "RESEARCH", label: "조사" },
|
||||
@@ -99,7 +106,7 @@ function toDraft(project: ProjectEditResponse): Draft {
|
||||
*/
|
||||
export function ProjectEditor() {
|
||||
const { params } = useRouteInput<"TECH_LOG_STUDIO_PROJECT_EDIT">();
|
||||
const projectId = params.id;
|
||||
const projectId = routeId(params.id);
|
||||
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
|
||||
|
||||
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() {
|
||||
const { params } = useRouteInput<"TECH_LOG_STUDIO_RELEASE_EDIT">();
|
||||
const releaseId = params.id;
|
||||
const releaseId = routeId(params.id);
|
||||
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ReleaseUpdateRequest,
|
||||
} from "../../../contracts/management/contract.ts";
|
||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
/**
|
||||
@@ -81,13 +82,13 @@ const STATUS_LABELS: Readonly<Record<string, string>> = {
|
||||
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 [createdId, setCreatedId] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
@@ -112,29 +113,6 @@ export function ReleaseManager() {
|
||||
};
|
||||
}, [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;
|
||||
@@ -148,9 +126,11 @@ export function ReleaseManager() {
|
||||
try {
|
||||
const created = await managementGateway.createRelease(title);
|
||||
setNewTitle("");
|
||||
setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`);
|
||||
// 만들자마자 편집할 것이 분명하므로 그 화면으로 보낸다.
|
||||
navigate(`/studio/releases/${created.id}`);
|
||||
setRequestAnnouncement(
|
||||
`릴리즈 ${title} 초안을 만들었습니다. 목록에서 편집을 눌러 내용을 채웁니다.`,
|
||||
);
|
||||
setCreatedId(created.id);
|
||||
reload();
|
||||
} catch (error) {
|
||||
setError(managementFailureMessage(error, "릴리즈를 만들지 못했습니다."));
|
||||
} 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) => {
|
||||
if (pending) return;
|
||||
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 (
|
||||
<div className="studio-page studio-documents-page">
|
||||
<header className="studio-page-top">
|
||||
@@ -303,6 +199,7 @@ export function ReleaseManager() {
|
||||
<article
|
||||
key={release.id}
|
||||
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>
|
||||
<div className="studio-document-title">
|
||||
<h2>{release.title}</h2>
|
||||
<p>{release.summary || "요약 없음"}</p>
|
||||
<p>{release.publication.canonicalPath ?? "아직 공개하지 않음"}</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
|
||||
@@ -19,6 +19,7 @@ export type MessageParameters = Readonly<{
|
||||
"action.alertCloseNamed": { title: string };
|
||||
"route.loadingNamed": { title: string };
|
||||
"route.documentTitle": { title: string; appName: string };
|
||||
"route.auth.returnTo": { path: string };
|
||||
"template.supportReference": { reference: string };
|
||||
"form.remaining": { count: number };
|
||||
"boot.supportReference": { reference: string };
|
||||
|
||||
Reference in New Issue
Block a user