CI: feature/editor-persistence #13

Merged
jaybe merged 3 commits from feature/editor-persistence into main 2025-11-30 08:39:18 +00:00
15 changed files with 1765 additions and 179 deletions
Showing only changes of commit 17bfac62ed - Show all commits
+10
View File
@@ -14,6 +14,7 @@
"@prisma/client": "^6.19.0",
"dotenv": "^17.2.3",
"jszip": "^3.10.1",
"lucide-react": "^0.555.0",
"next": "^16.0.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -7089,6 +7090,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.555.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz",
"integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+1
View File
@@ -20,6 +20,7 @@
"@prisma/client": "^6.19.0",
"dotenv": "^17.2.3",
"jszip": "^3.10.1",
"lucide-react": "^0.555.0",
"next": "^16.0.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+27 -8
View File
@@ -3,14 +3,8 @@ import { PrismaClient } from "@/generated/prisma/client";
const prisma = new PrismaClient();
interface Params {
params: {
slug: string;
};
}
export async function GET(_request: Request, { params }: Params) {
const { slug } = params;
export async function GET(_request: Request, context: { params: { slug: string } | Promise<{ slug: string }> }) {
const { slug } = await context.params;
const project = await prisma.project.findUnique({
where: { slug },
@@ -22,3 +16,28 @@ export async function GET(_request: Request, { params }: Params) {
return NextResponse.json(project, { status: 200 });
}
export async function DELETE(
_request: Request,
context: { params: { slug: string } | Promise<{ slug: string }> },
) {
const { slug } = await context.params;
try {
await prisma.project.delete({
where: { slug },
});
return NextResponse.json({ message: "프로젝트가 삭제되었습니다." }, { status: 200 });
} catch (error: any) {
// Prisma NotFound 에러 코드(P2025)가 떨어지는 경우 404 로 매핑한다.
if (error?.code === "P2025") {
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
}
return NextResponse.json(
{ message: "프로젝트 삭제 중 오류가 발생했습니다.", error: error?.message },
{ status: 500 },
);
}
}
+31 -2
View File
@@ -3,6 +3,30 @@ import { PrismaClient } from "@/generated/prisma/client";
const prisma = new PrismaClient();
export async function GET(_request: Request) {
try {
const projects = await prisma.project.findMany({
orderBy: { createdAt: "desc" },
take: 50,
select: {
id: true,
title: true,
slug: true,
status: true,
createdAt: true,
updatedAt: true,
},
});
return NextResponse.json(projects, { status: 200 });
} catch (error: any) {
return NextResponse.json(
{ message: "프로젝트 목록을 가져오는 중 오류가 발생했습니다.", error: error?.message },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
const body = await request.json();
const { title, slug, contentJson } = body;
@@ -12,8 +36,13 @@ export async function POST(request: Request) {
}
try {
const project = await prisma.project.create({
data: {
const project = await prisma.project.upsert({
where: { slug },
update: {
title,
contentJson,
},
create: {
title,
slug,
contentJson,
+126 -112
View File
@@ -117,6 +117,7 @@ export default function EditorPage() {
const projectConfig = useEditorStore(
(state) => (state as any).projectConfig as ProjectConfig,
);
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
@@ -127,17 +128,12 @@ export default function EditorPage() {
const [exportJson, setExportJson] = useState("");
const [importJson, setImportJson] = useState("");
const [projectTitle, setProjectTitle] = useState("");
const [projectSlug, setProjectSlug] = useState("");
const [loadSlug, setLoadSlug] = useState("");
const [projectMessage, setProjectMessage] = useState("");
// 멀티 선택(MVP)을 위한 로컬 선택 상태: 단일 선택(selectedBlockId)과 병행 사용한다.
const [selectedBlockIds, setSelectedBlockIds] = useState<string[]>([]);
const [menuOpen, setMenuOpen] = useState(false);
const [activeModal, setActiveModal] = useState<"project" | "json" | "exportPreview" | null>(null);
const [exportPreviewHtml, setExportPreviewHtml] = useState("");
const [exportPreviewStatus, setExportPreviewStatus] = useState<"idle" | "loading" | "error">("idle");
const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null);
const sensors = useSensors(useSensor(PointerSensor));
@@ -190,37 +186,6 @@ export default function EditorPage() {
}
};
const handleRefreshExportPreview = async () => {
try {
setExportPreviewStatus("loading");
const payload = {
blocks,
projectConfig,
};
const response = await fetch("/api/export/preview", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
setExportPreviewStatus("error");
return;
}
const html = await response.text();
setExportPreviewHtml(html);
setExportPreviewStatus("idle");
} catch (error) {
console.error("Export 미리보기 요청 중 오류", error);
setExportPreviewStatus("error");
}
};
const commitEditing = () => {
if (!editingBlockId) return;
updateBlock(editingBlockId, { text: editingText });
@@ -239,7 +204,11 @@ export default function EditorPage() {
const handleExportJson = () => {
try {
const payload = JSON.stringify(blocks, null, 2);
const snapshot = {
blocks,
projectConfig,
};
const payload = JSON.stringify(snapshot, null, 2);
setExportJson(payload);
setImportJson(payload);
} catch {
@@ -256,31 +225,42 @@ export default function EditorPage() {
const handleApplyImportJson = () => {
if (!importJson.trim()) return;
try {
const parsed = JSON.parse(importJson);
if (!Array.isArray(parsed)) return;
// 최소한의 구조 검증: id, type, props.text 가 있는 블록만 사용
const safeBlocks = parsed.filter((b) => b && typeof b.id === "string" && b.type === "text" && b.props && typeof b.props.text === "string");
replaceBlocks(safeBlocks);
// 내보내기 영역도 최신 상태로 업데이트
setExportJson(JSON.stringify(safeBlocks, null, 2));
const parsed = JSON.parse(importJson) as {
blocks?: Block[];
projectConfig?: ProjectConfig;
};
if (!parsed || typeof parsed !== "object") return;
if (!Array.isArray(parsed.blocks)) return;
replaceBlocks(parsed.blocks as any);
if (parsed.projectConfig && typeof parsed.projectConfig === "object") {
updateProjectConfig(parsed.projectConfig as ProjectConfig);
}
setExportJson(JSON.stringify(parsed, null, 2));
} catch {
// 파싱 에러는 무시 (추후에는 토스트 등으로 피드백 가능)
}
};
const handleSaveProject = async () => {
const slug = projectSlug.trim();
const slug = (projectConfig.slug ?? "").trim();
if (!slug) {
setProjectMessage("프로젝트 주소를 입력해 주세요.");
return;
}
const title = (projectConfig.title ?? "").trim() || "제목 없음";
try {
// 1) 로컬스토리지에 현재 상태 저장 (슬러그 기준)
if (typeof window !== "undefined") {
const localKey = `pb:project:${slug}`;
const payload = {
title: projectTitle.trim() || "제목 없음",
title,
contentJson: blocks,
};
window.localStorage.setItem(localKey, JSON.stringify(payload));
@@ -293,7 +273,7 @@ export default function EditorPage() {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: projectTitle.trim() || "제목 없음",
title,
slug,
contentJson: blocks,
}),
@@ -306,6 +286,10 @@ export default function EditorPage() {
const data = await response.json();
setProjectMessage(`프로젝트가 저장되었습니다: ${data.slug}`);
if (typeof window !== "undefined") {
window.location.href = "/projects";
}
} catch (error) {
console.error(error);
setProjectMessage("프로젝트 저장 중 오류가 발생했습니다.");
@@ -313,7 +297,7 @@ export default function EditorPage() {
};
const handleLoadProject = async () => {
const slug = loadSlug.trim() || projectSlug.trim();
const slug = (projectConfig.slug ?? "").trim();
if (!slug) {
setProjectMessage("불러올 주소를 입력해 주세요.");
return;
@@ -358,6 +342,43 @@ export default function EditorPage() {
}
};
const handleDeleteProject = async () => {
const slug = (projectConfig.slug ?? "").trim();
if (!slug) {
setProjectMessage("삭제할 프로젝트 주소가 없습니다.");
return;
}
if (typeof window === "undefined") return;
const confirmed = window.confirm(
"현재 프로젝트를 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
);
if (!confirmed) return;
try {
const response = await fetch(`/api/projects/${encodeURIComponent(slug)}`, {
method: "DELETE",
});
if (!response.ok) {
setProjectMessage("프로젝트 삭제에 실패했습니다.");
return;
}
// 프로젝트 저장 및 자동저장 스냅샷을 정리한다.
window.localStorage.removeItem(`pb:project:${slug}`);
window.localStorage.removeItem(`pb:autosave:${slug}`);
setProjectMessage("프로젝트가 삭제되었습니다.");
window.location.href = "/projects";
} catch (error) {
console.error(error);
setProjectMessage("프로젝트 삭제 중 오류가 발생했습니다.");
}
};
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
setActiveDragId(String(active.id));
@@ -524,6 +545,48 @@ export default function EditorPage() {
const rootBlocks = blocks.filter((block) => !block.sectionId);
useEffect(() => {
if (typeof window === "undefined") return;
const slug = projectConfig?.slug?.trim();
if (!slug) return;
const key = `pb:autosave:${slug}`;
const raw = window.localStorage.getItem(key);
if (!raw) return;
try {
const parsed = JSON.parse(raw) as { blocks?: Block[]; projectConfig?: ProjectConfig };
if (Array.isArray(parsed.blocks)) {
replaceBlocks(parsed.blocks as any);
}
if (parsed.projectConfig) {
updateProjectConfig(parsed.projectConfig as ProjectConfig);
}
} catch {
return;
}
}, [projectConfig?.slug, replaceBlocks, updateProjectConfig]);
useEffect(() => {
if (typeof window === "undefined") return;
const slug = projectConfig?.slug?.trim();
if (!slug) return;
const key = `pb:autosave:${slug}`;
const payload = {
blocks,
projectConfig,
};
try {
window.localStorage.setItem(key, JSON.stringify(payload));
} catch {
return;
}
}, [blocks, projectConfig]);
// 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z),
// Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제,
// ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다.
@@ -681,6 +744,12 @@ export default function EditorPage() {
<p className="text-xs text-slate-400"> MVP용 </p>
</div>
<div className="flex items-center gap-2 text-xs">
<Link
href="/projects"
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
>
</Link>
<Link
href="/preview"
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
@@ -720,13 +789,13 @@ export default function EditorPage() {
</button>
<button
type="button"
className="w-full px-3 py-2 text-left hover:bg-slate-800 text-slate-100 border-t border-slate-800"
className="w-full px-3 py-2 text-left hover:bg-red-900/60 text-red-100 border-t border-slate-800"
onClick={() => {
setMenuOpen(false);
setActiveModal("exportPreview");
void handleDeleteProject();
}}
>
Export
</button>
<button
type="button"
@@ -806,16 +875,16 @@ export default function EditorPage() {
<span className="text-slate-400"> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={projectTitle}
onChange={(e) => setProjectTitle(e.target.value)}
value={projectConfig.title ?? ""}
onChange={(e) => updateProjectConfig({ title: e.target.value })}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> (: my-landing)</span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={projectSlug}
onChange={(e) => setProjectSlug(e.target.value)}
value={projectConfig.slug ?? ""}
onChange={(e) => updateProjectConfig({ slug: e.target.value })}
/>
</label>
<div className="flex gap-2">
@@ -834,14 +903,6 @@ export default function EditorPage() {
</button>
</div>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> ( )</span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={loadSlug}
onChange={(e) => setLoadSlug(e.target.value)}
/>
</label>
{projectMessage && (
<p className="text-[11px] text-slate-300 mt-1">{projectMessage}</p>
)}
@@ -850,54 +911,7 @@ export default function EditorPage() {
</div>
)}
{activeModal === "exportPreview" && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
<div className="w-full max-w-4xl rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">Export </h3>
<button
type="button"
className="text-slate-400 hover:text-slate-100 text-sm"
onClick={() => setActiveModal(null)}
>
</button>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] text-slate-400">
Export HTML .
</p>
<button
type="button"
className="rounded border border-sky-700 bg-sky-950 px-3 py-1 text-[11px] text-sky-100 hover:bg-sky-900 disabled:opacity-60 disabled:cursor-not-allowed"
onClick={handleRefreshExportPreview}
disabled={exportPreviewStatus === "loading"}
>
{exportPreviewStatus === "loading" ? "로딩 중..." : "미리보기 새로고침"}
</button>
</div>
{exportPreviewStatus === "error" && (
<p className="text-[11px] text-red-300">
Export HTML . .
</p>
)}
{exportPreviewHtml ? (
<iframe
title="Export preview"
data-testid="export-preview-frame"
className="w-full h-[480px] rounded border border-slate-700 bg-slate-950"
srcDoc={exportPreviewHtml}
/>
) : (
<div className="w-full h-[200px] rounded border border-dashed border-slate-700 bg-slate-950 flex items-center justify-center text-[11px] text-slate-500">
Export HTML . "미리보기 새로고침" .
</div>
)}
</div>
</div>
</div>
)}
{/* Export 미리보기 기능은 제거되었습니다. */}
{activeModal === "json" && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
+32
View File
@@ -1,6 +1,7 @@
"use client";
import type { CSSProperties } from "react";
import { useEffect } from "react";
import Link from "next/link";
import { useEditorStore } from "@/features/editor/state/editorStore";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
@@ -8,6 +9,31 @@ import { PublicPageRenderer } from "@/features/editor/components/PublicPageRende
export default function PreviewPage() {
const blocks = useEditorStore((state) => state.blocks);
const projectConfig = useEditorStore((state) => (state as any).projectConfig);
const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
useEffect(() => {
if (typeof window === "undefined") return;
const slug = (projectConfig as any)?.slug?.trim?.();
if (!slug) return;
const key = `pb:autosave:${slug}`;
const raw = window.localStorage.getItem(key);
if (!raw) return;
try {
const parsed = JSON.parse(raw) as { blocks?: any[]; projectConfig?: any };
if (Array.isArray(parsed.blocks)) {
replaceBlocks(parsed.blocks as any);
}
if (parsed.projectConfig) {
updateProjectConfig(parsed.projectConfig as any);
}
} catch {
return;
}
}, [projectConfig?.slug, replaceBlocks, updateProjectConfig]);
const canvasStyle: CSSProperties = {};
const preset = projectConfig?.canvasPreset ?? "full";
@@ -88,6 +114,12 @@ export default function PreviewPage() {
>
(ZIP)
</button>
<Link
href="/projects"
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-xs text-slate-100 hover:bg-slate-800"
>
</Link>
<Link
href="/editor"
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-xs text-slate-100 hover:bg-slate-800"
+348
View File
@@ -0,0 +1,348 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import {
FilePlus2,
Trash2,
Eye,
Pencil,
ListChecks,
ChevronLeft,
ChevronRight,
} from "lucide-react";
interface ProjectListItem {
id: string;
title: string;
slug: string;
status: string;
createdAt: string;
updatedAt: string;
}
export default function ProjectsPage() {
const [projects, setProjects] = useState<ProjectListItem[]>([]);
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
setStatus("loading");
const res = await fetch("/api/projects");
if (!res.ok) {
if (!cancelled) {
setStatus("error");
}
return;
}
const data = (await res.json()) as ProjectListItem[];
if (!cancelled) {
setProjects(Array.isArray(data) ? data : []);
setStatus("idle");
}
} catch {
if (!cancelled) {
setStatus("error");
}
}
};
void load();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(projects.length / pageSize));
setCurrentPage((prev) => Math.min(prev, totalPages));
}, [projects.length, pageSize]);
const handleDelete = async (slug: string) => {
if (typeof window !== "undefined") {
const confirmed = window.confirm(
"이 프로젝트를 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
);
if (!confirmed) return;
}
try {
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`, {
method: "DELETE",
});
if (!res.ok) {
setStatus("error");
return;
}
setProjects((prev) => prev.filter((p) => p.slug !== slug));
setSelectedSlugs((prev) => prev.filter((s) => s !== slug));
if (typeof window !== "undefined") {
window.localStorage.removeItem(`pb:project:${slug}`);
window.localStorage.removeItem(`pb:autosave:${slug}`);
}
} catch {
setStatus("error");
}
};
const totalCount = projects.length;
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const pageProjects = projects.slice(startIndex, endIndex);
const handleBulkDelete = async () => {
const slugs = selectedSlugs;
if (slugs.length === 0) return;
if (typeof window !== "undefined") {
const confirmed = window.confirm(
"선택한 프로젝트를 모두 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
);
if (!confirmed) return;
}
try {
const results = await Promise.all(
slugs.map(async (slug) => {
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`, { method: "DELETE" });
return { slug, ok: res.ok };
}),
);
const okSlugs = results.filter((r) => r.ok).map((r) => r.slug);
if (okSlugs.length === 0) {
setStatus("error");
return;
}
setProjects((prev) => prev.filter((p) => !okSlugs.includes(p.slug)));
setSelectedSlugs((prev) => prev.filter((s) => !okSlugs.includes(s)));
if (typeof window !== "undefined") {
for (const slug of okSlugs) {
window.localStorage.removeItem(`pb:project:${slug}`);
window.localStorage.removeItem(`pb:autosave:${slug}`);
}
}
if (okSlugs.length !== slugs.length) {
setStatus("error");
}
} catch {
setStatus("error");
}
};
return (
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between bg-slate-950/80 backdrop-blur">
<div>
<h1 className="text-xl font-semibold"> </h1>
<p className="text-xs text-slate-400"> </p>
</div>
<div className="flex items-center gap-2 text-xs">
<Link
href="/editor"
className="inline-flex items-center gap-1 rounded border border-sky-700 bg-sky-950 px-3 py-1 text-sky-100 hover:bg-sky-900 shadow-sm"
>
<FilePlus2 className="w-4 h-4" aria-hidden="true" />
<span> </span>
</Link>
</div>
</header>
<section className="flex-1 px-6 py-4 overflow-auto">
{status === "error" && (
<p className="text-xs text-red-300 mb-3"> . .</p>
)}
{projects.length === 0 && status === "idle" && (
<p className="text-xs text-slate-400"> . .</p>
)}
{projects.length > 0 && (
<>
<div className="flex items-center justify-between mb-2 text-[11px] text-slate-300">
<div className="inline-flex items-center gap-2">
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-slate-900/70 border border-slate-700">
<ListChecks className="w-3 h-3 text-slate-400" aria-hidden="true" />
<span>
: <span className="font-semibold">{selectedSlugs.length}</span>
</span>
</span>
</div>
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-red-700 px-2 py-1 text-red-200 hover:bg-red-900/40 disabled:opacity-40 disabled:cursor-not-allowed"
disabled={selectedSlugs.length === 0}
onClick={() => {
void handleBulkDelete();
}}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</button>
</div>
<table className="w-full text-xs text-left border-collapse mt-2">
<thead>
<tr className="border-b border-slate-800 text-slate-400">
<th className="py-2 pr-2 w-8">
<input
type="checkbox"
className="h-3 w-3 rounded border-slate-700 bg-slate-900 align-middle"
aria-label="전체 선택"
checked={
pageProjects.length > 0 && pageProjects.every((p) => selectedSlugs.includes(p.slug))
}
onChange={(e) => {
if (e.target.checked) {
const slugsOnPage = pageProjects.map((p) => p.slug);
setSelectedSlugs((prev) => Array.from(new Set([...prev, ...slugsOnPage])));
} else {
const slugsOnPage = pageProjects.map((p) => p.slug);
setSelectedSlugs((prev) => prev.filter((s) => !slugsOnPage.includes(s)));
}
}}
/>
</th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4">(slug)</th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4 text-right"></th>
</tr>
</thead>
<tbody>
{pageProjects.map((project) => {
const checked = selectedSlugs.includes(project.slug);
return (
<tr key={project.id} className="border-b border-slate-900 hover:bg-slate-900/60">
<td className="py-2 pr-2 w-8">
<input
type="checkbox"
className="h-3 w-3 rounded border-slate-700 bg-slate-900 align-middle"
aria-label={`${project.title} 선택`}
checked={checked}
onChange={(e) => {
if (e.target.checked) {
setSelectedSlugs((prev) =>
prev.includes(project.slug) ? prev : [...prev, project.slug],
);
} else {
setSelectedSlugs((prev) => prev.filter((s) => s !== project.slug));
}
}}
/>
</td>
<td className="py-2 pr-4 text-slate-100">{project.title}</td>
<td className="py-2 pr-4 text-slate-300 font-mono text-[11px]">{project.slug}</td>
<td className="py-2 pr-4 text-slate-300">{project.status}</td>
<td className="py-2 pr-4 text-slate-500 text-[11px]">
{new Date(project.createdAt).toLocaleString()}
</td>
<td className="py-2 pr-4 text-slate-500 text-[11px]">
{new Date(project.updatedAt).toLocaleString()}
</td>
<td className="py-2 pl-4 pr-0 text-right text-[11px]">
<div className="inline-flex items-center gap-2">
<Link
href={`/editor?slug=${encodeURIComponent(project.slug)}`}
className="inline-flex items-center gap-1 text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline"
>
<Pencil className="w-3 h-3" aria-hidden="true" />
<span></span>
</Link>
<Link
href={`/preview?slug=${encodeURIComponent(project.slug)}`}
className="inline-flex items-center gap-1 text-slate-300 hover:text-slate-100 underline-offset-2 hover:underline"
>
<Eye className="w-3 h-3" aria-hidden="true" />
<span></span>
</Link>
<button
type="button"
className="inline-flex items-center gap-1 text-red-300 hover:text-red-200 underline-offset-2 hover:underline"
onClick={() => {
void handleDelete(project.slug);
}}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span></span>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-300">
<div className="inline-flex items-center gap-2">
<span className="px-2 py-1 rounded-full bg-slate-900/70 border border-slate-700">
{totalCount} {" "}
<span className="font-semibold">
{totalCount === 0 ? 0 : startIndex + 1}{Math.min(endIndex, totalCount)}
</span>
</span>
</div>
<div className="flex items-center gap-1">
{Array.from({ length: totalPages }).map((_, idx) => {
const page = idx + 1;
const isActive = page === currentPage;
return (
<button
key={page}
type="button"
onClick={() => setCurrentPage(page)}
className={`w-6 h-6 rounded-full text-[10px] flex items-center justify-center border transition-colors ${
isActive
? "bg-sky-600 text-white border-sky-500 shadow-sm"
: "bg-slate-900 text-slate-300 border-slate-700 hover:bg-slate-800"
}`}
>
{page}
</button>
);
})}
</div>
<div className="flex items-center gap-1">
<button
type="button"
disabled={currentPage === 1}
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-slate-700 text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-800"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="w-3 h-3" aria-hidden="true" />
<span></span>
</button>
<button
type="button"
disabled={currentPage === totalPages}
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-slate-700 text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-800"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
>
<span></span>
<ChevronRight className="w-3 h-3" aria-hidden="true" />
</button>
</div>
</div>
</>
)}
</section>
</main>
);
}
+203 -1
View File
@@ -9,13 +9,80 @@ vi.mock("@/generated/prisma/client", () => {
class PrismaClientMock {
project = {
create: async ({ data }: any) => {
const project = { id: inMemoryProjects.length + 1, ...data };
const now = new Date();
const project = {
id: String(inMemoryProjects.length + 1),
status: "DRAFT",
createdAt: now,
updatedAt: now,
...data,
};
inMemoryProjects.push(project);
return project;
},
findUnique: async ({ where: { slug } }: any) => {
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
},
upsert: async ({ where: { slug }, update, create }: any) => {
const index = inMemoryProjects.findIndex((p) => p.slug === slug);
if (index >= 0) {
const existing = inMemoryProjects[index];
const updated = {
...existing,
...update,
updatedAt: new Date(),
};
inMemoryProjects[index] = updated;
return updated;
}
const now = new Date();
const project = {
id: String(inMemoryProjects.length + 1),
status: "DRAFT",
createdAt: now,
updatedAt: now,
...create,
};
inMemoryProjects.push(project);
return project;
},
delete: async ({ where: { slug } }: any) => {
const index = inMemoryProjects.findIndex((p) => p.slug === slug);
if (index === -1) {
const error: any = new Error("Project not found");
error.code = "P2025";
throw error;
}
const [removed] = inMemoryProjects.splice(index, 1);
return removed;
},
findMany: async ({ orderBy, take, select }: any = {}) => {
let items = [...inMemoryProjects];
if (orderBy?.createdAt === "desc") {
items.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
if (typeof take === "number") {
items = items.slice(0, take);
}
if (select) {
return items.map((p) => {
const picked: any = {};
for (const key of Object.keys(select)) {
if (select[key]) {
picked[key] = p[key];
}
}
return picked;
});
}
return items;
},
};
}
@@ -70,4 +137,139 @@ describe("/api/projects", () => {
expect(fetched.slug).toBe(payload.slug);
expect(fetched.contentJson).toEqual(payload.contentJson);
});
it("GET /api/projects 는 최근 생성 순으로 프로젝트 목록을 반환해야 한다", async () => {
const { POST: createProject } = await import("@/app/api/projects/route");
const firstPayload = {
title: "첫 번째 프로젝트",
slug: "first-project",
contentJson: [],
};
const secondPayload = {
title: "두 번째 프로젝트",
slug: "second-project",
contentJson: [],
};
await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(firstPayload),
}),
);
await new Promise((resolve) => setTimeout(resolve, 5));
await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(secondPayload),
}),
);
const { GET: listProjects } = await import("@/app/api/projects/route");
const res = await listProjects(new Request(`${BASE_URL}/api/projects`));
expect(res.status).toBe(200);
const list = (await res.json()) as any[];
expect(list.length).toBeGreaterThanOrEqual(2);
expect(list[0].slug).toBe("second-project");
expect(list[1].slug).toBe("first-project");
});
it("DELETE /api/projects/[slug] 로 프로젝트를 삭제할 수 있어야 한다", async () => {
const { POST: createProject } = await import("@/app/api/projects/route");
const slug = `delete-slug-${Date.now()}`;
const payload = {
title: "삭제 테스트 프로젝트",
slug,
contentJson: [],
};
await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
const { DELETE: deleteProject, GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
const deleteResponse = await deleteProject(
new Request(`${BASE_URL}/api/projects/${slug}`, { method: "DELETE" }),
{ params: { slug } } as any,
);
expect(deleteResponse.status).toBe(200);
const getAfterDelete = await getProjectBySlug(
new Request(`${BASE_URL}/api/projects/${slug}`),
{ params: { slug } } as any,
);
expect(getAfterDelete.status).toBe(404);
});
it("동일 slug 로 두 번 POST 하면 두 번째 요청은 기존 프로젝트를 업데이트해야 한다", async () => {
const { POST: handleProject } = await import("@/app/api/projects/route");
const slug = "duplicate-slug";
const firstPayload = {
title: "첫 번째 제목",
slug,
contentJson: [
{
id: "blk_first",
type: "text",
props: { text: "첫 번째 내용", align: "left", size: "base" },
},
],
};
const secondPayload = {
title: "두 번째 제목",
slug,
contentJson: [
{
id: "blk_second",
type: "text",
props: { text: "두 번째 내용", align: "left", size: "base" },
},
],
};
const firstRes = await handleProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(firstPayload),
}),
);
expect(firstRes.status).toBe(201);
const secondRes = await handleProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(secondPayload),
}),
);
expect([200, 201]).toContain(secondRes.status);
const updated = (await secondRes.json()) as any;
expect(updated.slug).toBe(slug);
expect(updated.title).toBe("두 번째 제목");
expect(updated.contentJson).toEqual(secondPayload.contentJson);
});
});
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, cleanup } from "@testing-library/react";
import EditorPage from "@/app/editor/page";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
// EditorPage 자동저장 TDD
// - blocks + projectConfig 를 localStorage 의 pb:autosave:<slug> 키로 자동 저장해야 한다.
// - localStorage 에 autosave 스냅샷이 있으면 초기 렌더에서 replaceBlocks + updateProjectConfig 로 복원해야 한다.
let mockState: any;
beforeEach(() => {
const baseProjectConfig: ProjectConfig = {
title: "자동저장 테스트 페이지",
slug: "autosave-test",
canvasPreset: "full",
} as ProjectConfig;
mockState = {
blocks: [
{
id: "blk_1",
type: "text",
props: {
text: "자동저장 테스트 블록",
align: "left",
size: "base",
},
},
] as Block[],
projectConfig: baseProjectConfig,
selectedBlockId: null as string | null,
selectedListItemId: null as string | null,
// EditorPage 가 참조하는 액션들: 기본적으로 no-op mock 으로 채운다.
undo: vi.fn(),
redo: vi.fn(),
removeBlock: vi.fn(),
duplicateBlock: vi.fn(),
selectBlock: vi.fn(),
selectListItem: vi.fn(),
replaceBlocks: vi.fn(),
reorderBlocks: vi.fn(),
moveBlock: vi.fn(),
addTextBlock: vi.fn(),
addButtonBlock: vi.fn(),
addImageBlock: vi.fn(),
addDividerBlock: vi.fn(),
addListBlock: vi.fn(),
addSectionBlock: vi.fn(),
addFormBlock: vi.fn(),
addFormInputBlock: vi.fn(),
addFormSelectBlock: vi.fn(),
addFormCheckboxBlock: vi.fn(),
addFormRadioBlock: vi.fn(),
addHeroTemplateSection: vi.fn(),
addFeaturesTemplateSection: vi.fn(),
addCtaTemplateSection: vi.fn(),
addFaqTemplateSection: vi.fn(),
addPricingTemplateSection: vi.fn(),
addTestimonialsTemplateSection: vi.fn(),
addBlogTemplateSection: vi.fn(),
addTeamTemplateSection: vi.fn(),
addFooterTemplateSection: vi.fn(),
updateBlock: vi.fn(),
updateProjectConfig: vi.fn(),
};
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
vi.mock("@/features/editor/state/editorStore", () => {
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
(useEditorStore as any).getState = () => mockState;
return {
__esModule: true,
useEditorStore,
};
});
vi.mock("next/link", () => {
return {
__esModule: true,
default: ({ href, children }: any) => <a href={href}>{children}</a>,
};
});
describe("EditorPage - 자동저장", () => {
it("blocks 와 projectConfig 를 localStorage 의 pb:autosave:<slug> 키로 자동 저장해야 한다", () => {
const storageProto = Object.getPrototypeOf(window.localStorage);
const setItemSpy = vi.spyOn(storageProto, "setItem");
render(<EditorPage />);
expect(setItemSpy).toHaveBeenCalled();
const [key, value] = setItemSpy.mock.calls[0] as [string, string];
expect(key).toBe("pb:autosave:autosave-test");
const parsed = JSON.parse(value);
expect(Array.isArray(parsed.blocks)).toBe(true);
expect(parsed.blocks[0].id).toBe("blk_1");
expect(parsed.projectConfig.slug).toBe("autosave-test");
});
it("localStorage 에 autosave 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 복원해야 한다", () => {
const savedBlocks: Block[] = [
{
id: "saved_1",
type: "text",
props: {
text: "저장된 블록",
align: "left",
size: "base",
},
} as any,
];
const savedConfig: ProjectConfig = {
title: "저장된 프로젝트",
slug: "autosave-test",
canvasPreset: "full",
} as ProjectConfig;
const payload = {
blocks: savedBlocks,
projectConfig: savedConfig,
};
window.localStorage.setItem("pb:autosave:autosave-test", JSON.stringify(payload));
render(<EditorPage />);
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks);
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(savedConfig);
});
});
+10 -56
View File
@@ -1,13 +1,11 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import EditorPage from "@/app/editor/page";
// EditorPage Export 미리보기 UX TDD
// - 상단 메뉴에 "Export 미리보기" 항목이 노출되검증한다.
// - Export 미리보기 모달에서 "미리보기 새로고침" 클릭 시
// - /api/export/preview 엔드포인트를 호출하고,
// - 반환된 HTML 을 iframe srcdoc 으로 렌더하는지 확인한다.
// EditorPage 상단 메뉴 UX TDD
// - Export 미리보기 기능 제거 후, 상단 메뉴에 더 이상 "Export 미리보기" 항목이 노출되지 않아야 한다.
// - 대신 프로젝트 저장/불러오기와 연계된 "프로젝트 목록" 항목이 메뉴에 노출되어야 한다.
let mockState: any;
@@ -88,61 +86,17 @@ vi.mock("next/link", () => {
};
});
describe("EditorPage - Export 미리보기", () => {
it("메뉴에서 Export 미리보기 항목을 클릭하면 Export 미리보기 모달이 열려야 한다", () => {
describe("EditorPage - 상단 메뉴 (Export 미리보기 제거)", () => {
it("메뉴를 열었을 때 'Export 미리보기' 항목은 보이지 않고, '프로젝트 목록' 항목이 노출되어야 한다", () => {
render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const previewMenuItem = screen.getByText("Export 미리보기");
fireEvent.click(previewMenuItem);
const removedItem = screen.queryByText("Export 미리보기");
expect(removedItem).toBeNull();
const modalTitle = screen.getByText("Export 미리보기");
expect(modalTitle).toBeTruthy();
});
it("Export 미리보기 새로고침 시 /api/export/preview 를 호출하고 iframe 에 HTML 을 렌더해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
"<!DOCTYPE html><html><head><title>Export 미리보기 테스트</title></head><body>Export 미리보기 본문</body></html>",
{
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
},
),
);
vi.stubGlobal("fetch", fetchMock);
render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const previewMenuItem = screen.getByText("Export 미리보기");
fireEvent.click(previewMenuItem);
const refreshButton = screen.getByText("미리보기 새로고침");
fireEvent.click(refreshButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const [url, options] = fetchMock.mock.calls[0] as any;
expect(url).toBe("/api/export/preview");
expect(options.method).toBe("POST");
expect(options.headers["Content-Type"]).toBe("application/json");
const parsed = JSON.parse(options.body);
expect(Array.isArray(parsed.blocks)).toBe(true);
expect(parsed.blocks[0].id).toBe("blk_text_1");
expect(parsed.projectConfig.slug).toBe("export-preview-test");
const iframe = await screen.findByTestId("export-preview-frame");
const srcDoc = iframe.getAttribute("srcdoc") ?? "";
expect(srcDoc).toContain("Export 미리보기 테스트");
expect(srcDoc).toContain("Export 미리보기 본문");
const projectListItem = screen.getByText("프로젝트 목록");
expect(projectListItem).toBeTruthy();
});
});
+195
View File
@@ -0,0 +1,195 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
import EditorPage from "@/app/editor/page";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
// EditorPage JSON 내보내기/불러오기 TDD
// - "JSON 내보내기" 클릭 시 blocks + projectConfig 를 포함한 JSON 을 에디터 상태 영역에 출력해야 한다.
// - "JSON 적용하기" 클릭 시 blocks + projectConfig 형태의 JSON 을 파싱해 replaceBlocks + updateProjectConfig 로 반영해야 한다.
// - 잘못된 JSON 이거나 구조가 맞지 않으면 replaceBlocks / updateProjectConfig 를 호출하지 않아야 한다.
let mockState: any;
beforeEach(() => {
const baseProjectConfig: ProjectConfig = {
title: "JSON Export/Import 테스트",
slug: "json-export-import-test",
canvasPreset: "full",
} as ProjectConfig;
mockState = {
blocks: [
{
id: "blk_1",
type: "text",
props: {
text: "JSON 테스트 블록",
align: "left",
size: "base",
},
},
] as Block[],
projectConfig: baseProjectConfig,
selectedBlockId: null as string | null,
selectedListItemId: null as string | null,
// EditorPage 가 참조하는 액션들: 기본적으로 no-op mock 으로 채운다.
undo: vi.fn(),
redo: vi.fn(),
removeBlock: vi.fn(),
duplicateBlock: vi.fn(),
selectBlock: vi.fn(),
selectListItem: vi.fn(),
replaceBlocks: vi.fn(),
reorderBlocks: vi.fn(),
moveBlock: vi.fn(),
addTextBlock: vi.fn(),
addButtonBlock: vi.fn(),
addImageBlock: vi.fn(),
addDividerBlock: vi.fn(),
addListBlock: vi.fn(),
addSectionBlock: vi.fn(),
addFormBlock: vi.fn(),
addFormInputBlock: vi.fn(),
addFormSelectBlock: vi.fn(),
addFormCheckboxBlock: vi.fn(),
addFormRadioBlock: vi.fn(),
addHeroTemplateSection: vi.fn(),
addFeaturesTemplateSection: vi.fn(),
addCtaTemplateSection: vi.fn(),
addFaqTemplateSection: vi.fn(),
addPricingTemplateSection: vi.fn(),
addTestimonialsTemplateSection: vi.fn(),
addBlogTemplateSection: vi.fn(),
addTeamTemplateSection: vi.fn(),
addFooterTemplateSection: vi.fn(),
updateBlock: vi.fn(),
updateProjectConfig: vi.fn(),
};
window.localStorage.clear();
});
afterEach(() => {
cleanup();
});
vi.mock("@/features/editor/state/editorStore", () => {
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
(useEditorStore as any).getState = () => mockState;
return {
__esModule: true,
useEditorStore,
};
});
vi.mock("next/link", () => {
return {
__esModule: true,
default: ({ href, children }: any) => <a href={href}>{children}</a>,
};
});
function openJsonModal() {
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const jsonMenuItem = screen.getByText("JSON 내보내기/불러오기");
fireEvent.click(jsonMenuItem);
const modalTitle = screen.getByText("JSON Export / Import");
const jsonModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
return jsonModal;
}
describe("EditorPage - JSON 내보내기/불러오기", () => {
it("JSON 내보내기 클릭 시 blocks + projectConfig 구조의 JSON 이 에디터 상태 영역에 출력되어야 한다", () => {
render(<EditorPage />);
const jsonModal = openJsonModal();
const exportLabel = within(jsonModal).getByText("에디터 상태 JSON").closest("label") as HTMLElement;
const exportTextarea = within(exportLabel).getByRole("textbox") as HTMLTextAreaElement;
// 초기에는 비어 있어야 한다.
expect(exportTextarea.value).toBe("");
const exportButton = within(jsonModal).getByText("JSON 내보내기");
fireEvent.click(exportButton);
const value = exportTextarea.value;
expect(value.trim().length).toBeGreaterThan(0);
const parsed = JSON.parse(value);
expect(Array.isArray(parsed.blocks)).toBe(true);
expect(parsed.blocks[0].id).toBe("blk_1");
expect(parsed.projectConfig.slug).toBe("json-export-import-test");
});
it("JSON 적용하기 클릭 시 blocks + projectConfig 구조의 JSON 으로 replaceBlocks 와 updateProjectConfig 를 호출해야 한다", () => {
render(<EditorPage />);
const jsonModal = openJsonModal();
const importLabel = within(jsonModal).getByText("JSON에서 불러오기").closest("label") as HTMLElement;
const importTextarea = within(importLabel).getByRole("textbox") as HTMLTextAreaElement;
const importedBlocks: Block[] = [
{
id: "imported_1",
type: "text",
props: {
text: "가져온 블록",
align: "center",
size: "lg",
},
} as any,
];
const importedConfig: ProjectConfig = {
title: "가져온 프로젝트",
slug: "imported-slug",
canvasPreset: "full",
} as ProjectConfig;
const payload = {
blocks: importedBlocks,
projectConfig: importedConfig,
};
fireEvent.change(importTextarea, { target: { value: JSON.stringify(payload) } });
// 초기 마운트 시 호출됐을 수 있는 기록은 무시한다.
mockState.replaceBlocks.mockReset();
mockState.updateProjectConfig.mockReset();
const applyButton = within(importLabel).getByText("JSON 적용하기");
fireEvent.click(applyButton);
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
expect(mockState.replaceBlocks).toHaveBeenCalledWith(importedBlocks as any);
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(importedConfig as ProjectConfig);
});
it("잘못된 JSON 은 무시되어야 하며 replaceBlocks/updateProjectConfig 를 호출하면 안 된다", () => {
render(<EditorPage />);
const jsonModal = openJsonModal();
const importLabel = within(jsonModal).getByText("JSON에서 불러오기").closest("label") as HTMLElement;
const importTextarea = within(importLabel).getByRole("textbox") as HTMLTextAreaElement;
fireEvent.change(importTextarea, { target: { value: "{ this-is: not-valid-json" } });
mockState.replaceBlocks.mockReset();
mockState.updateProjectConfig.mockReset();
const applyButton = within(importLabel).getByText("JSON 적용하기");
fireEvent.click(applyButton);
expect(mockState.replaceBlocks).not.toHaveBeenCalled();
expect(mockState.updateProjectConfig).not.toHaveBeenCalled();
});
});
+308
View File
@@ -0,0 +1,308 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor, within } from "@testing-library/react";
import EditorPage from "@/app/editor/page";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
// EditorPage 프로젝트 저장/불러오기 UX TDD
// - 프로젝트 저장 버튼 클릭 시 현재 blocks 를 로컬스토리지(pb:project:<slug>)와 서버(/api/projects) 모두에 저장해야 한다.
// - 불러오기 버튼 클릭 시 로컬스토리지에 프로젝트가 있으면 서버를 호출하지 않고 로컬 데이터로 replaceBlocks 해야 한다.
// - 로컬스토리지에 없으면 /api/projects/[slug] 를 호출해 서버에서 프로젝트를 불러와 replaceBlocks 해야 한다.
let mockState: any;
beforeEach(() => {
const baseProjectConfig: ProjectConfig = {
title: "프로젝트 저장/불러오기 테스트",
slug: "editor-project-test",
canvasPreset: "full",
} as ProjectConfig;
mockState = {
blocks: [
{
id: "blk_1",
type: "text",
props: {
text: "프로젝트 저장/불러오기 테스트 블록",
align: "left",
size: "base",
},
},
] as Block[],
projectConfig: baseProjectConfig,
selectedBlockId: null as string | null,
selectedListItemId: null as string | null,
// EditorPage 가 참조하는 액션들: 기본적으로 no-op mock 으로 채운다.
undo: vi.fn(),
redo: vi.fn(),
removeBlock: vi.fn(),
duplicateBlock: vi.fn(),
selectBlock: vi.fn(),
selectListItem: vi.fn(),
replaceBlocks: vi.fn(),
reorderBlocks: vi.fn(),
moveBlock: vi.fn(),
addTextBlock: vi.fn(),
addButtonBlock: vi.fn(),
addImageBlock: vi.fn(),
addDividerBlock: vi.fn(),
addListBlock: vi.fn(),
addSectionBlock: vi.fn(),
addFormBlock: vi.fn(),
addFormInputBlock: vi.fn(),
addFormSelectBlock: vi.fn(),
addFormCheckboxBlock: vi.fn(),
addFormRadioBlock: vi.fn(),
addHeroTemplateSection: vi.fn(),
addFeaturesTemplateSection: vi.fn(),
addCtaTemplateSection: vi.fn(),
addFaqTemplateSection: vi.fn(),
addPricingTemplateSection: vi.fn(),
addTestimonialsTemplateSection: vi.fn(),
addBlogTemplateSection: vi.fn(),
addTeamTemplateSection: vi.fn(),
addFooterTemplateSection: vi.fn(),
updateBlock: vi.fn(),
updateProjectConfig: vi.fn((partial: Partial<ProjectConfig>) => {
mockState.projectConfig = {
...(mockState.projectConfig as ProjectConfig),
...partial,
} as ProjectConfig;
}),
};
window.localStorage.clear();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
vi.mock("@/features/editor/state/editorStore", () => {
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
(useEditorStore as any).getState = () => mockState;
return {
__esModule: true,
useEditorStore,
};
});
vi.mock("next/link", () => {
return {
__esModule: true,
default: ({ href, children }: any) => <a href={href}>{children}</a>,
};
});
describe("EditorPage - 프로젝트 저장/불러오기", () => {
it("프로젝트 저장 버튼 클릭 시 로컬스토리지와 서버에 상태를 저장해야 하고, projectConfig 와 제목/주소 입력이 동기화되어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ slug: "save-test-slug" }),
} as any);
vi.stubGlobal("fetch", fetchMock);
const { rerender } = render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
fireEvent.click(projectMenuItem);
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
const titleInput = within(projectModal).getByLabelText("프로젝트 제목") as HTMLInputElement;
const slugInput = within(projectModal).getByLabelText("프로젝트 주소 (예: my-landing)") as HTMLInputElement;
expect(titleInput.value).toBe("프로젝트 저장/불러오기 테스트");
expect(slugInput.value).toBe("editor-project-test");
fireEvent.change(titleInput, { target: { value: "저장 테스트 프로젝트" } });
fireEvent.change(slugInput, { target: { value: "save-test-slug" } });
expect(mockState.projectConfig.title).toBe("저장 테스트 프로젝트");
expect(mockState.projectConfig.slug).toBe("save-test-slug");
rerender(<EditorPage />);
const saveButton = screen.getByText("저장 (로컬 + 서버)");
fireEvent.click(saveButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const localKey = "pb:project:save-test-slug";
const raw = window.localStorage.getItem(localKey);
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw as string);
expect(parsed.title).toBe("저장 테스트 프로젝트");
expect(Array.isArray(parsed.contentJson)).toBe(true);
expect(parsed.contentJson[0].id).toBe("blk_1");
const [url, options] = fetchMock.mock.calls[0] as any;
expect(url).toBe("/api/projects");
expect(options.method).toBe("POST");
expect(options.headers["Content-Type"]).toBe("application/json");
const body = JSON.parse(options.body);
expect(body.slug).toBe("save-test-slug");
expect(body.title).toBe("저장 테스트 프로젝트");
expect(Array.isArray(body.contentJson)).toBe(true);
expect(body.contentJson[0].id).toBe("blk_1");
const message = await screen.findByText("프로젝트가 저장되었습니다: save-test-slug");
expect(message).toBeTruthy();
});
it("로컬스토리지에 프로젝트가 있으면 서버를 호출하지 않고 로컬 데이터로 불러와야 한다", async () => {
const slug = "local-test-slug";
const localKey = `pb:project:${slug}`;
const savedBlocks: Block[] = [
{
id: "saved_1",
type: "text",
props: {
text: "로컬 저장된 블록",
align: "left",
size: "base",
},
} as any,
];
window.localStorage.setItem(
localKey,
JSON.stringify({
title: "로컬 프로젝트",
contentJson: savedBlocks,
}),
);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
mockState.projectConfig.slug = slug;
render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
fireEvent.click(projectMenuItem);
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
const loadButton = screen.getByText("불러오기");
fireEvent.click(loadButton);
await waitFor(() => {
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
});
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any);
expect(fetchMock).not.toHaveBeenCalled();
const message = await screen.findByText(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
expect(message).toBeTruthy();
});
it("로컬스토리지에 없으면 서버에서 프로젝트를 불러와야 한다", async () => {
const slug = "server-test-slug";
const serverBlocks: Block[] = [
{
id: "server_1",
type: "text",
props: {
text: "서버에서 불러온 블록",
align: "left",
size: "base",
},
} as any,
];
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ slug, contentJson: serverBlocks }),
} as any);
vi.stubGlobal("fetch", fetchMock);
mockState.projectConfig.slug = slug;
render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
fireEvent.click(projectMenuItem);
const loadButton = screen.getByText("불러오기");
fireEvent.click(loadButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const [url] = fetchMock.mock.calls[0] as any;
expect(url).toBe(`/api/projects/${slug}`);
await waitFor(() => {
expect(mockState.replaceBlocks).toHaveBeenCalledWith(serverBlocks as any);
});
const message = await screen.findByText(`프로젝트를 불러왔습니다: ${slug}`);
expect(message).toBeTruthy();
});
it("에디터 메뉴에서 프로젝트 삭제를 선택하면 서버에 DELETE 요청을 보내고 로컬 저장/자동저장 상태를 정리한 뒤 목록으로 이동해야 한다", async () => {
const slug = "delete-test-slug";
mockState.projectConfig.slug = slug;
// 로컬 저장/자동저장 스냅샷이 존재하는 상황을 준비한다.
window.localStorage.setItem(`pb:project:${slug}`, "stored-project");
window.localStorage.setItem(`pb:autosave:${slug}`, "stored-autosave");
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
} as any);
vi.stubGlobal("fetch", fetchMock);
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const deleteMenuItem = screen.getByText("프로젝트 삭제");
fireEvent.click(deleteMenuItem);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const [url, options] = fetchMock.mock.calls[0] as any;
expect(url).toBe(`/api/projects/${slug}`);
expect(options.method).toBe("DELETE");
expect(window.localStorage.getItem(`pb:project:${slug}`)).toBeNull();
expect(window.localStorage.getItem(`pb:autosave:${slug}`)).toBeNull();
confirmSpy.mockRestore();
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, cleanup } from "@testing-library/react";
import PreviewPage from "@/app/preview/page";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
// PreviewPage 자동 복원 TDD
// - localStorage 의 pb:autosave:<slug> 스냅샷을 기반으로 초기 렌더에서 replaceBlocks + updateProjectConfig 로 상태를 복원해야 한다.
let mockState: any;
beforeEach(() => {
const baseProjectConfig: ProjectConfig = {
title: "프리뷰 자동복원 테스트",
slug: "preview-autosave-test",
canvasPreset: "full",
} as ProjectConfig;
mockState = {
blocks: [
{
id: "blk_1",
type: "text",
props: {
text: "프리뷰 기본 블록",
align: "left",
size: "base",
},
},
] as Block[],
projectConfig: baseProjectConfig,
replaceBlocks: vi.fn(),
updateProjectConfig: vi.fn(),
};
window.localStorage.clear();
});
afterEach(() => {
cleanup();
});
vi.mock("@/features/editor/state/editorStore", () => {
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
(useEditorStore as any).getState = () => mockState;
return {
__esModule: true,
useEditorStore,
};
});
vi.mock("next/link", () => {
return {
__esModule: true,
default: ({ href, children }: any) => <a href={href}>{children}</a>,
};
});
describe("PreviewPage - 자동 복원", () => {
it("pb:autosave:<slug> 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 상태를 복원해야 한다", () => {
const savedBlocks: Block[] = [
{
id: "saved_1",
type: "text",
props: {
text: "저장된 프리뷰 블록",
align: "center",
size: "lg",
},
} as any,
];
const savedConfig: ProjectConfig = {
title: "저장된 프리뷰 프로젝트",
slug: "preview-autosave-test",
canvasPreset: "full",
} as ProjectConfig;
const payload = {
blocks: savedBlocks,
projectConfig: savedConfig,
};
window.localStorage.setItem("pb:autosave:preview-autosave-test", JSON.stringify(payload));
render(<PreviewPage />);
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any);
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(savedConfig as ProjectConfig);
});
});
+8
View File
@@ -53,6 +53,14 @@ vi.mock("next/link", () => {
});
describe("PreviewPage - ZIP Export", () => {
it("프리뷰 헤더에 프로젝트 목록 링크가 노출되고 /projects 로 연결되어야 한다", () => {
render(<PreviewPage />);
const link = screen.getByText("프로젝트 목록");
expect(link).toBeTruthy();
expect(link.closest("a")?.getAttribute("href")).toBe("/projects");
});
it("프리뷰 헤더에 ZIP Export 버튼이 노출되어야 한다", () => {
render(<PreviewPage />);
+230
View File
@@ -0,0 +1,230 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
import ProjectsPage from "@/app/projects/page";
// ProjectsPage 프로젝트 목록 TDD
// - 마운트 시 /api/projects 로 GET 요청을 보내고, 반환된 프로젝트 목록을 테이블로 렌더링해야 한다.
// - 각 프로젝트 행에는 /editor?slug=..., /preview?slug=... 로 이동하는 "편집" / "미리보기" 링크가 있어야 한다.
// - 각 프로젝트 행에는 프로젝트를 삭제하는 "삭제" 버튼이 있고, 클릭 시 서버에 DELETE 요청을 보내고 목록/로컬스토리지를 갱신해야 한다.
// - 체크박스로 여러 프로젝트를 선택한 뒤 상단의 "선택 삭제" 버튼으로 일괄 삭제할 수 있어야 한다.
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("ProjectsPage - 프로젝트 목록", () => {
it("/api/projects 로부터 받은 프로젝트 목록을 제목/slug 기준으로 렌더링하고, 편집/미리보기 링크를 노출해야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
{
id: "2",
title: "테스트 프로젝트 B",
slug: "test-project-b",
status: "PUBLISHED",
createdAt: "2025-01-02T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const [url, options] = fetchMock.mock.calls[0] as any;
expect(url).toBe("/api/projects");
expect(options).toBeUndefined();
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
expect(screen.getByText("test-project-a")).toBeTruthy();
expect(await screen.findByText("테스트 프로젝트 B")).toBeTruthy();
expect(screen.getByText("test-project-b")).toBeTruthy();
const editLinks = screen.getAllByText("편집");
expect(editLinks).toHaveLength(2);
expect(editLinks[0].closest("a")?.getAttribute("href")).toBe("/editor?slug=test-project-a");
expect(editLinks[1].closest("a")?.getAttribute("href")).toBe("/editor?slug=test-project-b");
const previewLinks = screen.getAllByText("미리보기");
expect(previewLinks).toHaveLength(2);
expect(previewLinks[0].closest("a")?.getAttribute("href")).toBe("/preview?slug=test-project-a");
expect(previewLinks[1].closest("a")?.getAttribute("href")).toBe("/preview?slug=test-project-b");
});
it("삭제 버튼 클릭 시 서버에 DELETE 요청을 보내고 목록과 로컬스토리지를 갱신해야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
{
id: "2",
title: "테스트 프로젝트 B",
slug: "test-project-b",
status: "PUBLISHED",
createdAt: "2025-01-02T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
},
];
window.localStorage.setItem("pb:project:test-project-a", "stored-project");
window.localStorage.setItem("pb:autosave:test-project-a", "stored-autosave");
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/projects" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/projects/test-project-a" && method === "DELETE") {
return Promise.resolve(new Response(null, { status: 200 }));
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock);
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
render(<ProjectsPage />);
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
const deleteButtons = screen.getAllByText("삭제");
expect(deleteButtons.length).toBeGreaterThan(0);
deleteButtons[0].click();
await waitFor(() => {
const deleteCall = fetchMock.mock.calls.find(([url, options]) => {
return url === "/api/projects/test-project-a" && options?.method === "DELETE";
});
expect(deleteCall).toBeDefined();
});
expect(screen.queryByText("테스트 프로젝트 A")).toBeNull();
expect(window.localStorage.getItem("pb:project:test-project-a")).toBeNull();
expect(window.localStorage.getItem("pb:autosave:test-project-a")).toBeNull();
confirmSpy.mockRestore();
});
it("체크박스로 여러 프로젝트를 선택한 뒤 '선택 삭제' 버튼으로 일괄 삭제할 수 있어야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
{
id: "2",
title: "테스트 프로젝트 B",
slug: "test-project-b",
status: "PUBLISHED",
createdAt: "2025-01-02T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
},
];
window.localStorage.setItem("pb:project:test-project-a", "stored-project-a");
window.localStorage.setItem("pb:autosave:test-project-a", "stored-autosave-a");
window.localStorage.setItem("pb:project:test-project-b", "stored-project-b");
window.localStorage.setItem("pb:autosave:test-project-b", "stored-autosave-b");
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/projects" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/projects/test-project-a" && method === "DELETE") {
return Promise.resolve(new Response(null, { status: 200 }));
}
if (url === "/api/projects/test-project-b" && method === "DELETE") {
return Promise.resolve(new Response(null, { status: 200 }));
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock);
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
render(<ProjectsPage />);
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
expect(await screen.findByText("테스트 프로젝트 B")).toBeTruthy();
const checkboxA = screen.getByLabelText("테스트 프로젝트 A 선택");
const checkboxB = screen.getByLabelText("테스트 프로젝트 B 선택");
fireEvent.click(checkboxA);
fireEvent.click(checkboxB);
const bulkDeleteButton = screen.getByText("선택 삭제");
fireEvent.click(bulkDeleteButton);
await waitFor(() => {
const deleteCalls = fetchMock.mock.calls.filter(([, options]) => options?.method === "DELETE");
const hasA = deleteCalls.some(([url]) => url === "/api/projects/test-project-a");
const hasB = deleteCalls.some(([url]) => url === "/api/projects/test-project-b");
expect(hasA).toBe(true);
expect(hasB).toBe(true);
});
expect(screen.queryByText("테스트 프로젝트 A")).toBeNull();
expect(screen.queryByText("테스트 프로젝트 B")).toBeNull();
expect(window.localStorage.getItem("pb:project:test-project-a")).toBeNull();
expect(window.localStorage.getItem("pb:autosave:test-project-a")).toBeNull();
expect(window.localStorage.getItem("pb:project:test-project-b")).toBeNull();
expect(window.localStorage.getItem("pb:autosave:test-project-b")).toBeNull();
confirmSpy.mockRestore();
});
});