diff --git a/src/features/tech-log/presentation/studio/components/document-editor-controller.ts b/src/features/tech-log/presentation/studio/components/document-editor-controller.ts index f095478..9043edf 100644 --- a/src/features/tech-log/presentation/studio/components/document-editor-controller.ts +++ b/src/features/tech-log/presentation/studio/components/document-editor-controller.ts @@ -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): void; replace(draft: WorkingCopyInput): void; save(): Promise; diff --git a/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx b/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx index 476c799..9edc94a 100644 --- a/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx +++ b/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx @@ -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([]); const [saveError, setSaveError] = useState(""); + /** + * 마지막 검증 결과. 편집기가 이것을 들고 있는 이유는, 무엇이 모자라 게시가 막히는지 작성자가 + * 고치는 자리에서 보여야 하기 때문이다 — 예전에는 별도 화면으로 나가야만 알 수 있었고, + * 돌아와서 어느 칸이었는지 기억해야 했다. + */ + const [validationSource, setValidationSource] = useState(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(() => { 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; diff --git a/src/features/tech-log/presentation/studio/components/document-editor.tsx b/src/features/tech-log/presentation/studio/components/document-editor.tsx index 5884d16..fa91a24 100644 --- a/src/features/tech-log/presentation/studio/components/document-editor.tsx +++ b/src/features/tech-log/presentation/studio/components/document-editor.tsx @@ -62,6 +62,35 @@ export function DocumentEditor({

문서 편집

{controller.draft.title || "제목 없는 작업본"}

+ {/* + 게시를 막는 것이 무엇인지 고치는 자리에서 보여 준다. 예전에는 별도 검증 화면으로 + 나가야만 알 수 있었고, 돌아와서는 어느 칸이었는지 기억해야 했다. + + 검증은 저장된 버전을 기준으로 도므로 이 목록도 그 버전의 것이다 — 저장한 뒤 다시 + 검증하기 전까지는 방금 고친 것이 아직 반영되지 않는다. 그 사실을 숨기지 않는다. + */} + {controller.validation && controller.validation.issues.length ? ( +
+

+ {controller.validation.current + ? "게시하려면 아래를 채워야 합니다" + : `버전 ${controller.validation.version} 검사에서 지적된 항목입니다 — 저장 후 다시 검증하면 갱신됩니다`} +

+
    + {controller.validation.issues.map((issue) => ( +
  • + {issue.severity === "ERROR" ? "필수" : "확인"} + {issue.message} + {issue.path} +
  • + ))} +
+
+ ) : null} {controller.draft.kind === "CASE" ? diff --git a/src/features/tech-log/presentation/studio/components/document-list.tsx b/src/features/tech-log/presentation/studio/components/document-list.tsx index 3495db1..2194128 100644 --- a/src/features/tech-log/presentation/studio/components/document-list.tsx +++ b/src/features/tech-log/presentation/studio/components/document-list.tsx @@ -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() {
다음
-
{nextLabel[item.nextAction]}
+
+ {nextHref[item.nextAction] ? ( + + {nextLabel[item.nextAction]} + + ) : ( + nextLabel[item.nextAction] + )} +
수정
diff --git a/src/features/tech-log/presentation/studio/components/document-status-rail.tsx b/src/features/tech-log/presentation/studio/components/document-status-rail.tsx index a517833..5956e52 100644 --- a/src/features/tech-log/presentation/studio/components/document-status-rail.tsx +++ b/src/features/tech-log/presentation/studio/components/document-status-rail.tsx @@ -23,7 +23,28 @@ export function DocumentStatusRail({ controller }: { controller: DocumentEditorC

{labels[controller.status]}

저장 버전
{controller.saved.version}
종류
{kindLabels[controller.draft.kind]}
- 저장본 검증 + {/* + 게시까지의 길을 통째로 보여 준다. 예전에는 다음 한 칸("저장본 검증")만 있었고, 작성자는 + 검증 → 미리보기 → 게시를 하나씩 밟아 본 뒤에야 게시 화면이 있다는 것을 알 수 있었다 — + "게시" 라는 말이 어디에도 먼저 나오지 않으니 게시하는 방법을 알기 어려웠다. + + 앞 단계를 마치지 않으면 뒤 단계가 거절하는 것은 그대로다. 여기서 바꾼 것은 순서를 + 숨기지 않는 것뿐이다. + */} + {/* 경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다 앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를 diff --git a/src/features/tech-log/presentation/styles/studio-editor.css b/src/features/tech-log/presentation/styles/studio-editor.css index f2055d2..15b1210 100644 --- a/src/features/tech-log/presentation/styles/studio-editor.css +++ b/src/features/tech-log/presentation/styles/studio-editor.css @@ -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; } diff --git a/tests/features/tech-log/studio-editor-smoke.test.tsx b/tests/features/tech-log/studio-editor-smoke.test.tsx index 1fe1806..712bc29 100644 --- a/tests/features/tech-log/studio-editor-smoke.test.tsx +++ b/tests/features/tech-log/studio-editor-smoke.test.tsx @@ -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: "즉시 미리보기" });