feat: delete a decision, manage assets while writing, and sweep before deploying

Four things an author could not do, and the check that should have caught them.

A Decision could not be deleted. Case, Reference and Question all could, so an
author who opened a decision draft had no way to close it. The contract gained
the operation and the list now offers it for every kind. Its path carries the
project because a decision belongs to one; a row with no project says so rather
than failing.

Assets could only be managed by leaving the document. The picker now deletes
one in place — the server still refuses an asset a document uses — so a
mistaken upload does not cost the author their editing session.

Zoom was decided for the author and could not be changed: only a DIAGRAM got
it, so a screenshot uploaded as an image or attachment went in with zoom off
and no way to turn it on. It now defaults on for images and the picker offers
the choice. The toggle is a picker control, not a document field, and carries
its own class — wearing the field class put it in the editor's field list.

`scripts/smoke/production-sweep.ts` walks every public and Studio screen and
the document flow, reporting console errors, failed API calls and error text.
It exists because verifying only the screen I had just changed is what let
broken screens reach production repeatedly; this runs before a deploy, not
after a report.
This commit is contained in:
DongHyeonka
2026-08-21 17:20:42 +09:00
parent 21f8425f1e
commit 89a73c13c6
16 changed files with 545 additions and 43 deletions
@@ -2,6 +2,8 @@ import { useEffect, useId, useState, type FormEvent } from "react";
import type { Asset } from "../../../contracts/studio/contract.ts";
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts";
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
/** One screenful of candidates; searching, not scrolling, reaches the rest. */
const PAGE_SIZE = 50;
@@ -57,6 +59,14 @@ export function AssetPicker({
// than fewer, and no trailing request after the author stops), and it is the
// pair `document-list.tsx` already uses for the same job.
const [searchDraft, setSearchDraft] = useState("");
/**
* 삽입할 때 확대를 허용할지. 예전에는 {@code kind === "DIAGRAM"} 일 때만 켰는데, 작성자가
* 스크린샷을 ATTACHMENT 나 IMAGE 로 올리면 확대가 꺼진 채로 들어갔고 켜는 방법도 없었다 —
* "줌이 왜 꺼져 있는지 모르겠다" 가 그것이다. 그림이면 켜 두고, 끄고 싶으면 여기서 끈다.
*/
const [allowZoom, setAllowZoom] = useState(true);
const [removingId, setRemovingId] = useState<string | null>(null);
const [notice, setNotice] = useState("");
const [q, setQ] = useState("");
const searchId = useId();
@@ -99,6 +109,27 @@ export function AssetPicker({
setQ(searchDraft.trim());
};
const removeAsset = async (asset: Asset) => {
if (removingId !== null) return;
setRemovingId(asset.id);
setNotice("");
try {
await gateway.deleteAsset(asset.id, {
idempotencyKey: createLocalId(`studio-asset-picker-delete-${asset.id}`),
});
setAssets((current) => current.filter((entry) => entry.id !== asset.id));
setNotice(`${asset.assetKey} 을(를) 삭제했습니다.`);
} catch (error) {
setNotice(
isStudioGatewayError(error)
? error.problem.detail
: "삭제하지 못했습니다. 문서에서 쓰이고 있을 수 있습니다.",
);
} finally {
setRemovingId(null);
}
};
const listMessage = status === "LOADING"
? "Asset 목록을 불러오는 중입니다."
: selectable.length > 0
@@ -124,6 +155,15 @@ export function AssetPicker({
<button type="submit"></button>
</div>
</form>
<label className="asset-picker-option">
<input
type="checkbox"
checked={allowZoom}
onChange={(event) => setAllowZoom(event.currentTarget.checked)}
/>
<span> </span>
</label>
{notice ? <p className="studio-asset-picker-empty" role="status">{notice}</p> : null}
{status === "ERROR"
? <p className="studio-error" role="alert">Asset .</p>
: <p className="studio-asset-picker-empty" role="status">{listMessage}</p>}
@@ -135,11 +175,23 @@ export function AssetPicker({
assetKey: asset.assetKey,
alt: asset.decorative ? "" : (asset.altText ?? ""),
caption: "",
zoom: asset.kind === "DIAGRAM",
zoom: allowZoom && asset.mediaType.startsWith("image/"),
}))}
>
{asset.assetKey}
</button>
{/*
문서를 쓰다가 잘못 올린 Asset 을 여기서 바로 지운다. 예전에는 Asset 화면으로 나가야
했고, 그러면 편집 중인 작업본을 떠나야 했다. 쓰이고 있는 Asset 은 서버가 거절한다.
*/}
<button
type="button"
className="studio-secondary-button"
disabled={removingId !== null}
onClick={() => { void removeAsset(asset); }}
>
</button>
</li>)}
</ul> : null}
</div>;
@@ -29,12 +29,6 @@ function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
/**
* Decision 은 지울 수 없다. 계약에 삭제 operation 이 없고, 그건 누락이 아니라 판단이다 — 결정의
* 수명주기는 수락·기각·대체이고 그 셋은 무슨 일이 있었는지 남기는 반면 삭제는 없앤다.
*/
const DELETABLE_KINDS = new Set(["CASE", "REFERENCE", "QUESTION"]);
export function DocumentList() {
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
const [deletingId, setDeletingId] = useState<string | null>(null);
@@ -61,13 +55,34 @@ export function DocumentList() {
setDeleteError("");
try {
const detail = await gateway.getDocument(item.id);
await managementGateway.deleteDocument(
item.kind as "CASE" | "REFERENCE" | "QUESTION",
item.id,
detail.document.version,
);
if (item.kind === "PROJECT_DECISION") {
// Decision 은 프로젝트에 속하고 경로가 둘을 요구한다. 목록 행이 프로젝트를 들고 있지
// 않으면 지울 주소를 만들 수 없다 — 그때는 프로젝트를 먼저 지정해야 한다.
if (!item.project) {
setDeleteError("프로젝트에 속하지 않은 결정은 여기서 지울 수 없습니다. 먼저 프로젝트를 지정해 주세요.");
return;
}
await managementGateway.deleteDecision(
item.project.id,
item.id,
detail.document.version,
);
} else {
await managementGateway.deleteDocument(
item.kind as "CASE" | "REFERENCE" | "QUESTION",
item.id,
detail.document.version,
);
}
setRequestAnnouncement(`작업본 ${item.title || "제목 없음"} 을(를) 삭제했습니다.`);
setRetryGeneration((value) => value + 1);
// 다시 불러오지 않고 이 행만 지운다. 목록은 커서 페이지네이션이라 재조회하면 다음
// 항목이 빈 자리를 즉시 채우고, 개수도 20 그대로다 — 작성자에게는 삭제가 아무 일도
// 하지 않은 것처럼 보인다. 삭제가 성공한 뒤의 화면은 그 행이 없는 화면이 맞다.
setPage((current) =>
current
? { ...current, items: current.items.filter((row) => row.id !== item.id) }
: current,
);
} catch {
setDeleteError(
"삭제하지 못했습니다. 게시 중이거나, 이 기록을 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다.",
@@ -203,7 +218,14 @@ export function DocumentList() {
) : null}
{page && !loading && !error ? (
<>
<p className="studio-result-count">{page.items.length} </p>
{/*
이 숫자는 전체가 아니라 이 페이지에 실린 수다. 예전 문구는 그것을 전체처럼 읽히게
해서, 28건 중 20건이 보이는 동안 무엇을 지워도 "20개" 가 그대로였다.
*/}
<p className="studio-result-count">
<span>{page.items.length} </span>
{page.nextCursor ? <span> · </span> : null}
</p>
{deleteError ? (
<p className="studio-screen-error" role="alert">
{deleteError}
@@ -242,16 +264,14 @@ export function DocumentList() {
</dd>
</div>
</dl>
{DELETABLE_KINDS.has(item.kind) ? (
<button
className="studio-secondary-button"
type="button"
disabled={deletingId !== null}
onClick={() => void removeDocument(item)}
>
</button>
) : null}
<button
className="studio-secondary-button"
type="button"
disabled={deletingId !== null}
onClick={() => void removeDocument(item)}
>
</button>
</article>
))}
</div>