feat: 릴리즈 편집을 자기 주소로 옮긴다
편집 폼이 릴리즈 목록 아래에 열렸다. 릴리즈가 열 개 스무 개로 늘면 편집하려고 목록 전체를 지나 내려가야 하고, 저장 버튼은 그보다 더 아래에 있다. 목록이 길어질수록 편집이 멀어지는 구조다. `/studio/releases/:id` 를 연다 — 문서와 프로젝트가 각자 편집 주소를 갖는 것과 같은 이유다. 목록의 "편집" 은 토글이 아니라 링크가 되고, 새 릴리즈를 만들면 곧바로 그 화면으로 간다(만들자마자 편집할 것이 분명하다). 라우트가 하나 늘어 CI 게이트가 함께 움직였다 — 아티팩트 기준선 133→134, 증거 개수 112→113, 게이트 형태 다이제스트 재계산(f9e7e521… 을 이전 gates.json 에서 먼저 재현해 계산 방법을 확인했다), 서빙 패턴 하나 추가. 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
6b9dc3f0a2
commit
84d72c4f60
@@ -0,0 +1,18 @@
|
||||
# TECH_LOG_STUDIO_RELEASE_EDIT accessibility review
|
||||
|
||||
Status: pending-manual-review
|
||||
Route ID: TECH_LOG_STUDIO_RELEASE_EDIT
|
||||
Release ID:
|
||||
Reviewer:
|
||||
Reviewed at:
|
||||
Signature:
|
||||
Attestation: pending
|
||||
M1 Keyboard: pending
|
||||
M2 Visible focus: pending
|
||||
M3 Route focus: pending
|
||||
M4 Modal focus: pending
|
||||
M5 Error association: pending
|
||||
M6 Color signal: pending
|
||||
M7 Reduced motion: pending
|
||||
Screen reader: pending
|
||||
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
|
||||
@@ -1214,6 +1214,12 @@
|
||||
"schemaId": "markdown",
|
||||
"production": "source-controlled"
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
|
||||
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASE_EDIT.md",
|
||||
"schemaId": "markdown",
|
||||
"production": "source-controlled"
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
||||
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md",
|
||||
@@ -1970,6 +1976,7 @@
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
||||
"artifact-artifacts-tests-a11y-manual-NOT-FOUND-md",
|
||||
"artifact-artifacts-tests-a11y-manual-report-json"
|
||||
|
||||
@@ -471,7 +471,9 @@ const CANONICAL_GATE_SHAPE_SHA256 =
|
||||
// 프로젝트 편집 화면: FE-GATE-009 가 TECH_LOG_STUDIO_PROJECT_EDIT 증거 아티팩트를
|
||||
// 얻어 다시 계산했다. 같은 방법이다 — 187dbd96… 을 이전 gates.json 에서 먼저 재현해,
|
||||
// 이 값을 만든 계산이 상수가 고정될 때 쓰인 그 계산임을 확인했다.
|
||||
"f9e7e521c1ea587c992f475e38f0c5f2ca39721970724529fa98678cc078a6c7";
|
||||
// 릴리즈 편집 화면: 같은 방법으로 다시 계산했다. f9e7e521… 을 이전 gates.json 에서 먼저
|
||||
// 재현했다.
|
||||
"fb138e7c51fdf969f755cd8ff32cf627f1750b966d212c33ee996c8c578db0e3";
|
||||
|
||||
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
||||
const normalized = gates.map(
|
||||
@@ -529,8 +531,10 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
|
||||
// The project edit route did the same: it is what finally lets a project carry
|
||||
// a purpose, a current objective, and a next step, so the public screens that
|
||||
// read those fields stop rendering blanks.
|
||||
if (contract.artifacts.length !== 133) {
|
||||
failures.push(`artifact authority baseline must contain exactly 133 artifacts; received ${contract.artifacts.length}`);
|
||||
// The release edit route followed: the editor used to open below the release
|
||||
// list, so editing meant scrolling past every release to reach it.
|
||||
if (contract.artifacts.length !== 134) {
|
||||
failures.push(`artifact authority baseline must contain exactly 134 artifacts; received ${contract.artifacts.length}`);
|
||||
}
|
||||
if (contract.stages.length !== 5) {
|
||||
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
||||
|
||||
@@ -51,6 +51,7 @@ const TECH_LOG_ROUTE_SPECS = [
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }),
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_PROJECT_EDIT", path: "/studio/projects/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "프로젝트 편집", navigationLabel: null, navigationOrder: null }),
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }),
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASE_EDIT", path: "/studio/releases/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "릴리즈 편집", navigationLabel: null, navigationOrder: null }),
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }),
|
||||
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type {
|
||||
ReleaseEditResponse,
|
||||
ReleaseUpdateRequest,
|
||||
} from "../../../contracts/management/contract.ts";
|
||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
/**
|
||||
* 릴리즈 한 편의 편집 화면.
|
||||
*
|
||||
* <p>예전에는 이 폼이 릴리즈 목록 <em>아래</em>에 열렸다. 릴리즈가 열 개 스무 개로 늘면 편집하려고
|
||||
* 목록 전체를 지나 내려가야 하고, 저장 버튼은 그보다 더 아래에 있다. 문서·프로젝트가 각자 편집
|
||||
* 주소를 갖는 것과 같은 이유로 릴리즈도 자기 주소를 갖는다.
|
||||
*
|
||||
* <p>본문이 마크다운 여섯 칸으로 나뉜 것은 계약의 모양이자 릴리즈 노트의 성격이다. 한 칸짜리 자유
|
||||
* 서술이었다면 "검증을 안 썼다"를 아무도 알아채지 못한다.
|
||||
*/
|
||||
|
||||
/** 백엔드 {@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 ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function ReleaseEditor() {
|
||||
const { params } = useRouteInput<"TECH_LOG_STUDIO_RELEASE_EDIT">();
|
||||
const releaseId = params.id;
|
||||
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [status, setStatus] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void managementGateway.getRelease(releaseId).then(
|
||||
(release) => {
|
||||
if (cancelled) return;
|
||||
setDraft(toDraft(release));
|
||||
setStatus(release.workflowStatus);
|
||||
setError("");
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) setError("릴리즈를 불러오지 못했습니다.");
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [managementGateway, releaseId, generation]);
|
||||
|
||||
const update = (patch: Partial<Draft>) =>
|
||||
setDraft((current) => (current === null ? current : { ...current, ...patch }));
|
||||
|
||||
const requestOf = (current: 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) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = await managementGateway.updateRelease(releaseId, requestOf(draft));
|
||||
setDraft(toDraft(saved));
|
||||
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
|
||||
reload();
|
||||
} catch (failure) {
|
||||
setError(managementFailureMessage(failure, "저장하지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const publish = async () => {
|
||||
if (pending || draft === null) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
/*
|
||||
먼저 저장한다. 발행은 서버에 저장된 릴리즈를 검사하는데(`PublishReleaseUseCase`), 예전에는
|
||||
저장하지 않고 발행만 불렀다 — 화면의 칸을 다 채우고 공개를 눌러도 서버 쪽은 여전히 빈
|
||||
초안이라 "모두 채워져야 합니다" 가 떴다. 채웠는데 안 된다는 말이 나온 이유가 이것이다.
|
||||
*/
|
||||
const saved = await managementGateway.updateRelease(releaseId, requestOf(draft));
|
||||
setDraft(toDraft(saved));
|
||||
const published = await managementGateway.publishRelease(releaseId, saved.version);
|
||||
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
|
||||
reload();
|
||||
} catch (failure) {
|
||||
// 무엇이 모자란지는 서버가 안다. 예전에는 그 답을 버리고 필수 항목을 전부 나열했는데,
|
||||
// 그러면 이미 채운 칸까지 비었다고 말하게 된다.
|
||||
setError(
|
||||
managementFailureMessage(
|
||||
failure,
|
||||
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const archive = async () => {
|
||||
if (pending || draft === null) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.archiveRelease(releaseId, draft.expectedVersion);
|
||||
setRequestAnnouncement("릴리즈를 공개에서 내렸습니다.");
|
||||
reload();
|
||||
} catch (failure) {
|
||||
setError(managementFailureMessage(failure, "공개에서 내리지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleChangeType = (value: string) => {
|
||||
if (draft === null) return;
|
||||
update({
|
||||
changeTypes: draft.changeTypes.includes(value)
|
||||
? draft.changeTypes.filter((entry) => entry !== value)
|
||||
: [...draft.changeTypes, value],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-page studio-editor-page">
|
||||
<header className="studio-page-top">
|
||||
<div className="studio-page-heading">
|
||||
<p className="studio-eyebrow">
|
||||
RELEASE · {STATUS_LABELS[status] ?? status}
|
||||
</p>
|
||||
<h1>{draft?.title || "릴리즈"}</h1>
|
||||
<p>공개 변경 기록 화면과 푸터 링크가 이 값을 그대로 읽습니다.</p>
|
||||
</div>
|
||||
<GuardedStudioLink className="studio-primary-action" href="/studio/releases">
|
||||
릴리즈 목록으로
|
||||
</GuardedStudioLink>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<p className="studio-screen-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{draft === null && !error ? (
|
||||
<p className="studio-loading" role="status">
|
||||
릴리즈를 불러오는 중입니다.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{draft === null ? null : (
|
||||
<>
|
||||
<section className="studio-editor-section" aria-labelledby="studio-release-edit-title">
|
||||
<div className="studio-editor-section-heading">
|
||||
<p className="studio-eyebrow">RELEASE · {draft.versionLabel}</p>
|
||||
<h2 id="studio-release-edit-title">{draft.title || "릴리즈"}</h2>
|
||||
<p>변경 기록 화면과 푸터 링크가 이 값을 그대로 읽습니다.</p>
|
||||
</div>
|
||||
<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 })}
|
||||
/>
|
||||
{/*
|
||||
새 릴리즈는 `draft-…` 라는 자리표시자 버전으로 만들어지고, 서버는 그것이 남아
|
||||
있으면 공개를 거절한다(`ReleaseDrafts.isPlaceholder`). 화면에는 값이 채워져
|
||||
보이므로 왜 거절당하는지 알 길이 없었다.
|
||||
*/}
|
||||
{draft.versionLabel.startsWith("draft-") ? (
|
||||
<span className="studio-field-notice studio-field-notice--error" role="alert">
|
||||
자리표시자 버전입니다. 공개하려면 실제 버전으로 바꿔 주세요 (예: 0.2.0).
|
||||
</span>
|
||||
) : null}
|
||||
</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-checkbox-set">
|
||||
<legend>변경 유형</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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -149,8 +149,8 @@ export function ReleaseManager() {
|
||||
const created = await managementGateway.createRelease(title);
|
||||
setNewTitle("");
|
||||
setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`);
|
||||
setSelectedId(created.id);
|
||||
reload();
|
||||
// 만들자마자 편집할 것이 분명하므로 그 화면으로 보낸다.
|
||||
navigate(`/studio/releases/${created.id}`);
|
||||
} catch (error) {
|
||||
setError(managementFailureMessage(error, "릴리즈를 만들지 못했습니다."));
|
||||
} finally {
|
||||
@@ -240,7 +240,6 @@ export function ReleaseManager() {
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.deleteRelease(release.id, release.version);
|
||||
if (selectedId === release.id) setSelectedId(null);
|
||||
setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`);
|
||||
reload();
|
||||
} catch (error) {
|
||||
@@ -304,7 +303,6 @@ export function ReleaseManager() {
|
||||
<article
|
||||
key={release.id}
|
||||
className="studio-document-row"
|
||||
aria-current={selectedId === release.id ? "true" : undefined}
|
||||
>
|
||||
{/*
|
||||
첫 칸의 유형 라벨은 목록 격자의 첫 열이다. 빠뜨리면 제목이 그 자리로 당겨져
|
||||
@@ -330,14 +328,12 @@ export function ReleaseManager() {
|
||||
</div>
|
||||
</dl>
|
||||
<div className="studio-row-actions">
|
||||
<button
|
||||
<GuardedStudioLink
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => setSelectedId(selectedId === release.id ? null : release.id)}
|
||||
href={`/studio/releases/${release.id}`}
|
||||
>
|
||||
{selectedId === release.id ? "닫기" : "편집"}
|
||||
</button>
|
||||
편집
|
||||
</GuardedStudioLink>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
@@ -353,112 +349,6 @@ export function ReleaseManager() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{draft === null ? null : (
|
||||
<section className="studio-editor-section" aria-labelledby="studio-release-edit-title">
|
||||
<div className="studio-editor-section-heading">
|
||||
<p className="studio-eyebrow">RELEASE · {draft.versionLabel}</p>
|
||||
<h2 id="studio-release-edit-title">{draft.title || "릴리즈"}</h2>
|
||||
<p>변경 기록 화면과 푸터 링크가 이 값을 그대로 읽습니다.</p>
|
||||
</div>
|
||||
<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 })}
|
||||
/>
|
||||
{/*
|
||||
새 릴리즈는 `draft-…` 라는 자리표시자 버전으로 만들어지고, 서버는 그것이 남아
|
||||
있으면 공개를 거절한다(`ReleaseDrafts.isPlaceholder`). 화면에는 값이 채워져
|
||||
보이므로 왜 거절당하는지 알 길이 없었다.
|
||||
*/}
|
||||
{draft.versionLabel.startsWith("draft-") ? (
|
||||
<span className="studio-field-notice studio-field-notice--error" role="alert">
|
||||
자리표시자 버전입니다. 공개하려면 실제 버전으로 바꿔 주세요 (예: 0.2.0).
|
||||
</span>
|
||||
) : null}
|
||||
</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-checkbox-set">
|
||||
<legend>변경 유형</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 { ReleaseEditor } from "../components/release-editor.tsx";
|
||||
|
||||
export function ReleaseEditPage() {
|
||||
return <ReleaseEditor />;
|
||||
}
|
||||
@@ -230,6 +230,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
|
||||
"StudioReleasesPage",
|
||||
),
|
||||
),
|
||||
TECH_LOG_STUDIO_RELEASE_EDIT: runtime(
|
||||
"TECH_LOG_STUDIO_RELEASE_EDIT",
|
||||
routeModule(
|
||||
() => import("./studio/pages/release-edit-page.tsx"),
|
||||
"ReleaseEditPage",
|
||||
),
|
||||
),
|
||||
TECH_LOG_STUDIO_NOT_FOUND: runtime(
|
||||
"TECH_LOG_STUDIO_NOT_FOUND",
|
||||
routeModule(
|
||||
|
||||
@@ -200,8 +200,8 @@ describe("CI gate contract", () => {
|
||||
// than taking either side's number.
|
||||
// Task 11 added TECH_LOG_STUDIO_ASSETS's manual accessibility evidence.
|
||||
// Alignment follow-up item 2 added the TechLog junit report.
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(112);
|
||||
expect(contract.artifacts).toHaveLength(133);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(113);
|
||||
expect(contract.artifacts).toHaveLength(134);
|
||||
expect(contract.stages).toHaveLength(5);
|
||||
expect(contract.retention.classes).toHaveLength(5);
|
||||
expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2);
|
||||
|
||||
@@ -192,7 +192,7 @@ describe("selective Task 3 contract closure", () => {
|
||||
expect(canonical.gates).toHaveLength(27);
|
||||
expect(canonical.commands).toHaveLength(85);
|
||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(97);
|
||||
expect(canonical.artifacts).toHaveLength(133);
|
||||
expect(canonical.artifacts).toHaveLength(134);
|
||||
expect(canonical.stages).toHaveLength(5);
|
||||
expect(canonical.retention.classes).toHaveLength(5);
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ describe("TechLog production serving contract", () => {
|
||||
"^/studio/publications$",
|
||||
"^/studio/publications/[^/]+/preview$",
|
||||
"^/studio/releases$",
|
||||
"^/studio/releases/[^/]+$",
|
||||
"^/studio/taxonomy$",
|
||||
]);
|
||||
expect(contract.notFound).toEqual({
|
||||
|
||||
@@ -34,6 +34,7 @@ const techLogRouteChunks = Object.freeze({
|
||||
"/studio/pages/taxonomy-page.tsx": "route-tech-log-studio-taxonomy",
|
||||
"/studio/pages/project-edit-page.tsx": "route-tech-log-studio-project-edit",
|
||||
"/studio/pages/releases-page.tsx": "route-tech-log-studio-releases",
|
||||
"/studio/pages/release-edit-page.tsx": "route-tech-log-studio-release-edit",
|
||||
"/studio/pages/studio-not-found-page.tsx": "route-tech-log-studio-not-found",
|
||||
"/public/pages/public-not-found-page.tsx": "route-not-found",
|
||||
} as const);
|
||||
|
||||
Reference in New Issue
Block a user