From e9b866184a7c7b88bc89e15b43d96c4ba344c5de Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Mon, 24 Aug 2026 01:47:41 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=EC=84=9C=20=ED=8E=B8=EC=A7=91=20=ED=9D=94?= =?UTF-8?q?=EC=A0=81=EC=9D=84=20=EA=B1=B7=EC=96=B4=EB=82=B4=EA=B3=A0,=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EA=B2=80=EC=82=AC=EA=B0=80=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=EB=A1=9C=20=EB=8F=8C=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋에서 편집 폼만 떼어 내고 그것을 움직이던 상태와 핸들러를 목록에 남겨 두었다. `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) Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu --- .../studio/components/home-focus-editor.tsx | 4 +- .../studio/components/project-editor.tsx | 11 +- .../studio/components/release-editor.tsx | 13 +- .../studio/components/release-manager.tsx | 123 ++---------------- src/presentation/i18n/message-contract.ts | 1 + 5 files changed, 35 insertions(+), 117 deletions(-) diff --git a/src/features/tech-log/presentation/studio/components/home-focus-editor.tsx b/src/features/tech-log/presentation/studio/components/home-focus-editor.tsx index 1a0f293..b2214dc 100644 --- a/src/features/tech-log/presentation/studio/components/home-focus-editor.tsx +++ b/src/features/tech-log/presentation/studio/components/home-focus-editor.tsx @@ -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"]; + /** * 공개 홈의 "지금 집중하는 것" 을 정하는 화면. * diff --git a/src/features/tech-log/presentation/studio/components/project-editor.tsx b/src/features/tech-log/presentation/studio/components/project-editor.tsx index b6c58a0..0e00910 100644 --- a/src/features/tech-log/presentation/studio/components/project-editor.tsx +++ b/src/features/tech-log/presentation/studio/components/project-editor.tsx @@ -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(null); diff --git a/src/features/tech-log/presentation/studio/components/release-editor.tsx b/src/features/tech-log/presentation/studio/components/release-editor.tsx index 0c39e6c..3fbc4e2 100644 --- a/src/features/tech-log/presentation/studio/components/release-editor.tsx +++ b/src/features/tech-log/presentation/studio/components/release-editor.tsx @@ -71,9 +71,20 @@ function toDraft(release: ReleaseEditResponse): Draft { }; } +const STATUS_LABELS: Readonly> = { + 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(null); diff --git a/src/features/tech-log/presentation/studio/components/release-manager.tsx b/src/features/tech-log/presentation/studio/components/release-manager.tsx index 801303f..7e129d8 100644 --- a/src/features/tech-log/presentation/studio/components/release-manager.tsx +++ b/src/features/tech-log/presentation/studio/components/release-manager.tsx @@ -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> = { export function ReleaseManager() { const { managementGateway, setRequestAnnouncement } = useStudio(); const [releases, setReleases] = useState(null); - const [selectedId, setSelectedId] = useState(null); - const [draft, setDraft] = useState(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(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) => - 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): 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 (
@@ -303,6 +199,7 @@ export function ReleaseManager() {
{/* 첫 칸의 유형 라벨은 목록 격자의 첫 열이다. 빠뜨리면 제목이 그 자리로 당겨져 @@ -311,7 +208,7 @@ export function ReleaseManager() {

RELEASE

{release.title}

-

{release.summary || "요약 없음"}

+

{release.publication.canonicalPath ?? "아직 공개하지 않음"}

diff --git a/src/presentation/i18n/message-contract.ts b/src/presentation/i18n/message-contract.ts index a5dc294..1e0efc3 100644 --- a/src/presentation/i18n/message-contract.ts +++ b/src/presentation/i18n/message-contract.ts @@ -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 };