feat: show the way to publish, and what is blocking it, in the editor
Publishing was reachable only by walking the whole chain blind. The editor offered one link — "저장본 검증" — and the word "게시" appeared nowhere until three screens later, so an author with a finished draft could not tell how to publish it. The working-copy list already named the next step, but the name was plain text with nowhere to go. The editor now shows the whole path: 검증 → 미리보기 → 게시, each a link. The list's next step is a link to that step. Neither weakens the gates — an unvalidated document is still refused at preview, an unpreviewed one at publish. What changes is that the order stops being a secret. What is blocking publication now appears where it gets fixed. The validation report lived on its own screen, so an author read the list, navigated back, and had to remember which field each item meant. The editor shows the same issues above the fields, in red, and says plainly when they describe an older saved version rather than the current one. The clock is read during render, not captured as an effect dependency. It is a new function on every render of the provider, so depending on it refetched the document endlessly — the editor never settled, and a tab click did not even register. A value you are asking about now does not belong in a dependency array.
This commit is contained in:
@@ -14,6 +14,15 @@ export type DocumentEditorController = {
|
||||
* 남아 있어, 작성자는 저장된 줄 알고 검증에서 "slug 이 없다" 를 만났다.
|
||||
*/
|
||||
saveError: string;
|
||||
/**
|
||||
* 마지막 검증이 남긴 항목. 게시를 막는 것이 무엇인지 고치는 자리에서 보여 주기 위한 것이며,
|
||||
* `current` 가 거짓이면 지금 저장된 버전을 검사한 결과가 아니다.
|
||||
*/
|
||||
validation: Readonly<{
|
||||
current: boolean;
|
||||
version: number;
|
||||
issues: readonly { severity: "ERROR" | "WARNING"; code: string; message: string; path: string }[];
|
||||
}> | null;
|
||||
update(patch: Partial<WorkingCopyInput>): void;
|
||||
replace(draft: WorkingCopyInput): void;
|
||||
save(): Promise<void>;
|
||||
|
||||
@@ -6,12 +6,14 @@ import type {
|
||||
Asset,
|
||||
WorkingCopy,
|
||||
WorkingCopyInput,
|
||||
WorkingCopyDetail,
|
||||
} from "../../../contracts/studio/contract.ts";
|
||||
import { mergeAssetCatalog } from "../../../domain/content-format/asset-evidence-catalog.ts";
|
||||
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 { deriveValidationState } from "../../../domain/studio/document-state.ts";
|
||||
import { slugFromName } from "./slug-from-name.ts";
|
||||
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
||||
|
||||
@@ -31,6 +33,22 @@ function inputOf(document: WorkingCopy): WorkingCopyInput {
|
||||
* 보는 이유는, 어긋난 값을 보내면 서버가 details 없는 422 로 거절하고 편집기는 그것을 이유 없는
|
||||
* 실패로만 보여 주기 때문이다 — 작성자에게는 어느 칸이 문제인지 알 방법이 없었다.
|
||||
*/
|
||||
type ValidationIssue = components["schemas"]["ValidationIssue"];
|
||||
|
||||
/**
|
||||
* 마지막 검증을 편집기가 쓸 모양으로 줄인다. `current` 는 "지금 저장된 버전을 검사한 결과인가"
|
||||
* 다 — 아니면 목록은 참고일 뿐이고, 방금 고친 것이 아직 모자라다고 말할 수 있다.
|
||||
*/
|
||||
function validationOf(detail: WorkingCopyDetail, now: Date) {
|
||||
const report = detail.currentValidation;
|
||||
if (!report) return null;
|
||||
return Object.freeze({
|
||||
current: deriveValidationState({ ...detail, currentValidation: report, now }).freshness === "CURRENT",
|
||||
version: report.validatedVersion,
|
||||
issues: report.issues,
|
||||
});
|
||||
}
|
||||
|
||||
const SLUG_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
||||
|
||||
export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
@@ -57,6 +75,12 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
// below, when the screen switches to a different document.
|
||||
const [assets, setAssets] = useState<readonly Asset[]>([]);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
/**
|
||||
* 마지막 검증 결과. 편집기가 이것을 들고 있는 이유는, 무엇이 모자라 게시가 막히는지 작성자가
|
||||
* 고치는 자리에서 보여야 하기 때문이다 — 예전에는 별도 화면으로 나가야만 알 수 있었고,
|
||||
* 돌아와서 어느 칸이었는지 기억해야 했다.
|
||||
*/
|
||||
const [validationSource, setValidationSource] = useState<WorkingCopyDetail | null>(null);
|
||||
const observeAssets = useCallback((observed: readonly Asset[]) => {
|
||||
setAssets((current) => mergeAssetCatalog(current, observed));
|
||||
}, []);
|
||||
@@ -83,6 +107,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
problem: null,
|
||||
});
|
||||
begin(detail.document, inputOf(detail.document));
|
||||
setValidationSource(detail);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (request.signal.aborted) return;
|
||||
@@ -130,6 +155,9 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
{ idempotencyKey: createLocalId("studio-editor-save") },
|
||||
);
|
||||
begin(detail.document, inputOf(detail.document));
|
||||
// 저장하면 직전 검증은 그 버전의 것이 아니게 된다. 그 사실을 바로 반영해야 낡은 목록이
|
||||
// 방금 고친 항목을 아직 모자라다고 말하지 않는다.
|
||||
setValidationSource(detail);
|
||||
studio.setRequestAnnouncement(
|
||||
`버전 ${detail.document.version}으로 저장했습니다.`,
|
||||
);
|
||||
@@ -145,6 +173,13 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
}
|
||||
}, [begin, editor, setStatus, studio]);
|
||||
|
||||
/**
|
||||
* 검증의 신선도는 시각에 달렸는데, 시계는 렌더마다 새 함수다. 그것을 효과 의존성에 넣었더니
|
||||
* 문서를 끝없이 다시 불러왔다 — 화면이 정착하지 못해 탭 전환조차 먹히지 않았다. 시각은
|
||||
* 저장할 값이 아니라 지금 묻는 값이므로 렌더에서 읽는다.
|
||||
*/
|
||||
const validation = validationSource ? validationOf(validationSource, studio.now()) : null;
|
||||
|
||||
const controller = useMemo<DocumentEditorController | null>(() => {
|
||||
if (!editor || editor.documentId !== documentId) return null;
|
||||
return {
|
||||
@@ -152,6 +187,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
draft: editor.draft,
|
||||
status: editor.status,
|
||||
saveError,
|
||||
validation,
|
||||
update(patch) {
|
||||
updateDraft({ ...editor.draft, ...patch, kind: editor.draft.kind } as WorkingCopyInput);
|
||||
},
|
||||
@@ -160,7 +196,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
},
|
||||
save,
|
||||
};
|
||||
}, [documentId, editor, save, saveError, updateDraft]);
|
||||
}, [documentId, editor, save, saveError, updateDraft, validation]);
|
||||
|
||||
const currentResult = result?.key === requestKey ? result : null;
|
||||
const problem = currentResult?.problem ?? null;
|
||||
|
||||
@@ -62,6 +62,35 @@ export function DocumentEditor({
|
||||
<h1>문서 편집</h1>
|
||||
<p>{controller.draft.title || "제목 없는 작업본"}</p>
|
||||
</header>
|
||||
{/*
|
||||
게시를 막는 것이 무엇인지 고치는 자리에서 보여 준다. 예전에는 별도 검증 화면으로
|
||||
나가야만 알 수 있었고, 돌아와서는 어느 칸이었는지 기억해야 했다.
|
||||
|
||||
검증은 저장된 버전을 기준으로 도므로 이 목록도 그 버전의 것이다 — 저장한 뒤 다시
|
||||
검증하기 전까지는 방금 고친 것이 아직 반영되지 않는다. 그 사실을 숨기지 않는다.
|
||||
*/}
|
||||
{controller.validation && controller.validation.issues.length ? (
|
||||
<section
|
||||
className={`studio-editor-validation${controller.validation.current ? "" : " studio-editor-validation--stale"}`}
|
||||
role={controller.validation.current ? "alert" : "status"}
|
||||
aria-label="검증에서 지적된 항목"
|
||||
>
|
||||
<p className="studio-editor-validation-title">
|
||||
{controller.validation.current
|
||||
? "게시하려면 아래를 채워야 합니다"
|
||||
: `버전 ${controller.validation.version} 검사에서 지적된 항목입니다 — 저장 후 다시 검증하면 갱신됩니다`}
|
||||
</p>
|
||||
<ul>
|
||||
{controller.validation.issues.map((issue) => (
|
||||
<li key={`${issue.code}-${issue.path}`} data-severity={issue.severity}>
|
||||
<span>{issue.severity === "ERROR" ? "필수" : "확인"}</span>
|
||||
{issue.message}
|
||||
<code>{issue.path}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} onUpdate={controller.update} />
|
||||
{controller.draft.kind === "CASE"
|
||||
? <CaseFields draft={controller.draft} onChange={controller.replace} onAssetsObserved={onAssetsObserved} onAssetUploaded={onAssetUploaded} />
|
||||
|
||||
@@ -25,6 +25,21 @@ const nextLabel = {
|
||||
NONE: "완료",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 다음 단계는 이름만 있고 갈 곳이 없었다. 작성자는 편집 → 검증 → 미리보기 → 게시를 순서대로
|
||||
* 밟아야만 게시 화면에 닿을 수 있었고, 그 경로 어디에도 "게시" 라는 말이 먼저 보이지 않아
|
||||
* 게시하는 방법을 알기 어려웠다. 서버가 이미 다음 할 일을 알려 주므로, 그 말을 그대로 링크로
|
||||
* 만든다.
|
||||
*/
|
||||
const nextHref = {
|
||||
CONTINUE_EDITING: "edit",
|
||||
VALIDATE: "validation",
|
||||
FIX_VALIDATION: "edit",
|
||||
CREATE_PREVIEW: "preview",
|
||||
PUBLISH: "publish",
|
||||
NONE: "",
|
||||
} as const;
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
@@ -251,7 +266,17 @@ export function DocumentList() {
|
||||
</div>
|
||||
<div>
|
||||
<dt>다음</dt>
|
||||
<dd>{nextLabel[item.nextAction]}</dd>
|
||||
<dd>
|
||||
{nextHref[item.nextAction] ? (
|
||||
<GuardedStudioLink
|
||||
href={`/studio/documents/${item.id}/${nextHref[item.nextAction]}`}
|
||||
>
|
||||
{nextLabel[item.nextAction]}
|
||||
</GuardedStudioLink>
|
||||
) : (
|
||||
nextLabel[item.nextAction]
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>수정</dt>
|
||||
|
||||
@@ -23,7 +23,28 @@ export function DocumentStatusRail({ controller }: { controller: DocumentEditorC
|
||||
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
|
||||
<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>
|
||||
{/*
|
||||
게시까지의 길을 통째로 보여 준다. 예전에는 다음 한 칸("저장본 검증")만 있었고, 작성자는
|
||||
검증 → 미리보기 → 게시를 하나씩 밟아 본 뒤에야 게시 화면이 있다는 것을 알 수 있었다 —
|
||||
"게시" 라는 말이 어디에도 먼저 나오지 않으니 게시하는 방법을 알기 어려웠다.
|
||||
|
||||
앞 단계를 마치지 않으면 뒤 단계가 거절하는 것은 그대로다. 여기서 바꾼 것은 순서를
|
||||
숨기지 않는 것뿐이다.
|
||||
*/}
|
||||
<nav className="studio-editor-flow" aria-label="게시까지의 단계">
|
||||
<ol>
|
||||
{[
|
||||
{ label: "검증", href: `/studio/documents/${controller.saved.id}/validation` },
|
||||
{ label: "미리보기", href: `/studio/documents/${controller.saved.id}/preview` },
|
||||
{ label: "게시", href: `/studio/documents/${controller.saved.id}/publish` },
|
||||
].map((step, index) => (
|
||||
<li key={step.href}>
|
||||
<span aria-hidden="true">{index + 1}</span>
|
||||
<GuardedStudioLink href={step.href}>{step.label}</GuardedStudioLink>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
{/*
|
||||
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
|
||||
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
|
||||
|
||||
@@ -119,3 +119,21 @@
|
||||
.studio-app .studio-item-actions { display: grid; grid-template-columns: minmax(0, 1fr); }
|
||||
.studio-app .studio-instant-preview .public-record-embedded { padding: 22px 16px; }
|
||||
}
|
||||
|
||||
/* 게시까지의 단계. 순서가 있는 길이라 번호를 붙인다 — 장식이 아니라 밟아야 하는 차례다. */
|
||||
.studio-app .studio-editor-flow ol { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 14px 0 0; padding: 0; list-style: none; }
|
||||
.studio-app .studio-editor-flow li { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||
.studio-app .studio-editor-flow li + li::before { content: "→"; margin-right: 4px; color: var(--faint); }
|
||||
.studio-app .studio-editor-flow span { display: inline-flex; min-width: 18px; height: 18px; align-items: center; justify-content: center; border-radius: 999px; background: var(--line); color: var(--muted); font-size: 11px; font-weight: 650; }
|
||||
|
||||
/* 게시를 막는 항목. 고치는 자리에서 보이지 않으면 작성자는 별도 화면과 편집기를 오가며
|
||||
어느 칸이었는지 기억해야 한다. */
|
||||
.studio-app .studio-editor-validation { margin: 0 0 20px; padding: 14px 16px; border: 1px solid var(--danger, #b4232a); border-radius: 6px; background: var(--paper); }
|
||||
.studio-app .studio-editor-validation--stale { border-color: var(--line-strong); opacity: 0.72; }
|
||||
.studio-app .studio-editor-validation-title { margin: 0 0 10px; color: var(--danger, #b4232a); font-size: 13px; font-weight: 700; }
|
||||
.studio-app .studio-editor-validation--stale .studio-editor-validation-title { color: var(--muted); }
|
||||
.studio-app .studio-editor-validation ul { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
|
||||
.studio-app .studio-editor-validation li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; font-size: 13px; color: var(--ink); }
|
||||
.studio-app .studio-editor-validation li span { min-width: 30px; color: var(--danger, #b4232a); font-size: 11px; font-weight: 700; }
|
||||
.studio-app .studio-editor-validation li[data-severity="WARNING"] span { color: var(--muted); }
|
||||
.studio-app .studio-editor-validation code { color: var(--faint); font-size: 11px; }
|
||||
|
||||
@@ -104,10 +104,18 @@ describe("TechLog Studio document editor", () => {
|
||||
expect(screen.getByRole("complementary", { name: "작업 상태" })).toHaveTextContent(
|
||||
"저장 버전4종류CASE",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "저장본 검증" })).toHaveAttribute(
|
||||
"href",
|
||||
`/studio/documents/${FIXTURE_IDS.redisAdapterCase}/validation`,
|
||||
);
|
||||
// 편집기는 다음 한 칸이 아니라 게시까지의 길을 보여 준다. 예전에는 "저장본 검증" 하나뿐이라,
|
||||
// 작성자가 그 단계를 밟아 본 뒤에야 뒤에 미리보기와 게시가 있다는 것을 알 수 있었다.
|
||||
const flow = screen.getByRole("navigation", { name: "게시까지의 단계" });
|
||||
expect(
|
||||
within(flow)
|
||||
.getAllByRole("link")
|
||||
.map((link) => [link.textContent, link.getAttribute("href")]),
|
||||
).toEqual([
|
||||
["검증", `/studio/documents/${FIXTURE_IDS.redisAdapterCase}/validation`],
|
||||
["미리보기", `/studio/documents/${FIXTURE_IDS.redisAdapterCase}/preview`],
|
||||
["게시", `/studio/documents/${FIXTURE_IDS.redisAdapterCase}/publish`],
|
||||
]);
|
||||
|
||||
const editTab = screen.getByRole("tab", { name: "편집" });
|
||||
const previewTab = screen.getByRole("tab", { name: "즉시 미리보기" });
|
||||
|
||||
Reference in New Issue
Block a user