fix: give a document a slug, and say so when saving fails
Validation reported a slug the author could see on screen as missing. Both halves of that were the editor's fault. The document slug must match `^[a-z0-9]+(?:-[a-z0-9]+)*$`, which the editor never said and never helped with. A Korean slug was rejected by the server with a 422 carrying no details — and the editor answered that only through `setRequestAnnouncement`, which is an aria-live region and shows a sighted author nothing at all. The value stayed in the field, so it looked saved. Then validation, which reads the saved version by design, correctly reported no slug, and the author read that as the tool contradicting itself. An empty slug is now derived from the title, romanizing Hangul the same way topic slugs do, so a Korean title produces a valid slug and the author never has to learn the rule. A slug that cannot work is refused before the request, naming the rule instead of letting the server answer with an unexplained 422. Every save failure now renders where the author is looking, not only where a screen reader would hear it. The rail shows one message rather than two: a conflict already says what to do, so it outranks the server's wording, and everything else shows the server's reason.
This commit is contained in:
@@ -22,7 +22,7 @@ export function CommonDocumentFields({
|
||||
<div className="studio-editor-section-heading"><p className="studio-eyebrow">DOCUMENT</p><h2 id="studio-common-fields-title">기본 정보</h2></div>
|
||||
<div className="studio-field-grid">
|
||||
<label className="studio-field studio-field--wide"><span>제목</span><input value={draft.title} maxLength={120} onChange={(event) => onUpdate({ title: event.currentTarget.value })} /></label>
|
||||
<label className="studio-field"><span>slug</span><input value={draft.slug} maxLength={100} onChange={(event) => onUpdate({ slug: event.currentTarget.value as WorkingCopyInput["slug"] })} /></label>
|
||||
<label className="studio-field"><span>slug</span><input value={draft.slug} maxLength={100} placeholder="비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)" onChange={(event) => onUpdate({ slug: event.currentTarget.value as WorkingCopyInput["slug"] })} /></label>
|
||||
<label className="studio-field studio-field--wide"><span>요약</span><textarea value={draft.summary} maxLength={300} onChange={(event) => onUpdate({ summary: event.currentTarget.value })} /></label>
|
||||
<label className="studio-field"><span>Topic</span><select value={draft.topicId ?? ""} onChange={(event) => onUpdate({ topicId: event.currentTarget.value || null })}><option value="">선택하지 않음</option>{topics.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
||||
<label className="studio-field"><span>Project</span><select value={draft.projectId ?? ""} onChange={(event) => onUpdate({ projectId: event.currentTarget.value || null })}><option value="">미지정</option>{projects.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
||||
|
||||
@@ -8,6 +8,12 @@ export type DocumentEditorController = {
|
||||
saved: WorkingCopy;
|
||||
draft: WorkingCopyInput;
|
||||
status: StudioEditorStatus;
|
||||
/**
|
||||
* 저장이 실패한 이유. 이전에는 `setRequestAnnouncement` 로만 알렸는데 그것은 aria-live 라
|
||||
* 눈에는 아무것도 보이지 않았다 — 한글 slug 로 저장이 422 로 거절돼도 값은 화면에 그대로
|
||||
* 남아 있어, 작성자는 저장된 줄 알고 검증에서 "slug 이 없다" 를 만났다.
|
||||
*/
|
||||
saveError: string;
|
||||
update(patch: Partial<WorkingCopyInput>): void;
|
||||
replace(draft: WorkingCopyInput): void;
|
||||
save(): Promise<void>;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||
import { DocumentEditor } from "./document-editor.tsx";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { slugFromName } from "./slug-from-name.ts";
|
||||
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
@@ -25,6 +26,13 @@ function inputOf(document: WorkingCopy): WorkingCopyInput {
|
||||
return input as WorkingCopyInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* 백엔드가 문서 slug 에 요구하는 모양 (`WorkingCopyInputValidator.SLUG`). 저장 전에 여기서 먼저
|
||||
* 보는 이유는, 어긋난 값을 보내면 서버가 details 없는 422 로 거절하고 편집기는 그것을 이유 없는
|
||||
* 실패로만 보여 주기 때문이다 — 작성자에게는 어느 칸이 문제인지 알 방법이 없었다.
|
||||
*/
|
||||
const SLUG_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
||||
|
||||
export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
const studio = useStudio();
|
||||
const session = useStudioEditorSession();
|
||||
@@ -48,6 +56,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
// which can only grow the catalog -- the one legitimate reset is the effect
|
||||
// below, when the screen switches to a different document.
|
||||
const [assets, setAssets] = useState<readonly Asset[]>([]);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const observeAssets = useCallback((observed: readonly Asset[]) => {
|
||||
setAssets((current) => mergeAssetCatalog(current, observed));
|
||||
}, []);
|
||||
@@ -97,13 +106,26 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
current.status === "SAVING" ||
|
||||
current.status === "CONFLICT"
|
||||
) return;
|
||||
// 비워 두면 제목에서 만든다. 한글 제목도 로마자로 옮겨 유효한 slug 가 되므로, 작성자가
|
||||
// slug 규칙을 몰라도 저장이 막히지 않는다.
|
||||
const slug = current.draft.slug.trim() || slugFromName(current.draft.title);
|
||||
if (slug && !SLUG_SHAPE.test(slug)) {
|
||||
setSaveError(
|
||||
"slug 은 영문 소문자·숫자·하이픈만 쓸 수 있습니다. 비워 두면 제목에서 만들어 드립니다.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const draft = slug === current.draft.slug
|
||||
? current.draft
|
||||
: ({ ...current.draft, slug } as typeof current.draft);
|
||||
setSaveError("");
|
||||
setStatus("SAVING");
|
||||
try {
|
||||
const detail = await studio.gateway.saveDocument(
|
||||
current.documentId,
|
||||
{
|
||||
expectedVersion: current.saved.version,
|
||||
document: current.draft,
|
||||
document: draft,
|
||||
},
|
||||
{ idempotencyKey: createLocalId("studio-editor-save") },
|
||||
);
|
||||
@@ -115,11 +137,11 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
const conflict = isStudioGatewayError(error) &&
|
||||
error.code === "VERSION_CONFLICT";
|
||||
setStatus(conflict ? "CONFLICT" : "DIRTY");
|
||||
studio.setRequestAnnouncement(
|
||||
isStudioGatewayError(error)
|
||||
? error.problem.detail
|
||||
: "저장하지 못했습니다.",
|
||||
);
|
||||
const detail = isStudioGatewayError(error)
|
||||
? error.problem.detail
|
||||
: "저장하지 못했습니다.";
|
||||
setSaveError(detail);
|
||||
studio.setRequestAnnouncement(detail);
|
||||
}
|
||||
}, [begin, editor, setStatus, studio]);
|
||||
|
||||
@@ -129,6 +151,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
saved: editor.saved,
|
||||
draft: editor.draft,
|
||||
status: editor.status,
|
||||
saveError,
|
||||
update(patch) {
|
||||
updateDraft({ ...editor.draft, ...patch, kind: editor.draft.kind } as WorkingCopyInput);
|
||||
},
|
||||
@@ -137,7 +160,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
},
|
||||
save,
|
||||
};
|
||||
}, [documentId, editor, save, updateDraft]);
|
||||
}, [documentId, editor, save, saveError, updateDraft]);
|
||||
|
||||
const currentResult = result?.key === requestKey ? result : null;
|
||||
const problem = currentResult?.problem ?? null;
|
||||
|
||||
@@ -24,7 +24,18 @@ export function DocumentStatusRail({ controller }: { controller: DocumentEditorC
|
||||
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
||||
<button type="button" onClick={() => { void controller.save(); }} disabled={controller.status === "CLEAN" || controller.status === "SAVING" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
||||
<GuardedStudioLink className="studio-editor-next-link" href={`/studio/documents/${controller.saved.id}/validation`}>저장본 검증</GuardedStudioLink>
|
||||
{controller.status === "CONFLICT" ? <p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p> : <p>불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.</p>}
|
||||
{/*
|
||||
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
|
||||
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
|
||||
두 번 말하게 된다.
|
||||
*/}
|
||||
{controller.status === "CONFLICT" ? (
|
||||
<p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p>
|
||||
) : controller.saveError ? (
|
||||
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
|
||||
) : (
|
||||
<p>불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.</p>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user