feat: port TechLog Studio validation workflow
This commit is contained in:
@@ -2,9 +2,11 @@ import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
||||
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||
import type {
|
||||
WorkingCopy,
|
||||
@@ -16,13 +18,24 @@ import {
|
||||
type StudioEditorState,
|
||||
type StudioEditorStatus,
|
||||
} from "./use-studio.ts";
|
||||
import { UnsavedLeaveDialog } from "./components/unsaved-leave-dialog.tsx";
|
||||
import { useBeforeUnload } from "./components/use-before-unload.ts";
|
||||
|
||||
type StudioProviderProps = Readonly<{
|
||||
children: ReactNode;
|
||||
createGateway: () => StudioGateway;
|
||||
now?: () => Date;
|
||||
navigate?: (href: string) => void;
|
||||
}>;
|
||||
|
||||
function inputOf(document: WorkingCopy): WorkingCopyInput {
|
||||
const input: Record<string, unknown> = { ...document };
|
||||
delete input.id;
|
||||
delete input.version;
|
||||
delete input.updatedAt;
|
||||
return input as WorkingCopyInput;
|
||||
}
|
||||
|
||||
function defaultNavigate(href: string): void {
|
||||
window.history.pushState({}, "", href);
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
@@ -31,12 +44,31 @@ function defaultNavigate(href: string): void {
|
||||
export function StudioProvider({
|
||||
children,
|
||||
createGateway,
|
||||
now = () => new Date("2026-08-14T01:00:00.000Z"),
|
||||
navigate = defaultNavigate,
|
||||
}: StudioProviderProps) {
|
||||
const [gateway] = useState<StudioGateway>(() => createGateway());
|
||||
const [editor, setEditor] = useState<StudioEditorState | null>(null);
|
||||
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
||||
const [requestAnnouncement, setRequestAnnouncement] = useState("");
|
||||
const navigateInternal = useCallback((href: string) => navigate(href), [navigate]);
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
const saveSequence = useRef(0);
|
||||
|
||||
const unsafe = editor?.status === "DIRTY" || editor?.status === "CONFLICT";
|
||||
useBeforeUnload(unsafe);
|
||||
|
||||
const navigateInternal = useCallback((
|
||||
href: string,
|
||||
trigger?: HTMLElement | null,
|
||||
) => {
|
||||
if (!unsafe) {
|
||||
navigate(href);
|
||||
return;
|
||||
}
|
||||
triggerRef.current = trigger ?? null;
|
||||
setPendingHref(href);
|
||||
setRequestAnnouncement("");
|
||||
}, [navigate, unsafe]);
|
||||
const beginEditor = useCallback((saved: WorkingCopy, draft: WorkingCopyInput) => {
|
||||
setEditor({ documentId: saved.id, saved, draft, status: "CLEAN" });
|
||||
}, []);
|
||||
@@ -47,9 +79,71 @@ export function StudioProvider({
|
||||
setEditor((current) => current ? { ...current, status } : current);
|
||||
}, []);
|
||||
const clearEditor = useCallback(() => setEditor(null), []);
|
||||
|
||||
const stay = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
triggerRef.current = null;
|
||||
setPendingHref(null);
|
||||
setRequestAnnouncement("");
|
||||
queueMicrotask(() => trigger?.focus());
|
||||
}, []);
|
||||
|
||||
const discard = useCallback(() => {
|
||||
const href = pendingHref;
|
||||
setEditor((current) => current
|
||||
? { ...current, draft: inputOf(current.saved), status: "CLEAN" }
|
||||
: current);
|
||||
setPendingHref(null);
|
||||
setRequestAnnouncement("");
|
||||
triggerRef.current = null;
|
||||
if (href) navigate(href);
|
||||
}, [navigate, pendingHref]);
|
||||
|
||||
const saveThenNavigate = useCallback(async (): Promise<boolean> => {
|
||||
const current = editor;
|
||||
const href = pendingHref;
|
||||
if (!current || !href || current.status === "SAVING") return false;
|
||||
setEditor({ ...current, status: "SAVING" });
|
||||
try {
|
||||
const detail = await gateway.saveDocument(
|
||||
current.documentId,
|
||||
{
|
||||
expectedVersion: current.saved.version,
|
||||
document: current.draft,
|
||||
},
|
||||
{ idempotencyKey: `studio-local-save-${++saveSequence.current}` },
|
||||
);
|
||||
setEditor({
|
||||
documentId: detail.document.id,
|
||||
saved: detail.document,
|
||||
draft: inputOf(detail.document),
|
||||
status: "CLEAN",
|
||||
});
|
||||
setPendingHref(null);
|
||||
setRequestAnnouncement(`버전 ${detail.document.version}으로 저장했습니다.`);
|
||||
triggerRef.current = null;
|
||||
navigate(href);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setEditor({
|
||||
...current,
|
||||
status: isStudioGatewayError(error) && error.code === "VERSION_CONFLICT"
|
||||
? "CONFLICT"
|
||||
: "DIRTY",
|
||||
});
|
||||
setRequestAnnouncement(
|
||||
isStudioGatewayError(error)
|
||||
? error.problem.detail
|
||||
: "저장하지 못했습니다. 다시 시도해 주세요.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}, [editor, gateway, navigate, pendingHref]);
|
||||
|
||||
const value = useMemo<StudioContextValue>(
|
||||
() => ({
|
||||
gateway,
|
||||
now,
|
||||
editor,
|
||||
requestAnnouncement,
|
||||
setRequestAnnouncement,
|
||||
@@ -65,6 +159,7 @@ export function StudioProvider({
|
||||
editor,
|
||||
gateway,
|
||||
navigateInternal,
|
||||
now,
|
||||
requestAnnouncement,
|
||||
setEditorStatus,
|
||||
updateEditorDraft,
|
||||
@@ -73,7 +168,17 @@ export function StudioProvider({
|
||||
|
||||
return (
|
||||
<StudioContext.Provider value={value}>
|
||||
<div className="studio-app">{children}</div>
|
||||
<div className="studio-app">
|
||||
{children}
|
||||
<UnsavedLeaveDialog
|
||||
open={pendingHref !== null}
|
||||
saving={editor?.status === "SAVING"}
|
||||
message={requestAnnouncement}
|
||||
onStay={stay}
|
||||
onDiscard={discard}
|
||||
onSaveThenNavigate={saveThenNavigate}
|
||||
/>
|
||||
</div>
|
||||
</StudioContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user