CI: feature/editor-persistence #13

Merged
jaybe merged 3 commits from feature/editor-persistence into main 2025-11-30 08:39:18 +00:00
66 changed files with 3379 additions and 710 deletions
+10
View File
@@ -14,6 +14,7 @@
"@prisma/client": "^6.19.0", "@prisma/client": "^6.19.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^0.555.0",
"next": "^16.0.3", "next": "^16.0.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -7089,6 +7090,15 @@
"yallist": "^3.0.2" "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": { "node_modules/lz-string": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "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", "@prisma/client": "^6.19.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^0.555.0",
"next": "^16.0.3", "next": "^16.0.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^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(); const prisma = new PrismaClient();
interface Params { export async function GET(_request: Request, context: { params: { slug: string } | Promise<{ slug: string }> }) {
params: { const { slug } = await context.params;
slug: string;
};
}
export async function GET(_request: Request, { params }: Params) {
const { slug } = params;
const project = await prisma.project.findUnique({ const project = await prisma.project.findUnique({
where: { slug }, where: { slug },
@@ -22,3 +16,28 @@ export async function GET(_request: Request, { params }: Params) {
return NextResponse.json(project, { status: 200 }); 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(); 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) { export async function POST(request: Request) {
const body = await request.json(); const body = await request.json();
const { title, slug, contentJson } = body; const { title, slug, contentJson } = body;
@@ -12,8 +36,13 @@ export async function POST(request: Request) {
} }
try { try {
const project = await prisma.project.create({ const project = await prisma.project.upsert({
data: { where: { slug },
update: {
title,
contentJson,
},
create: {
title, title,
slug, slug,
contentJson, contentJson,
+1 -1
View File
@@ -86,7 +86,7 @@ export function EditorCanvas(props: EditorCanvasProps) {
> >
{blocks.length === 0 ? ( {blocks.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-slate-500 text-xs"> <div className="flex-1 flex items-center justify-center text-slate-500 text-xs">
"텍스트 블록 추가" . "텍스트" .
</div> </div>
) : ( ) : (
<DndContext <DndContext
+183 -122
View File
@@ -3,6 +3,7 @@
import type { CSSProperties, ReactNode } from "react"; import type { CSSProperties, ReactNode } from "react";
import { Fragment, useEffect, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { FilePlus2, Trash2, Eye, Pencil, ListChecks, Undo2, Redo2 } from "lucide-react";
import { import {
DndContext, DndContext,
PointerSensor, PointerSensor,
@@ -117,6 +118,7 @@ export default function EditorPage() {
const projectConfig = useEditorStore( const projectConfig = useEditorStore(
(state) => (state as any).projectConfig as ProjectConfig, (state) => (state as any).projectConfig as ProjectConfig,
); );
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
const [editingBlockId, setEditingBlockId] = useState<string | null>(null); const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
const [editingText, setEditingText] = useState(""); const [editingText, setEditingText] = useState("");
@@ -127,17 +129,17 @@ export default function EditorPage() {
const [exportJson, setExportJson] = useState(""); const [exportJson, setExportJson] = useState("");
const [importJson, setImportJson] = useState(""); const [importJson, setImportJson] = useState("");
const [projectTitle, setProjectTitle] = useState("");
const [projectSlug, setProjectSlug] = useState("");
const [loadSlug, setLoadSlug] = useState("");
const [projectMessage, setProjectMessage] = useState(""); const [projectMessage, setProjectMessage] = useState("");
// 멀티 선택(MVP)을 위한 로컬 선택 상태: 단일 선택(selectedBlockId)과 병행 사용한다. // 멀티 선택(MVP)을 위한 로컬 선택 상태: 단일 선택(selectedBlockId)과 병행 사용한다.
const [selectedBlockIds, setSelectedBlockIds] = useState<string[]>([]); const [selectedBlockIds, setSelectedBlockIds] = useState<string[]>([]);
const historyLength = useEditorStore((state) => (state as any).history?.length ?? 0);
const futureLength = useEditorStore((state) => (state as any).future?.length ?? 0);
const canUndo = historyLength > 0;
const canRedo = futureLength > 0;
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [activeModal, setActiveModal] = useState<"project" | "json" | "exportPreview" | null>(null); const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null);
const [exportPreviewHtml, setExportPreviewHtml] = useState("");
const [exportPreviewStatus, setExportPreviewStatus] = useState<"idle" | "loading" | "error">("idle");
const sensors = useSensors(useSensor(PointerSensor)); const sensors = useSensors(useSensor(PointerSensor));
@@ -190,37 +192,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 = () => { const commitEditing = () => {
if (!editingBlockId) return; if (!editingBlockId) return;
updateBlock(editingBlockId, { text: editingText }); updateBlock(editingBlockId, { text: editingText });
@@ -239,7 +210,11 @@ export default function EditorPage() {
const handleExportJson = () => { const handleExportJson = () => {
try { try {
const payload = JSON.stringify(blocks, null, 2); const snapshot = {
blocks,
projectConfig,
};
const payload = JSON.stringify(snapshot, null, 2);
setExportJson(payload); setExportJson(payload);
setImportJson(payload); setImportJson(payload);
} catch { } catch {
@@ -256,31 +231,42 @@ export default function EditorPage() {
const handleApplyImportJson = () => { const handleApplyImportJson = () => {
if (!importJson.trim()) return; if (!importJson.trim()) return;
try { try {
const parsed = JSON.parse(importJson); const parsed = JSON.parse(importJson) as {
if (!Array.isArray(parsed)) return; blocks?: Block[];
// 최소한의 구조 검증: id, type, props.text 가 있는 블록만 사용 projectConfig?: ProjectConfig;
const safeBlocks = parsed.filter((b) => b && typeof b.id === "string" && b.type === "text" && b.props && typeof b.props.text === "string"); };
replaceBlocks(safeBlocks);
// 내보내기 영역도 최신 상태로 업데이트 if (!parsed || typeof parsed !== "object") return;
setExportJson(JSON.stringify(safeBlocks, null, 2));
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 { } catch {
// 파싱 에러는 무시 (추후에는 토스트 등으로 피드백 가능) // 파싱 에러는 무시 (추후에는 토스트 등으로 피드백 가능)
} }
}; };
const handleSaveProject = async () => { const handleSaveProject = async () => {
const slug = projectSlug.trim(); const slug = (projectConfig.slug ?? "").trim();
if (!slug) { if (!slug) {
setProjectMessage("프로젝트 주소를 입력해 주세요."); setProjectMessage("프로젝트 주소를 입력해 주세요.");
return; return;
} }
const title = (projectConfig.title ?? "").trim() || "제목 없음";
try { try {
// 1) 로컬스토리지에 현재 상태 저장 (슬러그 기준) // 1) 로컬스토리지에 현재 상태 저장 (슬러그 기준)
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const localKey = `pb:project:${slug}`; const localKey = `pb:project:${slug}`;
const payload = { const payload = {
title: projectTitle.trim() || "제목 없음", title,
contentJson: blocks, contentJson: blocks,
}; };
window.localStorage.setItem(localKey, JSON.stringify(payload)); window.localStorage.setItem(localKey, JSON.stringify(payload));
@@ -293,7 +279,7 @@ export default function EditorPage() {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
title: projectTitle.trim() || "제목 없음", title,
slug, slug,
contentJson: blocks, contentJson: blocks,
}), }),
@@ -306,6 +292,10 @@ export default function EditorPage() {
const data = await response.json(); const data = await response.json();
setProjectMessage(`프로젝트가 저장되었습니다: ${data.slug}`); setProjectMessage(`프로젝트가 저장되었습니다: ${data.slug}`);
if (typeof window !== "undefined") {
window.location.href = "/projects";
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setProjectMessage("프로젝트 저장 중 오류가 발생했습니다."); setProjectMessage("프로젝트 저장 중 오류가 발생했습니다.");
@@ -313,7 +303,7 @@ export default function EditorPage() {
}; };
const handleLoadProject = async () => { const handleLoadProject = async () => {
const slug = loadSlug.trim() || projectSlug.trim(); const slug = (projectConfig.slug ?? "").trim();
if (!slug) { if (!slug) {
setProjectMessage("불러올 주소를 입력해 주세요."); setProjectMessage("불러올 주소를 입력해 주세요.");
return; return;
@@ -358,6 +348,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 handleDragStart = (event: DragStartEvent) => {
const { active } = event; const { active } = event;
setActiveDragId(String(active.id)); setActiveDragId(String(active.id));
@@ -524,6 +551,48 @@ export default function EditorPage() {
const rootBlocks = blocks.filter((block) => !block.sectionId); 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), // 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z),
// Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제, // Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제,
// ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다. // ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다.
@@ -675,25 +744,57 @@ export default function EditorPage() {
return ( return (
<main className="h-screen flex flex-col overflow-hidden"> <main className="h-screen flex flex-col overflow-hidden">
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between relative z-20"> <header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between relative z-20 bg-slate-950/80 backdrop-blur">
<div className="flex items-baseline gap-3"> <div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-slate-900 border border-sky-700 shadow-sm">
<Pencil className="w-4 h-4 text-sky-400" aria-hidden="true" />
</span>
<div className="flex flex-col">
<h1 className="text-xl font-semibold">Page Editor</h1> <h1 className="text-xl font-semibold">Page Editor</h1>
<p className="text-xs text-slate-400"> MVP용 </p> <p className="text-xs text-slate-400"> MVP용 </p>
</div> </div>
</div>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<Link <Link
href="/preview" 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" className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
> >
<ListChecks className="w-3 h-3" aria-hidden="true" />
<span> </span>
</Link> </Link>
<Link
href="/preview"
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
>
<Eye className="w-3 h-3" aria-hidden="true" />
<span> </span>
</Link>
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed"
onClick={undo}
disabled={!canUndo}
>
<Undo2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</button>
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed"
onClick={redo}
disabled={!canRedo}
>
<Redo2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</button>
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800" className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
onClick={() => setMenuOpen((prev) => !prev)} onClick={() => setMenuOpen((prev) => !prev)}
> >
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span></span>
<span className="text-[9px]"></span> <span className="text-[9px]"></span>
</button> </button>
{menuOpen && ( {menuOpen && (
@@ -706,7 +807,10 @@ export default function EditorPage() {
setActiveModal("project"); setActiveModal("project");
}} }}
> >
/ <span className="inline-flex items-center gap-2">
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span> /</span>
</span>
</button> </button>
<button <button
type="button" type="button"
@@ -716,17 +820,23 @@ export default function EditorPage() {
setActiveModal("json"); setActiveModal("json");
}} }}
> >
JSON / <span className="inline-flex items-center gap-2">
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span>JSON /</span>
</span>
</button> </button>
<button <button
type="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={() => { onClick={() => {
setMenuOpen(false); setMenuOpen(false);
setActiveModal("exportPreview"); void handleDeleteProject();
}} }}
> >
Export <span className="inline-flex items-center gap-2">
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</span>
</button> </button>
<button <button
type="button" type="button"
@@ -736,7 +846,10 @@ export default function EditorPage() {
void handleExportZip(); void handleExportZip();
}} }}
> >
(ZIP) <span className="inline-flex items-center gap-2">
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span> (ZIP)</span>
</span>
</button> </button>
<button <button
type="button" type="button"
@@ -748,7 +861,10 @@ export default function EditorPage() {
} }
}} }}
> >
<span className="inline-flex items-center gap-2">
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</span>
</button> </button>
</div> </div>
)} )}
@@ -806,16 +922,16 @@ export default function EditorPage() {
<span className="text-slate-400"> </span> <span className="text-slate-400"> </span>
<input <input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500" 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} value={projectConfig.title ?? ""}
onChange={(e) => setProjectTitle(e.target.value)} onChange={(e) => updateProjectConfig({ title: e.target.value })}
/> />
</label> </label>
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="text-slate-400"> (: my-landing)</span> <span className="text-slate-400"> (: my-landing)</span>
<input <input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500" 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} value={projectConfig.slug ?? ""}
onChange={(e) => setProjectSlug(e.target.value)} onChange={(e) => updateProjectConfig({ slug: e.target.value })}
/> />
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -834,14 +950,6 @@ export default function EditorPage() {
</button> </button>
</div> </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 && ( {projectMessage && (
<p className="text-[11px] text-slate-300 mt-1">{projectMessage}</p> <p className="text-[11px] text-slate-300 mt-1">{projectMessage}</p>
)} )}
@@ -850,54 +958,7 @@ export default function EditorPage() {
</div> </div>
)} )}
{activeModal === "exportPreview" && ( {/* Export 미리보기 기능은 제거되었습니다. */}
<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>
)}
{activeModal === "json" && ( {activeModal === "json" && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60"> <div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
+130 -26
View File
@@ -1,7 +1,23 @@
"use client"; "use client";
import { useCallback } from "react"; import { useCallback, useState } from "react";
import { useEditorStore } from "@/features/editor/state/editorStore"; import { useEditorStore } from "@/features/editor/state/editorStore";
import {
FilePlus2,
ListChecks,
Type,
MousePointerClick,
Image as ImageIcon,
Video,
Minus,
List,
LayoutTemplate,
Pencil,
FileText,
ListFilter,
CircleDot,
SquareCheck,
} from "lucide-react";
// 좌측 블록/폼/템플릿 추가 사이드바를 분리한 컴포넌트 // 좌측 블록/폼/템플릿 추가 사이드바를 분리한 컴포넌트
export function BlocksSidebar() { export function BlocksSidebar() {
@@ -69,101 +85,187 @@ export function BlocksSidebar() {
[addFooterTemplateSection], [addFooterTemplateSection],
); );
const [isBlocksOpen, setIsBlocksOpen] = useState(true);
const [isFormsOpen, setIsFormsOpen] = useState(true);
const [isTemplatesOpen, setIsTemplatesOpen] = useState(true);
return ( return (
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3 overflow-y-auto pb-scroll"> <aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3 overflow-y-auto pb-scroll bg-slate-950/40">
<h2 className="font-medium"></h2> <h2 className="font-medium flex items-center gap-2 text-slate-200">
<button
type="button"
className="flex items-center justify-between w-full text-left"
aria-expanded={isBlocksOpen}
onClick={() => setIsBlocksOpen((prev) => !prev)}
>
<span className="inline-flex items-center gap-2">
<Pencil className="w-4 h-4 text-sky-400" aria-hidden="true" />
<span></span>
</span>
</button>
</h2>
{isBlocksOpen && (
<>
<button <button
type="button" type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddText} onClick={handleAddText}
> >
<span className="inline-flex items-center gap-2">
<Type className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</button> </button>
<button <button
type="button" type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddButton} onClick={handleAddButton}
> >
<span className="inline-flex items-center gap-2">
<MousePointerClick className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</button> </button>
<button <button
type="button" type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddImage} onClick={handleAddImage}
> >
<span className="inline-flex items-center gap-2">
<ImageIcon className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</button> </button>
<button <button
type="button" type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddVideo} onClick={handleAddVideo}
> >
<span className="inline-flex items-center gap-2">
<Video className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</button> </button>
<button <button
type="button" type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddDivider} onClick={handleAddDivider}
> >
<span className="inline-flex items-center gap-2">
<Minus className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</button> </button>
<button <button
type="button" type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddList} onClick={handleAddList}
> >
<span className="inline-flex items-center gap-2">
<List className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</button> </button>
<button <button
type="button" type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddSection} onClick={handleAddSection}
> >
<span className="inline-flex items-center gap-2">
<LayoutTemplate className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</button> </button>
</>
)}
<div className="pt-3 border-t border-slate-800 mt-3 space-y-2"> <div className="pt-3 border-t border-slate-800 mt-3 space-y-2">
<h3 className="text-[11px] font-medium text-slate-300"> </h3> <h3 className="text-[11px] font-medium text-slate-300 flex items-center gap-2">
<button
type="button"
className="flex items-center justify-between w-full text-left"
aria-expanded={isFormsOpen}
onClick={() => setIsFormsOpen((prev) => !prev)}
>
<span className="inline-flex items-center gap-2">
<ListChecks className="w-3 h-3" aria-hidden="true" />
<span> </span>
</span>
</button>
</h3>
{isFormsOpen && (
<>
<button <button
type="button" type="button"
className="w-full rounded border border-emerald-700 bg-emerald-950 px-3 py-2 text-left text-xs text-emerald-100 hover:bg-emerald-900" className="w-full rounded border border-emerald-700 bg-emerald-950 px-3 py-2 text-left text-xs text-emerald-100 hover:bg-emerald-900"
onClick={handleAddFormBlock} onClick={handleAddFormBlock}
> >
<span className="inline-flex items-center gap-2">
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</span>
</button> </button>
<button <button
type="button" type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFormInput} onClick={handleAddFormInput}
> >
<span className="inline-flex items-center gap-2">
<FileText className="w-3 h-3" aria-hidden="true" />
<span>(Input)</span>
</span>
</button> </button>
<button <button
type="button" type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFormSelect} onClick={handleAddFormSelect}
> >
<span className="inline-flex items-center gap-2">
<ListFilter className="w-3 h-3" aria-hidden="true" />
<span>(Select)</span>
</span>
</button> </button>
<button <button
type="button" type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFormRadio} onClick={handleAddFormRadio}
> >
<span className="inline-flex items-center gap-2">
<CircleDot className="w-3 h-3" aria-hidden="true" />
<span> (Radio)</span>
</span>
</button> </button>
<button <button
type="button" type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFormCheckbox} onClick={handleAddFormCheckbox}
> >
<span className="inline-flex items-center gap-2">
<SquareCheck className="w-3 h-3" aria-hidden="true" />
<span> (Checkbox)</span>
</span>
</button> </button>
</>
)}
</div> </div>
<div className="pt-3 border-t border-slate-800 mt-3 space-y-3"> <div className="pt-3 border-t border-slate-800 mt-3 space-y-3">
<h3 className="text-[11px] font-medium text-slate-300">릿</h3> <h3 className="text-[11px] font-medium text-slate-300 flex items-center gap-2">
<button
type="button"
className="flex items-center justify-between w-full text-left"
aria-expanded={isTemplatesOpen}
onClick={() => setIsTemplatesOpen((prev) => !prev)}
>
<span className="inline-flex items-center gap-2">
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span>릿</span>
</span>
</button>
</h3>
{isTemplatesOpen && (
<div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400"> · CTA</p> <p className="text-[10px] font-semibold text-slate-400"> · CTA</p>
<div className="space-y-2"> <div className="space-y-2">
@@ -174,7 +276,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddHeroTemplate} onClick={handleAddHeroTemplate}
> >
Hero 릿 Hero 릿
</button> </button>
<div <div
data-testid="template-preview-hero" data-testid="template-preview-hero"
@@ -199,7 +301,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddCtaTemplate} onClick={handleAddCtaTemplate}
> >
CTA 릿 CTA 릿
</button> </button>
<div <div
data-testid="template-preview-cta" data-testid="template-preview-cta"
@@ -229,7 +331,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFeaturesTemplate} onClick={handleAddFeaturesTemplate}
> >
Features 릿 릿
</button> </button>
<div <div
data-testid="template-preview-features" data-testid="template-preview-features"
@@ -259,7 +361,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFaqTemplate} onClick={handleAddFaqTemplate}
> >
FAQ 릿 FAQ 릿
</button> </button>
<div <div
data-testid="template-preview-faq" data-testid="template-preview-faq"
@@ -285,7 +387,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddPricingTemplate} onClick={handleAddPricingTemplate}
> >
Pricing 릿 릿
</button> </button>
<div <div
data-testid="template-preview-pricing" data-testid="template-preview-pricing"
@@ -316,7 +418,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddBlogTemplate} onClick={handleAddBlogTemplate}
> >
Blog 릿 릿
</button> </button>
<div <div
data-testid="template-preview-blog" data-testid="template-preview-blog"
@@ -354,7 +456,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddTestimonialsTemplate} onClick={handleAddTestimonialsTemplate}
> >
Testimonials 릿 릿
</button> </button>
<div <div
data-testid="template-preview-testimonials" data-testid="template-preview-testimonials"
@@ -384,7 +486,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddTeamTemplate} onClick={handleAddTeamTemplate}
> >
Team 릿 Team 릿
</button> </button>
<div <div
data-testid="template-preview-team" data-testid="template-preview-team"
@@ -422,7 +524,7 @@ export function BlocksSidebar() {
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800" className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFooterTemplate} onClick={handleAddFooterTemplate}
> >
Footer 릿 Footer 릿
</button> </button>
<div <div
data-testid="template-preview-footer" data-testid="template-preview-footer"
@@ -441,6 +543,8 @@ export function BlocksSidebar() {
</div> </div>
</div> </div>
</div> </div>
)}
</div>
</aside> </aside>
); );
} }
+673 -4
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState } from "react";
import type { Block, ButtonBlockProps, FormBlockProps, ListBlockProps, SectionBlockProps, TextBlockProps, VideoBlockProps } from "@/features/editor/state/editorStore"; import type { Block, ButtonBlockProps, FormBlockProps, ListBlockProps, SectionBlockProps, TextBlockProps, VideoBlockProps } from "@/features/editor/state/editorStore";
import { ButtonPropertiesPanel } from "./ButtonPropertiesPanel"; import { ButtonPropertiesPanel } from "./ButtonPropertiesPanel";
import { TextPropertiesPanel } from "./TextPropertiesPanel"; import { TextPropertiesPanel } from "./TextPropertiesPanel";
@@ -14,6 +15,18 @@ import { FormCheckboxPropertiesPanel } from "../forms/FormCheckboxPropertiesPane
import { FormRadioPropertiesPanel } from "../forms/FormRadioPropertiesPanel"; import { FormRadioPropertiesPanel } from "../forms/FormRadioPropertiesPanel";
import { FormControllerPanel } from "../forms/FormControllerPanel"; import { FormControllerPanel } from "../forms/FormControllerPanel";
import { ProjectPropertiesPanel } from "./ProjectPropertiesPanel"; import { ProjectPropertiesPanel } from "./ProjectPropertiesPanel";
import { Trash2, Copy, SlidersHorizontal, HelpCircle } from "lucide-react";
type BlockHelpProperty = {
label: string;
description: string;
};
type BlockHelpContent = {
title: string;
description: string;
properties?: BlockHelpProperty[];
};
interface PropertiesSidebarProps { interface PropertiesSidebarProps {
blocks: Block[]; blocks: Block[];
@@ -48,16 +61,621 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
onOutdentSelectedItem, onOutdentSelectedItem,
} = props; } = props;
const [helpOpen, setHelpOpen] = useState(false);
const getHelpContentForSelectedBlock = (): BlockHelpContent | null => {
if (!selectedBlockId) return null;
const selectedBlock = blocks.find((b) => b.id === selectedBlockId);
if (!selectedBlock) return null;
switch (selectedBlock.type) {
case "text":
return {
title: "텍스트 블록 튜토리얼",
description:
"제목과 본문 텍스트를 입력할 때 사용하는 기본 블록입니다.\n\n좌측 패널에서 텍스트를 추가한 뒤, 캔버스에서 텍스트를 더블클릭하거나 속성 패널에서 내용을 수정해 보세요. 정렬, 크기, 색상 등을 조절해 다양한 스타일의 텍스트를 만들 수 있습니다.",
properties: [
{
label: "선택한 텍스트 블록 내용",
description:
"이 블록에 실제로 표시될 문장을 입력하는 영역입니다. 캔버스에서 텍스트를 더블클릭했을 때와 동일한 내용이며, 여러 줄을 입력하면 줄바꿈이 그대로 반영됩니다.",
},
{
label: "정렬",
description:
"텍스트를 왼쪽/가운데/오른쪽 중 어디에 맞출지 결정합니다. 일반 본문은 왼쪽, Hero 제목이나 짧은 강조 문구는 가운데 정렬을 추천합니다.",
},
{
label: "텍스트 스타일 (밑줄/가운데줄/이탤릭)",
description:
"밑줄은 링크처럼 보이게 하거나 특정 단어를 강조할 때, 가운데줄은 할인 전 가격처럼 취소선을 표현할 때, 이탤릭은 인용구나 제품명 등 살짝 톤을 바꾸고 싶을 때 사용합니다.",
},
{
label: "글자 크기",
description:
"텍스트의 폰트 크기를 px 단위로 조절합니다. 프리셋(XS~3XL)으로 대략적인 크기를 고른 뒤, 슬라이더/숫자 입력으로 세밀하게 조정할 수 있습니다.",
},
{
label: "글자 간격",
description:
"문자 사이의 간격(자간)을 조절합니다. 본문은 보통 또는 약간 넓게, 대문자 위주의 짧은 타이틀은 살짝 넓게 설정하면 가독성과 디자인이 좋아집니다.",
},
{
label: "줄 간격",
description:
"행과 행 사이의 간격(행간)을 정합니다. 본문은 1.5~1.7 정도가 읽기 좋고, 아주 짧은 제목은 1.25 정도로 줄이면 한 덩어리로 단단해 보입니다.",
},
{
label: "굵기",
description:
"폰트 두께를 100~900 범위에서 조절합니다. 본문은 보통(400), 섹션 제목은 세미볼드(600), 강한 CTA 문구는 볼드(700) 정도를 추천합니다.",
},
{
label: "텍스트 색상",
description:
"글자 색을 팔레트 또는 HEX 코드로 지정합니다. 본문은 대비가 높은 짙은 색을, 강조 문구는 브랜드 메인 색을 사용하는 것이 좋습니다.",
},
{
label: "블록 배경색",
description:
"텍스트가 들어 있는 카드/박스 전체 배경색을 지정합니다. 공지사항, 강조 박스, 경고 문구 등을 만들 때 연한 배경색과 함께 사용하면 좋습니다.",
},
{
label: "최대 너비",
description:
"한 줄 텍스트가 지나치게 길어지지 않도록 최대 줄 길이를 제한합니다. `본문 폭(60ch)`는 일반 문단에, `좁게(40ch)`는 카드/배너 같은 짧은 문구에 적합합니다.",
},
],
};
case "button":
return {
title: "버튼 블록 튜토리얼",
description:
"폼 제출이나 링크 이동을 위한 CTA 버튼을 만들 때 사용하는 블록입니다. 라벨, 링크(URL), 정렬, 색상/크기를 조절해 원하는 액션 버튼을 구성해 보세요.",
properties: [
{
label: "버튼 텍스트",
description:
"버튼 위에 표시될 문구입니다. 예: '지금 시작하기', '문의 남기기'. 짧고 행동을 유도하는 문장을 사용하는 것이 좋습니다.",
},
{
label: "가로 패딩 (px)",
description:
"버튼 양 옆의 여백을 조절합니다. 값이 클수록 버튼이 가로로 넓어져 더 강조됩니다.",
},
{
label: "세로 패딩 (px)",
description:
"버튼 위·아래 여백을 조절합니다. 값이 클수록 버튼 높이가 높아져 더 눈에 띱니다.",
},
{
label: "버튼 링크",
description:
"버튼 클릭 시 이동할 URL 또는 앵커(#section)입니다. 외부 페이지, 페이지 내 섹션, 폼 제출 엔드포인트 등으로 연결할 수 있습니다.",
},
{
label: "정렬",
description:
"해당 버튼이 포함된 영역 안에서 왼쪽/가운데/오른쪽 중 어디에 위치할지 결정합니다. 주요 CTA 버튼은 가운데 정렬이 많이 쓰입니다.",
},
{
label: "텍스트 색상",
description:
"버튼 라벨 글자 색입니다. 채움 색상과 충분한 대비가 나도록 설정해야 가독성이 좋습니다.",
},
{
label: "스타일",
description:
"버튼 외형 스타일입니다. '채움'은 꽉 찬 스타일, '외곽선'은 테두리만 있는 스타일, '고스트'는 배경 없이 텍스트/테두리만 있는 스타일입니다.",
},
{
label: "채움 색상",
description:
"채움 스타일에서 버튼 내부를 채우는 색입니다. 브랜드 메인 색이나 강조 색을 사용하면 좋습니다.",
},
{
label: "외곽선 색상",
description:
"외곽선/고스트 스타일에서 버튼 테두리 색입니다. 배경색과의 대비를 고려해 설정합니다.",
},
{
label: "모서리 둥글기",
description:
"버튼 모서리를 얼마나 둥글게 처리할지 결정합니다. '없음'은 각진 버튼, '완전 둥글게'는 알약 모양 버튼입니다.",
},
{
label: "버튼 너비 모드 / 버튼 고정 너비",
description:
"버튼이 컨테이너 너비에 맞춰 늘어날지(전체 폭), 내용 길이에 맞출지(자동), 고정 px 너비를 가질지 결정합니다.",
},
{
label: "버튼 크기",
description:
"버튼 라벨 텍스트의 폰트 크기입니다. 중요한 CTA 버튼은 주변 텍스트보다 조금 더 크게 설정하면 좋습니다.",
},
{
label: "줄 간격 / 글자 간격",
description:
"버튼 라벨의 행간과 자간을 조절합니다. 글자가 너무 답답해 보이면 자간을 약간 넓히고, 여러 줄 버튼 문구는 줄 간격을 조금 넓혀 주세요.",
},
],
};
case "image":
return {
title: "이미지 블록 튜토리얼",
description:
"배너, 썸네일, 설명 이미지를 배치할 때 사용하는 블록입니다. 외부 이미지 URL을 입력하거나 업로드 이미지를 선택하고, 너비/정렬/모서리 둥글기를 조절해 레이아웃에 맞게 배치해 보세요.",
properties: [
{
label: "이미지 소스",
description:
"이미지를 외부 URL에서 가져올지(URL), 빌더에 업로드한 에셋을 사용할지(파일 업로드) 선택합니다.",
},
{
label: "이미지 URL / 이미지 파일 업로드",
description:
"URL 모드에서는 외부 이미지 주소를 입력하고, 업로드 모드에서는 로컬 이미지를 업로드해 `/api/image/:id` 형식으로 관리합니다.",
},
{
label: "대체 텍스트",
description:
"이미지가 로드되지 않거나 스크린리더를 사용하는 사용자에게 보여질 설명 텍스트입니다. 이미지가 전달하는 의미를 한두 문장으로 작성해 주세요.",
},
{
label: "카드 배경색",
description:
"이미지를 감싸는 카드 영역의 배경색입니다. 투명한 PNG 아이콘이나 로고를 올릴 때 배경을 살짝 깔아주면 더 잘 보입니다.",
},
{
label: "정렬",
description:
"이미지가 포함된 영역 안에서 왼쪽/가운데/오른쪽 중 어디에 위치할지 결정합니다.",
},
{
label: "너비 모드 / 고정 너비 (px)",
description:
"이미지를 내용 크기에 맞출지(auto) 또는 px 단위로 고정된 너비를 사용할지 선택합니다. 썸네일 그리드에서는 고정 너비를 사용하는 편이 레이아웃이 안정적입니다.",
},
{
label: "모서리 둥글기",
description:
"이미지 카드 모서리 둥글기를 px 단위로 조절합니다. 아바타/아이콘은 완전 둥글게, 일반 사진은 살짝 둥글게 설정하면 자연스럽습니다.",
},
],
};
case "list":
return {
title: "리스트 블록 튜토리얼",
description:
"불릿/번호 리스트를 만들 때 사용하는 블록입니다. 아이템 텍스트를 수정하고, 정렬/불릿 스타일을 바꿔 체크리스트나 단계별 안내 등을 표현할 수 있습니다.",
properties: [
{
label: "리스트 아이템 (줄바꿈으로 구분)",
description:
"각 줄이 하나의 리스트 항목이 됩니다. 들여쓰기를 이용한 중첩 리스트는 TDD 기준의 변환 로직에 따라 자동으로 트리 구조로 변환됩니다.",
},
{
label: "정렬",
description:
"리스트 전체를 왼쪽/가운데/오른쪽 중 어디에 정렬할지 결정합니다. 체크리스트는 왼쪽 정렬, 특징 요약은 가운데 정렬이 자주 사용됩니다.",
},
{
label: "글자 크기 (px)",
description:
"리스트 텍스트의 폰트 크기를 조절합니다. 본문보다 약간 작게 설정하면 보조 정보 느낌을 줄 수 있습니다.",
},
{
label: "줄 간격",
description:
"리스트 항목 내부의 행간을 조절합니다. 항목 내용이 길어질수록 1.5~1.8 정도의 넉넉한 값을 추천합니다.",
},
{
label: "텍스트 색상",
description:
"리스트 텍스트의 색상입니다. 배경색과의 대비를 고려해 본문과 동일하거나 약간 연한 색상을 사용할 수 있습니다.",
},
{
label: "블록 배경색",
description:
"리스트 전체 카드의 배경색입니다. 단계별 안내나 체크리스트 박스를 강조하는 용도로 사용할 수 있습니다.",
},
{
label: "불릿 스타일",
description:
"불릿(●/○/■) 또는 번호(1., a., i.) 형식을 선택합니다. 숫자/알파벳/로마 숫자 스타일을 선택하면 자동으로 순서형 리스트(ordered)가 됩니다.",
},
{
label: "아이템 간 여백 (px)",
description:
"각 리스트 항목 사이의 세로 간격입니다. 설명이 긴 항목이 많다면 여백을 넉넉히 두어 가독성을 확보하세요.",
},
],
};
case "divider":
return {
title: "구분선 블록 튜토리얼",
description:
"섹션과 섹션 사이를 나누거나 콘텐츠를 시각적으로 구분할 때 사용하는 블록입니다. 정렬, 두께, 길이, 색상, 여백을 조절해 페이지 흐름을 정리해 보세요.",
properties: [
{
label: "정렬",
description:
"구분선을 컨테이너 안에서 왼쪽/가운데/오른쪽 중 어디에 위치시킬지 결정합니다.",
},
{
label: "두께",
description:
"선의 두께입니다. '얇게'는 가벼운 보조 구분선, '보통'은 섹션 경계를 확실히 나눌 때 사용합니다.",
},
{
label: "길이 모드 / 고정 길이 (px)",
description:
"구분선을 전체 폭으로 쓸지, 내용에 맞출지, 또는 px 단위의 고정 길이를 사용할지 선택합니다.",
},
{
label: "선 색상",
description:
"구분선 컬러입니다. 너무 진한 색보다는 섹션 배경보다 살짝 진한 정도의 중간 톤을 추천합니다.",
},
{
label: "위/아래 여백",
description:
"구분선 위·아래에 들어가는 공백입니다. 값이 클수록 섹션이 더 명확하게 나뉘어 보입니다.",
},
],
};
case "section":
return {
title: "섹션 블록 튜토리얼",
description:
"레이아웃의 큰 덩어리를 나누는 컨테이너 블록입니다. 배경색/배경 이미지/비디오, 위아래 패딩, 너비를 설정해 Hero, Features, Footer 같은 영역을 구성할 수 있습니다.",
properties: [
{
label: "배경 색상",
description:
"섹션 전체의 기본 배경색입니다. 페이지의 큰 분위기를 결정하는 요소이므로, 섹션 간 배경 대비를 적절히 주는 것이 좋습니다.",
},
{
label: "배경 이미지 소스 / URL / 파일 업로드",
description:
"섹션 배경으로 사용할 이미지를 외부 URL 또는 업로드 파일로 지정합니다. Hero 배경, 패턴, 질감 이미지를 설정할 때 사용합니다.",
},
{
label: "배경 이미지 크기 / 위치 / 반복",
description:
"cover/contain/auto로 크기를 조절하고, 프리셋 또는 X/Y 퍼센트로 위치를 조절하며, 반복 여부를 설정해 패턴 형태로 사용할 수 있습니다.",
},
{
label: "배경 비디오 소스 / URL / 파일 업로드",
description:
"섹션 배경에 비디오를 설정합니다. 시선을 끌어야 하는 Hero 영역 등에 사용하되, 가독성 저하를 막기 위해 텍스트 대비를 꼭 확인하세요.",
},
{
label: "세로 패딩",
description:
"섹션 위·아래 여백입니다. 값이 클수록 섹션이 여유 있고 강조되어 보입니다. Hero 섹션은 넉넉한 패딩을, 보조 섹션은 중간 정도를 추천합니다.",
},
],
};
case "video":
return {
title: "비디오 블록 튜토리얼",
description:
"YouTube/영상 URL 또는 직접 호스팅한 비디오를 삽입할 때 사용하는 블록입니다. 동영상 주소를 입력하고, 비율/정렬/재생 옵션을 조정해 페이지에 맞게 배치해 보세요.",
properties: [
{
label: "비디오 소스 / 비디오 URL / 비디오 파일 업로드",
description:
"외부 동영상 URL(YouTube, Vimeo 등)을 직접 입력하거나, 비디오 파일을 업로드해 `/api/video/:id` 형태로 사용할 수 있습니다.",
},
{
label: "포스터 이미지 URL",
description:
"동영상 재생 전/로딩 중에 보여줄 썸네일 이미지입니다. 영상의 핵심 장면이나 대표 이미지를 사용하면 클릭률을 높일 수 있습니다.",
},
{
label: "카드 배경색",
description:
"비디오가 들어가는 카드 영역의 배경색입니다. 주변 섹션과의 대비를 위해 살짝 어둡거나 밝은 색을 설정할 수 있습니다.",
},
{
label: "비디오 정렬",
description:
"비디오를 컨테이너 안에서 왼쪽/가운데/오른쪽 중 어디에 위치시킬지 결정합니다.",
},
{
label: "비디오 너비 모드 / 고정 너비 (px)",
description:
"비디오를 내용 너비에 맞출지, 가로 전체를 채울지, 특정 px 너비로 고정할지 선택합니다.",
},
{
label: "카드 패딩 / 카드 모서리 둥글기",
description:
"비디오 주변 카드의 안쪽 여백과 모서리 둥글기입니다. 카드형 레이아웃에서는 적당한 패딩과 둥근 모서리를 주면 디자인이 정돈됩니다.",
},
{
label: "시작 시점 (초) / 종료 시점 (초)",
description:
"영상의 특정 구간만 재생하고 싶을 때 사용하는 옵션입니다. 예를 들어 10초~30초만 재생하도록 설정할 수 있습니다.",
},
{
label: "화면 비율",
description:
"동영상 프레임 비율입니다. 대부분의 웹 영상은 16:9를 사용하며, 정사각형/세로형 콘텐츠는 1:1, 4:3 등을 사용할 수 있습니다.",
},
{
label: "자동 재생 / 반복 재생 / 음소거 / 재생 컨트롤 표시",
description:
"자동 재생/반복 재생/음소거/컨트롤 표시 여부를 설정합니다. 자동 재생 영상은 보통 음소거를 함께 켜 두는 것이 좋습니다.",
},
],
};
case "form":
return {
title: "폼 컨트롤러 블록 튜토리얼",
description:
"입력 필드(formInput/select/checkbox/radio)를 하나의 폼으로 묶는 컨트롤러입니다. 전송 대상, 전송 포맷, 너비, 여백 등을 설정해 네이티브 폼 제출을 구성하고, 필드 ID를 연결해 폼 구조를 완성할 수 있습니다.",
properties: [
{
label: "전송 대상 / Webhook 설정",
description:
"폼 데이터를 내부 처리로만 사용할지, 외부 Webhook/Google Sheets 등으로 전송할지 결정하고, 목적지 URL/전송 포맷/HTTP 메서드 등을 설정합니다.",
},
{
label: "추가 파라미터",
description:
"`key=value` 형식으로 줄바꿈해 작성하면, 모든 폼 제출에 공통으로 포함될 추가 파라미터를 지정할 수 있습니다. 예: `source=landing`.",
},
{
label: "폼 너비 모드 / 폼 고정 너비 (px)",
description:
"폼 전체의 가로 폭을 자동/전체 폭/고정 px 값 중에서 선택합니다. 문의 폼은 보통 480~640px 정도의 고정 폭이 읽기 좋습니다.",
},
{
label: "폼 위/아래 여백 (px)",
description:
"폼 블록 위·아래에 들어가는 공백입니다. 페이지 내 다른 섹션과의 간격을 조절할 때 사용합니다.",
},
{
label: "성공 메시지 / 에러 메시지",
description:
"폼 전송 성공 또는 실패 시 사용자에게 보여줄 텍스트입니다. 명확하고 친절한 문구로 작성하는 것이 좋습니다.",
},
{
label: "폼 필드 매핑",
description:
"폼 컨트롤러와 연결할 입력 필드(formInput/select/checkbox/radio)를 체크박스로 선택합니다. 체크된 필드만 실제 폼 제출 payload에 포함됩니다.",
},
{
label: "Submit 버튼",
description:
"폼 제출을 담당할 버튼 블록을 지정합니다. 별도 버튼 블록을 만들어 이 컨트롤러와 연결하면, 해당 버튼 클릭 시 폼이 제출됩니다.",
},
],
};
case "formInput":
return {
title: "폼 입력 블록 튜토리얼",
description:
"이름, 이메일 등 한 줄 텍스트 또는 긴 텍스트 입력 필드를 만들 때 사용하는 블록입니다. 레이블, 플레이스홀더, 필수 여부, 너비 등을 설정해 폼 컨트롤러와 함께 사용합니다.",
properties: [
{
label: "라벨 타입 / 필드 라벨",
description:
"입력 필드 위나 옆에 표시될 설명입니다. 텍스트 또는 이미지로 라벨을 구성할 수 있으며, 사용자가 어떤 값을 입력해야 하는지 명확하게 알려줍니다.",
},
{
label: "라벨 이미지 URL / 대체 텍스트",
description:
"라벨을 이미지로 사용할 때 경로와 alt 텍스트를 지정합니다. 로고형 라벨이나 아이콘 기반 UI를 만들 때 활용할 수 있습니다.",
},
{
label: "전송 키",
description:
"이 입력값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다. 백엔드나 스프레드시트 컬럼명과 일치시키면 이후 처리가 편해집니다.",
},
{
label: "필드 타입",
description:
"텍스트/이메일/긴 텍스트 중 어떤 형태의 입력을 받을지 결정합니다. 이메일 타입은 브라우저 기본 이메일 검증을 활용할 수 있습니다.",
},
{
label: "Placeholder",
description:
"입력 전 필드 안에 흐릿하게 보여줄 안내 문구입니다. 예: '이메일을 입력해 주세요'.",
},
{
label: "필수 필드",
description:
"체크 시 이 필드는 반드시 채워야 합니다. 이름/이메일 같은 필수 값에는 활성화하고, 선택 항목에는 비활성화하는 것을 추천합니다.",
},
{
label: "텍스트 크기 / 줄간격 / 자간",
description:
"입력 값의 폰트 크기(px), 줄간격(px), 자간(px)을 조절해 폼 필드의 가독성과 분위기를 세밀하게 튜닝합니다.",
},
{
label: "텍스트 정렬",
description:
"필드 안 텍스트를 왼쪽/가운데/오른쪽 중 어디에 정렬할지 결정합니다. 일반 입력은 왼쪽 정렬을 권장합니다.",
},
{
label: "레이아웃 / 라벨/필드 간격",
description:
"라벨과 필드를 세로(stack) 또는 가로(inline)로 배치하고, inline 모드에서 라벨과 입력 사이 간격(px)을 조절합니다.",
},
{
label: "너비 / 필드 고정 너비",
description:
"필드를 자동/전체 폭/고정 px 중 어떤 너비로 렌더링할지 결정합니다. 짧은 필드는 고정 값, 긴 입력은 전체 폭을 사용할 수 있습니다.",
},
{
label: "필드 가로/세로 패딩",
description:
"입력 상자 안쪽 여백을 조절합니다. 값이 클수록 필드가 커지고 누르기/입력하기 편해집니다.",
},
{
label: "필드 텍스트/채움/테두리 색상",
description:
"입력 텍스트, 배경, 테두리 색상을 각각 조절합니다. 에러 상태/강조 상태 등을 표현할 때 조합해서 사용할 수 있습니다.",
},
{
label: "필드 모서리 둥글기",
description:
"입력 상자의 모서리를 얼마나 둥글게 할지 결정합니다. 페이지의 전체 디자인 톤과 맞춰 사용하세요.",
},
],
};
case "formSelect":
return {
title: "폼 셀렉트 블록 튜토리얼",
description:
"드롭다운 선택 필드를 만들 때 사용하는 블록입니다. 옵션 목록과 기본 선택값, 너비를 설정해 폼 컨트롤러와 함께 사용합니다.",
properties: [
{
label: "라벨 타입 / 필드 라벨",
description:
"셀렉트 필드의 제목 역할을 하는 라벨입니다. 텍스트 또는 이미지로 구성할 수 있으며, 선택해야 할 값의 의미를 설명합니다.",
},
{
label: "전송 키",
description:
"선택된 옵션 값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다. 백엔드나 스프레드시트 컬럼명과 일치시키면 이후 처리가 편해집니다.",
},
{
label: "옵션",
description:
"사용자가 선택할 수 있는 옵션 목록입니다. 라벨과 value를 함께 설정하며, '옵션 추가' 버튼으로 새 옵션을 만들 수 있습니다.",
},
{
label: "필수 필드",
description:
"체크 시 반드시 하나의 옵션을 선택해야 합니다. 중요 선택 항목에는 필수로 설정하는 것을 권장합니다.",
},
{
label: "셀렉트 텍스트 크기 / 줄간격 / 자간",
description:
"드롭다운 필드의 텍스트 타이포그래피를 px 단위로 조절해 가독성과 분위기를 맞춥니다.",
},
{
label: "필드 너비 / 필드 고정 너비",
description:
"셀렉트 필드를 자동/전체 폭/고정 px 중 어떤 너비로 렌더링할지 결정합니다.",
},
{
label: "셀렉트 가로/세로 패딩",
description:
"드롭다운 안쪽 여백을 조절해 클릭 영역과 시각적 무게감을 조정합니다.",
},
{
label: "필드 텍스트/채움/테두리 색상",
description:
"선택 영역의 텍스트, 배경, 테두리 색상을 설정합니다. 폼 전체 톤과 잘 어울리는 색상을 사용하는 것이 좋습니다.",
},
{
label: "필드 모서리 둥글기",
description:
"셀렉트 박스의 모서리를 얼마나 둥글게 표시할지 결정합니다.",
},
],
};
case "formCheckbox":
return {
title: "폼 체크박스 블록 튜토리얼",
description:
"여러 항목 중 복수 선택이 필요한 경우 사용하는 체크박스 그룹 블록입니다. 그룹 타이틀과 옵션 라벨/이미지를 설정해 동의 항목이나 관심사 선택 등을 구성할 수 있습니다.",
properties: [
{
label: "그룹 타이틀 타입 / 그룹 타이틀",
description:
"체크박스 그룹 전체의 제목입니다. 텍스트 또는 이미지로 표현할 수 있으며, 사용자가 어떤 범주를 선택하는지 알려줍니다.",
},
{
label: "그룹 타이틀 이미지 소스 / URL / 파일 업로드",
description:
"그룹 라벨을 이미지로 사용할 때, URL 또는 업로드된 이미지를 선택합니다.",
},
{
label: "전송 키",
description:
"체크된 옵션들의 값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다.",
},
{
label: "옵션 / 옵션 이미지 소스",
description:
"각 체크박스 옵션의 라벨/값/이미지를 설정합니다. URL 또는 업로드 이미지로 옵션별 아이콘을 붙일 수 있습니다.",
},
{
label: "필수 필드",
description:
"체크 시 하나 이상 옵션을 선택해야 합니다. 약관 동의 등 필수 동의 항목에 사용합니다.",
},
{
label: "체크박스 텍스트 크기 / 줄간격 / 자간",
description:
"체크박스 라벨의 폰트 크기, 줄간격, 자간을 조절합니다. 항목이 길어질수록 줄간격을 넉넉히 두는 것이 좋습니다.",
},
],
};
case "formRadio":
return {
title: "폼 라디오 블록 튜토리얼",
description:
"여러 옵션 중 하나만 선택해야 할 때 사용하는 라디오 버튼 그룹 블록입니다. 옵션 라벨/이미지와 기본 선택값을 설정해 요금제 선택 등 단일 선택 시나리오를 구성할 수 있습니다.",
properties: [
{
label: "그룹 타이틀 타입 / 그룹 타이틀",
description:
"라디오 그룹의 제목입니다. 요금제 이름, 질문 문구 등 단일 선택의 맥락을 설명합니다.",
},
{
label: "그룹 타이틀 이미지 소스 / URL / 파일 업로드",
description:
"그룹 라벨을 이미지로 사용할 때, URL 또는 업로드 이미지를 선택합니다.",
},
{
label: "전송 키",
description:
"선택된 하나의 옵션 값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다.",
},
{
label: "옵션 / 옵션 이미지 소스",
description:
"각 라디오 옵션의 라벨/값/이미지를 설정합니다. 옵션별 아이콘이나 썸네일을 붙여 더 풍부한 선택 UI를 만들 수 있습니다.",
},
{
label: "필수 필드",
description:
"체크 시 반드시 하나의 옵션을 선택해야 합니다. 요금제/유형 선택처럼 반드시 선택이 필요한 경우에 사용합니다.",
},
{
label: "라디오 텍스트 크기 / 줄간격 / 자간",
description:
"라디오 라벨의 폰트 크기, 줄간격, 자간을 조절합니다. 옵션 설명이 긴 경우 줄간격을 넉넉히 두세요.",
},
],
};
default:
return {
title: "블록 튜토리얼",
description: "이 블록에 대한 자세한 도움말은 추후 추가될 예정입니다.",
};
}
};
return ( return (
<aside <aside
data-testid="properties-sidebar" data-testid="properties-sidebar"
className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto pb-scroll" className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto pb-scroll bg-slate-950/40"
onKeyDownCapture={(e) => { onKeyDownCapture={(e) => {
// 속성 패널 안에서 발생한 키 입력은 에디터 단축키/텍스트 블록 편집으로 전달되지 않도록 막는다. // 속성 패널 안에서 발생한 키 입력은 에디터 단축키/텍스트 블록 편집으로 전달되지 않도록 막는다.
e.stopPropagation(); e.stopPropagation();
}} }}
> >
<h2 className="font-medium mb-2"> </h2> <h2 className="font-medium mb-2 flex items-center gap-2 text-slate-200">
<SlidersHorizontal className="w-4 h-4 text-sky-400" aria-hidden="true" />
<span> </span>
</h2>
{selectedBlockId ? ( {selectedBlockId ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex gap-2 text-[11px]"> <div className="flex gap-2 text-[11px]">
@@ -66,14 +684,30 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-red-900/60 hover:border-red-700" className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-red-900/60 hover:border-red-700"
onClick={() => removeBlock(selectedBlockId)} onClick={() => removeBlock(selectedBlockId)}
> >
<span className="inline-flex items-center gap-2">
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</span>
</button> </button>
<button <button
type="button" type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-slate-800" className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-slate-800"
onClick={() => duplicateBlock(selectedBlockId)} onClick={() => duplicateBlock(selectedBlockId)}
> >
<span className="inline-flex items-center gap-2">
<Copy className="w-3 h-3" aria-hidden="true" />
<span> </span>
</span>
</button>
</div>
<div className="flex justify-end text-[11px]">
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-slate-800"
onClick={() => setHelpOpen(true)}
>
<HelpCircle className="w-3 h-3" aria-hidden="true" />
<span>HELP</span>
</button> </button>
</div> </div>
{(() => { {(() => {
@@ -224,6 +858,41 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
) : ( ) : (
<ProjectPropertiesPanel /> <ProjectPropertiesPanel />
)} )}
{helpOpen && (() => {
const help = getHelpContentForSelectedBlock();
if (!help) return null;
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60">
<div className="w-full max-w-md 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-2">
<h3 className="text-sm font-medium">{help.title}</h3>
<button
type="button"
className="text-slate-400 hover:text-slate-100 text-xs"
onClick={() => setHelpOpen(false)}
>
</button>
</div>
<p className="text-[11px] text-slate-200 whitespace-pre-line">{help.description}</p>
{help.properties && help.properties.length > 0 && (
<div className="mt-3 border-t border-slate-700 pt-2 space-y-2">
<h4 className="text-[11px] font-semibold text-slate-200"> </h4>
<ul className="space-y-1">
{help.properties.map((prop) => (
<li key={prop.label}>
<div className="text-[11px] font-semibold text-slate-100">{prop.label}</div>
<div className="text-[11px] text-slate-300">{prop.description}</div>
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
})()}
</aside> </aside>
); );
} }
+32
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useEditorStore } from "@/features/editor/state/editorStore"; import { useEditorStore } from "@/features/editor/state/editorStore";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer"; import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
@@ -8,6 +9,31 @@ import { PublicPageRenderer } from "@/features/editor/components/PublicPageRende
export default function PreviewPage() { export default function PreviewPage() {
const blocks = useEditorStore((state) => state.blocks); const blocks = useEditorStore((state) => state.blocks);
const projectConfig = useEditorStore((state) => (state as any).projectConfig); 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 canvasStyle: CSSProperties = {};
const preset = projectConfig?.canvasPreset ?? "full"; const preset = projectConfig?.canvasPreset ?? "full";
@@ -88,6 +114,12 @@ export default function PreviewPage() {
> >
(ZIP) (ZIP)
</button> </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 <Link
href="/editor" 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" 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>
);
}
@@ -404,8 +404,11 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
: tokens.bulletStyle, : tokens.bulletStyle,
}} }}
> >
{nodes.map((node) => ( {nodes.map((node, index) => (
<li key={node.id}> <li
key={node.id}
style={index < nodes.length - 1 ? { marginBottom: `${tokens.gapEm}em` } : undefined}
>
{node.text} {node.text}
{node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)} {node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)}
</li> </li>
+62
View File
@@ -263,6 +263,27 @@ export const computeFormInputEditorTokens = (props: FormInputBlockProps): FormIn
fieldStyle.borderRadius = 9999; fieldStyle.borderRadius = 9999;
} }
// 에디터에서는 px 기반 padding/타이포 값을 그대로 fieldStyle 에 반영해 미리보기에서 변화가 보이도록 한다.
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
fieldStyle.paddingInline = `${props.paddingX}px`;
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
fieldStyle.paddingBlock = `${props.paddingY}px`;
}
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
fieldStyle.fontSize = props.fontSizeCustom.trim();
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
fieldStyle.lineHeight = props.lineHeightCustom.trim();
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
fieldStyle.letterSpacing = props.letterSpacingCustom.trim();
}
const labelLayout = props.labelLayout ?? "stacked"; const labelLayout = props.labelLayout ?? "stacked";
const isInline = labelLayout === "inline"; const isInline = labelLayout === "inline";
@@ -324,6 +345,27 @@ export const computeFormSelectEditorTokens = (props: FormSelectBlockProps): Form
fieldStyle.borderRadius = 9999; fieldStyle.borderRadius = 9999;
} }
// 체크박스/라디오 그룹도 에디터에서 padding/타이포 스타일을 일부 반영해준다.
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
fieldStyle.paddingInline = `${props.paddingX}px`;
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
fieldStyle.paddingBlock = `${props.paddingY}px`;
}
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
fieldStyle.fontSize = props.fontSizeCustom.trim();
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
fieldStyle.lineHeight = props.lineHeightCustom.trim();
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
fieldStyle.letterSpacing = props.letterSpacingCustom.trim();
}
return { fieldStyle }; return { fieldStyle };
}; };
@@ -356,6 +398,26 @@ export const computeFormOptionGroupEditorTokens = (
fieldStyle.borderRadius = 9999; fieldStyle.borderRadius = 9999;
} }
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
fieldStyle.paddingInline = `${props.paddingX}px`;
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
fieldStyle.paddingBlock = `${props.paddingY}px`;
}
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
fieldStyle.fontSize = props.fontSizeCustom.trim();
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
fieldStyle.lineHeight = props.lineHeightCustom.trim();
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
fieldStyle.letterSpacing = props.letterSpacingCustom.trim();
}
return { fieldStyle }; return { fieldStyle };
}; };
+203 -1
View File
@@ -9,13 +9,80 @@ vi.mock("@/generated/prisma/client", () => {
class PrismaClientMock { class PrismaClientMock {
project = { project = {
create: async ({ data }: any) => { 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); inMemoryProjects.push(project);
return project; return project;
}, },
findUnique: async ({ where: { slug } }: any) => { findUnique: async ({ where: { slug } }: any) => {
return inMemoryProjects.find((p) => p.slug === slug) ?? null; 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.slug).toBe(payload.slug);
expect(fetched.contentJson).toEqual(payload.contentJson); 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);
});
}); });
+82 -64
View File
@@ -8,10 +8,10 @@ test("/editor 페이지가 존재하고 Page Editor 헤더를 보여줘야 한
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
}); });
test("텍스트 블록 추가 버튼을 누르면 캔버스에 '새 텍스트' 블록이 보여야 한다", async ({ page }) => { test("텍스트 버튼을 누르면 캔버스에 '새 텍스트' 블록이 보여야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
await expect(canvas.getByText("새 텍스트")).toBeVisible(); await expect(canvas.getByText("새 텍스트")).toBeVisible();
@@ -75,7 +75,7 @@ test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야
await page.goto("/editor"); await page.goto("/editor");
// 텍스트 블록을 하나 추가한다. // 텍스트 블록을 하나 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
// 1단계: 더블클릭 → Enter 로 커밋 // 1단계: 더블클릭 → Enter 로 커밋
// 기존 텍스트 블록을 더블클릭해서 편집 모드로 전환한다. // 기존 텍스트 블록을 더블클릭해서 편집 모드로 전환한다.
@@ -109,7 +109,7 @@ test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야
await editor.fill("blur 로 커밋된 텍스트"); await editor.fill("blur 로 커밋된 텍스트");
// 포커스를 다른 곳(예: 속성 패널의 버튼 추가 텍스트 입력)을 클릭하여 blur 를 발생시킨다. // 포커스를 다른 곳(예: 속성 패널의 버튼 추가 텍스트 입력)을 클릭하여 blur 를 발생시킨다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
// blur 이후에는 마지막 입력 값이 캔버스에 반영되어야 한다. // blur 이후에는 마지막 입력 값이 캔버스에 반영되어야 한다.
canvasAfterEdit = page.getByTestId("editor-canvas"); canvasAfterEdit = page.getByTestId("editor-canvas");
@@ -120,7 +120,7 @@ test("속성 패널에서 선택된 텍스트 블록 내용을 수정하면 캔
await page.goto("/editor"); await page.goto("/editor");
// 텍스트 블록을 추가한다. // 텍스트 블록을 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
// 캔버스 블록을 클릭해서 선택한다. // 캔버스 블록을 클릭해서 선택한다.
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
@@ -140,8 +140,8 @@ test("여러 텍스트 블록 중 선택을 전환하면 속성 패널 내용과
await page.goto("/editor"); await page.goto("/editor");
// 텍스트 블록 두 개를 추가한다. // 텍스트 블록 두 개를 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
@@ -172,7 +172,7 @@ test("텍스트 블록의 정렬과 글자 크기를 속성 패널에서 변경
await page.goto("/editor"); await page.goto("/editor");
// 텍스트 블록을 하나 추가하고 선택한다. // 텍스트 블록을 하나 추가하고 선택한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
const block = canvas.getByTestId("editor-block").nth(0); const block = canvas.getByTestId("editor-block").nth(0);
await block.click({ force: true }); await block.click({ force: true });
@@ -196,7 +196,7 @@ test("텍스트 블록 인라인 툴바에서 정렬과 글자 크기를 변경
await page.goto("/editor"); await page.goto("/editor");
// 텍스트 블록을 하나 추가한다. // 텍스트 블록을 하나 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).click();
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
const block = canvas.getByTestId("editor-block").nth(0); const block = canvas.getByTestId("editor-block").nth(0);
@@ -226,8 +226,8 @@ test("에디터 상태를 JSON으로 내보내고 다시 불러오면 동일한
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 블록 두 개를 추가하고 각각 다른 내용/스타일을 설정한다. // 블록 두 개를 추가하고 각각 다른 내용/스타일을 설정한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
const firstBlock = blocks.nth(0); const firstBlock = blocks.nth(0);
@@ -290,8 +290,8 @@ test("각 캔버스 블록에 드래그 핸들 UI가 표시되고 선택 상태
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 블록 두 개를 추가한다. // 블록 두 개를 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
const firstBlock = blocks.nth(0); const firstBlock = blocks.nth(0);
@@ -318,8 +318,8 @@ test("블록 드래그앤드롭으로 순서를 변경할 수 있어야 한다",
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 블록 두 개를 추가하고 서로 다른 텍스트로 구분한다. // 블록 두 개를 추가하고 서로 다른 텍스트로 구분한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
const firstBlock = blocks.nth(0); const firstBlock = blocks.nth(0);
@@ -352,8 +352,8 @@ test("리스트 블록에도 드래그 핸들이 있고 선택/드래그가 가
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 텍스트 블록과 리스트 블록을 하나씩 추가한다. // 텍스트 블록과 리스트 블록을 하나씩 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "리스트 블록 추가" }).click(); await page.getByRole("button", { name: "리스트" }).click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(2); await expect(blocks).toHaveCount(2);
@@ -387,7 +387,7 @@ test("버튼 블록을 추가하고 라벨과 링크를 수정할 수 있어야
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 버튼 블록을 추가한다. // 버튼 블록을 추가한다.
await page.getByRole("button", { name: "버튼 블록 추가" }).click(); await page.getByRole("button", { name: "버튼" }).click();
// 캔버스에 기본 버튼이 렌더되어야 한다. // 캔버스에 기본 버튼이 렌더되어야 한다.
const button = canvas.getByRole("button", { name: "버튼" }); const button = canvas.getByRole("button", { name: "버튼" });
@@ -410,7 +410,7 @@ test("버튼 블록을 추가하면 속성 패널 기본 색상/패딩 값이
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "버튼 블록 추가" }).click(); await page.getByRole("button", { name: "버튼" }).click();
const button = canvas.getByRole("button", { name: "버튼" }); const button = canvas.getByRole("button", { name: "버튼" });
await expect(button).toBeVisible(); await expect(button).toBeVisible();
@@ -437,7 +437,7 @@ test("버튼 가로/세로 패딩 px 값을 변경하면 에디터 버튼에도
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "버튼 블록 추가" }).click(); await page.getByRole("button", { name: "버튼" }).click();
const button = canvas.getByRole("button", { name: "버튼" }).first(); const button = canvas.getByRole("button", { name: "버튼" }).first();
@@ -470,7 +470,7 @@ test("버튼 가로/세로 패딩 px 값을 변경하면 에디터 버튼에도
test("이미지 블록 고정 너비와 모서리 둥글기 스타일이 에디터에서 적용되어야 한다", async ({ page }) => { test("이미지 블록 고정 너비와 모서리 둥글기 스타일이 에디터에서 적용되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "이미지 블록 추가" }).click(); await page.getByRole("button", { name: "이미지" }).click();
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -504,7 +504,7 @@ test("에디터 메뉴에서 페이지 파일로 내보내기 (ZIP)를 클릭하
await page.goto("/editor"); await page.goto("/editor");
// 최소 한 개의 블록을 추가해 내보낼 데이터가 있도록 한다. // 최소 한 개의 블록을 추가해 내보낼 데이터가 있도록 한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
// 헤더 메뉴를 연다. // 헤더 메뉴를 연다.
await page.getByRole("button", { name: "메뉴 ▼" }).click(); await page.getByRole("button", { name: "메뉴 ▼" }).click();
@@ -528,7 +528,7 @@ test("2컬럼 섹션에서 블록을 다른 컬럼 영역으로 드래그하면
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 섹션을 하나 추가하고 레이아웃을 2컬럼으로 변경한다. // 섹션을 하나 추가하고 레이아웃을 2컬럼으로 변경한다.
await page.getByRole("button", { name: "섹션 블록 추가" }).click(); await page.getByRole("button", { name: "섹션" }).click();
// 섹션을 선택한다 (캔버스 첫 블록). // 섹션을 선택한다 (캔버스 첫 블록).
const sectionBlock = canvas.getByTestId("editor-block").nth(0); const sectionBlock = canvas.getByTestId("editor-block").nth(0);
@@ -538,7 +538,7 @@ test("2컬럼 섹션에서 블록을 다른 컬럼 영역으로 드래그하면
await layoutSelect.selectOption("2col"); await layoutSelect.selectOption("2col");
// 왼쪽 컬럼에 텍스트 블록을 하나 추가한다. // 왼쪽 컬럼에 텍스트 블록을 하나 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
const leftColumn = canvas.locator('[data-section-id][data-column-id]').nth(0); const leftColumn = canvas.locator('[data-section-id][data-column-id]').nth(0);
const rightColumn = canvas.locator('[data-section-id][data-column-id]').nth(1); const rightColumn = canvas.locator('[data-section-id][data-column-id]').nth(1);
@@ -563,7 +563,7 @@ test("Hero 템플릿 버튼을 클릭하면 섹션과 기본 텍스트/버튼
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// Hero 템플릿을 추가한다. // Hero 템플릿을 추가한다.
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click(); await page.getByRole("button", { name: "Hero 템플릿" }).click();
// 섹션/블록이 하나 이상 존재해야 한다. // 섹션/블록이 하나 이상 존재해야 한다.
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
@@ -582,7 +582,7 @@ test("Features 템플릿 버튼을 클릭하면 3개의 Feature 항목(제목/
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "Features 템플릿 추가" }).click(); await page.getByRole("button", { name: "기능 템플릿" }).click();
// Feature 제목들이 렌더되어야 한다. // Feature 제목들이 렌더되어야 한다.
await expect(canvas.getByText("Feature 1 제목")).toBeVisible(); await expect(canvas.getByText("Feature 1 제목")).toBeVisible();
@@ -595,7 +595,7 @@ test("CTA 템플릿 버튼을 클릭하면 CTA 텍스트와 버튼이 포함된
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "CTA 템플릿 추가" }).click(); await page.getByRole("button", { name: "CTA 템플릿" }).click();
await expect(canvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible(); await expect(canvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
const ctaButton = canvas.getByRole("button", { name: "CTA 버튼" }); const ctaButton = canvas.getByRole("button", { name: "CTA 버튼" });
@@ -607,7 +607,7 @@ test("FAQ 템플릿 버튼을 클릭하면 질문/답변 텍스트가 세 쌍
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "FAQ 템플릿 추가" }).click(); await page.getByRole("button", { name: "FAQ 템플릿" }).click();
// 기본 FAQ 질문/답변 텍스트들이 렌더되어야 한다. // 기본 FAQ 질문/답변 텍스트들이 렌더되어야 한다.
await expect(canvas.getByText("서비스 이용료는 얼마인가요?")).toBeVisible(); await expect(canvas.getByText("서비스 이용료는 얼마인가요?")).toBeVisible();
@@ -621,7 +621,7 @@ test("Pricing 템플릿 버튼을 클릭하면 3개의 요금제 카드가 생
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "Pricing 템플릿 추가" }).click(); await page.getByRole("button", { name: "상품 템플릿" }).click();
await expect(canvas.getByText("Basic")).toBeVisible(); await expect(canvas.getByText("Basic")).toBeVisible();
await expect(canvas.getByText("₩9,900/월")).toBeVisible(); await expect(canvas.getByText("₩9,900/월")).toBeVisible();
@@ -636,7 +636,7 @@ test("Testimonials 템플릿 버튼을 클릭하면 3개의 후기 카드가 생
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "Testimonials 템플릿 추가" }).click(); await page.getByRole("button", { name: "후기 템플릿" }).click();
await expect(canvas.getByText("이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.")).toBeVisible(); await expect(canvas.getByText("이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.")).toBeVisible();
await expect(canvas.getByText("디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.")).toBeVisible(); await expect(canvas.getByText("디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.")).toBeVisible();
@@ -648,7 +648,7 @@ test("Blog 템플릿 버튼을 클릭하면 3개의 포스트 카드(제목/요
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "Blog 템플릿 추가" }).click(); await page.getByRole("button", { name: "블로그 템플릿" }).click();
await expect(canvas.getByText("블로그 포스트 1")).toBeVisible(); await expect(canvas.getByText("블로그 포스트 1")).toBeVisible();
await expect(canvas.getByText("첫 번째 포스트 요약을 여기에 입력하세요.")).toBeVisible(); await expect(canvas.getByText("첫 번째 포스트 요약을 여기에 입력하세요.")).toBeVisible();
@@ -661,7 +661,7 @@ test("Team 템플릿 버튼을 클릭하면 3명의 팀 카드가 생성되어
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "Team 템플릿 추가" }).click(); await page.getByRole("button", { name: "Team 템플릿" }).click();
await expect(canvas.getByText("홍길동")).toBeVisible(); await expect(canvas.getByText("홍길동")).toBeVisible();
await expect(canvas.getByText("Product Designer")).toBeVisible(); await expect(canvas.getByText("Product Designer")).toBeVisible();
@@ -675,7 +675,7 @@ test("템플릿 섹션을 삭제한 뒤 텍스트 블록을 추가하면 캔버
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// Hero 템플릿 섹션을 추가한다. // Hero 템플릿 섹션을 추가한다.
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click(); await page.getByRole("button", { name: "Hero 템플릿" }).click();
// 섹션 블록을 선택한다 (캔버스의 첫 번째 블록이 섹션이다). // 섹션 블록을 선택한다 (캔버스의 첫 번째 블록이 섹션이다).
const sectionBlock = canvas.getByTestId("editor-block").first(); const sectionBlock = canvas.getByTestId("editor-block").first();
@@ -685,7 +685,7 @@ test("템플릿 섹션을 삭제한 뒤 텍스트 블록을 추가하면 캔버
await page.getByRole("button", { name: "블록 삭제" }).click(); await page.getByRole("button", { name: "블록 삭제" }).click();
// 텍스트 블록을 추가한다. // 텍스트 블록을 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
// 새 텍스트 블록이 캔버스에 보여야 한다. // 새 텍스트 블록이 캔버스에 보여야 한다.
await expect(canvas.getByText("새 텍스트")).toBeVisible(); await expect(canvas.getByText("새 텍스트")).toBeVisible();
@@ -696,7 +696,7 @@ test("Footer 템플릿 버튼을 클릭하면 링크/카피라이트 텍스트
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "Footer 템플릿 추가" }).click(); await page.getByRole("button", { name: "Footer 템플릿" }).click();
await expect(canvas.getByText("서비스 소개")).toBeVisible(); await expect(canvas.getByText("서비스 소개")).toBeVisible();
await expect(canvas.getByText("고객지원")).toBeVisible(); await expect(canvas.getByText("고객지원")).toBeVisible();
@@ -710,7 +710,7 @@ test("Cmd/Ctrl+Z로 블록 추가를 Undo 하고 Cmd/Ctrl+Shift+Z로 Redo 할
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 텍스트 블록을 하나 추가한다. // 텍스트 블록을 하나 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
await expect(canvas.getByTestId("editor-block")).toHaveCount(1); await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
// Cmd/Ctrl + Z 로 Undo 한다. // Cmd/Ctrl + Z 로 Undo 한다.
@@ -728,6 +728,24 @@ test("Cmd/Ctrl+Z로 블록 추가를 Undo 하고 Cmd/Ctrl+Shift+Z로 Redo 할
await expect(canvas.getByTestId("editor-block")).toHaveCount(1); await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
}); });
test("헤더의 실행 취소/다시 실행 버튼으로 블록 추가를 Undo/Redo 할 수 있어야 한다", async ({ page }) => {
await page.goto("/editor");
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "텍스트" }).first().click();
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
const undoButton = page.getByRole("button", { name: "실행 취소" });
const redoButton = page.getByRole("button", { name: "다시 실행" });
await undoButton.click();
await expect(canvas.getByTestId("editor-block")).toHaveCount(0);
await redoButton.click();
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
});
test("ArrowUp/ArrowDown 키로 선택된 블록을 위/아래로 이동하며 순차적으로 선택할 수 있어야 한다", async ({ page }) => { test("ArrowUp/ArrowDown 키로 선택된 블록을 위/아래로 이동하며 순차적으로 선택할 수 있어야 한다", async ({ page }) => {
test.skip(true, "ArrowUp/Down 기반 선택 이동 E2E는 불안정하여 임시 스킵"); test.skip(true, "ArrowUp/Down 기반 선택 이동 E2E는 불안정하여 임시 스킵");
await page.goto("/editor"); await page.goto("/editor");
@@ -735,9 +753,9 @@ test("ArrowUp/ArrowDown 키로 선택된 블록을 위/아래로 이동하며
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 텍스트 블록을 세 개 추가한다. // 텍스트 블록을 세 개 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(3); await expect(blocks).toHaveCount(3);
@@ -771,8 +789,8 @@ test("속성 패널의 블록 삭제 버튼으로 선택된 블록을 삭제할
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 텍스트 블록 두 개를 추가한다. // 텍스트 블록 두 개를 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(2); await expect(blocks).toHaveCount(2);
@@ -793,7 +811,7 @@ test("Cmd/Ctrl+D 단축키로 선택된 블록을 복제할 수 있어야 한다
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 텍스트 블록을 하나 추가한다. // 텍스트 블록을 하나 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
let blocks = canvas.getByTestId("editor-block"); let blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(1); await expect(blocks).toHaveCount(1);
@@ -821,7 +839,7 @@ test("구분선 블록을 추가하면 캔버스에 구분선이 렌더되고
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 구분선 블록을 추가한다. // 구분선 블록을 추가한다.
await page.getByRole("button", { name: "구분선 블록 추가" }).click(); await page.getByRole("button", { name: "구분선" }).click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
const dividerBlock = blocks.nth(0); const dividerBlock = blocks.nth(0);
@@ -845,7 +863,7 @@ test("리스트 블록을 추가하고 첫 번째 아이템과 번호 매기기/
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 리스트 블록을 추가한다. // 리스트 블록을 추가한다.
await page.getByRole("button", { name: "리스트 블록 추가" }).click(); await page.getByRole("button", { name: "리스트" }).click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
const listBlock = blocks.nth(0); const listBlock = blocks.nth(0);
@@ -873,7 +891,7 @@ test("리스트 아이템을 선택하면 패널에서 아이템 위/아래/들
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 리스트 블록을 추가한다. // 리스트 블록을 추가한다.
await page.getByRole("button", { name: "리스트 블록 추가" }).click(); await page.getByRole("button", { name: "리스트" }).click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
const listBlock = blocks.nth(0); const listBlock = blocks.nth(0);
@@ -912,23 +930,23 @@ test("폼 요소 사이드바에서 폼 입력/셀렉트/라디오/체크박스
await expect(page.getByRole("heading", { name: "폼 요소" })).toBeVisible(); await expect(page.getByRole("heading", { name: "폼 요소" })).toBeVisible();
// 폼 입력 블록 추가 버튼을 클릭하면 formInput 블록이 캔버스에 생성되어야 한다. // 폼 입력 블록 추가 버튼을 클릭하면 formInput 블록이 캔버스에 생성되어야 한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
let blocks = canvas.getByTestId("editor-block"); let blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(1); await expect(blocks).toHaveCount(1);
// 폼 셀렉트 블록 추가 버튼을 클릭하면 formSelect 블록이 추가되어야 한다. // 폼 셀렉트 블록 추가 버튼을 클릭하면 formSelect 블록이 추가되어야 한다.
await page.getByRole("button", { name: "셀렉트 추가" }).click(); await page.getByRole("button", { name: "셀렉트(Select)" }).click();
blocks = canvas.getByTestId("editor-block"); blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(2); await expect(blocks).toHaveCount(2);
// 폼 라디오 블록 추가 버튼을 클릭하면 formRadio 블록이 추가되어야 한다. // 폼 라디오 블록 추가 버튼을 클릭하면 formRadio 블록이 추가되어야 한다.
await page.getByRole("button", { name: "폼 라디오 추가" }).click(); await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
blocks = canvas.getByTestId("editor-block"); blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(3); await expect(blocks).toHaveCount(3);
// 폼 체크박스 블록 추가 버튼을 클릭하면 formCheckbox 블록이 추가되어야 한다. // 폼 체크박스 블록 추가 버튼을 클릭하면 formCheckbox 블록이 추가되어야 한다.
await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
blocks = canvas.getByTestId("editor-block"); blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(4); await expect(blocks).toHaveCount(4);
}); });
@@ -939,12 +957,12 @@ test("폼 블록을 선택하면 우측 속성 패널에서 폼 필드/버튼
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 폼 요소와 버튼 블록을 몇 개 추가한다. // 폼 요소와 버튼 블록을 몇 개 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
await page.getByRole("button", { name: "셀렉트 추가" }).click(); await page.getByRole("button", { name: "셀렉트(Select)" }).click();
await page.getByRole("button", { name: "버튼 블록 추가" }).click(); await page.getByRole("button", { name: "버튼" }).click();
// 폼 블록을 추가한다. // 폼 블록을 추가한다.
await page.getByRole("button", { name: "폼 블록 추가" }).click(); await page.getByRole("button", { name: "폼 컨트롤러" }).click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
const formBlock = blocks.nth((await blocks.count()) - 1); const formBlock = blocks.nth((await blocks.count()) - 1);
@@ -984,7 +1002,7 @@ test("폼 입력 블록의 라벨/전송 키/필수 여부를 우측 패널에
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 폼 입력 블록을 하나 추가한다. // 폼 입력 블록을 하나 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
const inputBlock = blocks.nth(0); const inputBlock = blocks.nth(0);
@@ -1013,7 +1031,7 @@ test("폼 셀렉트/라디오/체크박스 블록도 우측 패널에서 기본
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 폼 셀렉트 블록 // 폼 셀렉트 블록
await page.getByRole("button", { name: "셀렉트 추가" }).click(); await page.getByRole("button", { name: "셀렉트(Select)" }).click();
let blocks = canvas.getByTestId("editor-block"); let blocks = canvas.getByTestId("editor-block");
const selectBlock = blocks.nth((await blocks.count()) - 1); const selectBlock = blocks.nth((await blocks.count()) - 1);
await selectBlock.click({ force: true }); await selectBlock.click({ force: true });
@@ -1031,7 +1049,7 @@ test("폼 셀렉트/라디오/체크박스 블록도 우측 패널에서 기본
await requiredCheckbox.check(); await requiredCheckbox.check();
// 폼 라디오 블록 // 폼 라디오 블록
await page.getByRole("button", { name: "폼 라디오 추가" }).click(); await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
blocks = canvas.getByTestId("editor-block"); blocks = canvas.getByTestId("editor-block");
const radioBlock = blocks.nth((await blocks.count()) - 1); const radioBlock = blocks.nth((await blocks.count()) - 1);
await radioBlock.click({ force: true }); await radioBlock.click({ force: true });
@@ -1047,7 +1065,7 @@ test("폼 셀렉트/라디오/체크박스 블록도 우측 패널에서 기본
// 라디오 그룹 타이틀 텍스트 필드는 그대로 편집 가능해야 한다. // 라디오 그룹 타이틀 텍스트 필드는 그대로 편집 가능해야 한다.
// 폼 체크박스 블록 // 폼 체크박스 블록
await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
blocks = canvas.getByTestId("editor-block"); blocks = canvas.getByTestId("editor-block");
const checkboxBlock = blocks.nth((await blocks.count()) - 1); const checkboxBlock = blocks.nth((await blocks.count()) - 1);
await checkboxBlock.click({ force: true }); await checkboxBlock.click({ force: true });
@@ -1069,7 +1087,7 @@ test("폼 입력 블록을 추가하면 에디터 캔버스에 라벨과 입력
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 폼 입력 블록을 하나 추가한다. // 폼 입력 블록을 하나 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
const inputBlock = blocks.nth(0); const inputBlock = blocks.nth(0);
@@ -1088,7 +1106,7 @@ test("폼 셀렉트 블록을 추가하면 에디터 캔버스에 라벨과 셀
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 폼 셀렉트 블록을 하나 추가한다. // 폼 셀렉트 블록을 하나 추가한다.
await page.getByRole("button", { name: "셀렉트 추가" }).click(); await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
const selectBlock = blocks.nth(0); const selectBlock = blocks.nth(0);
@@ -1107,7 +1125,7 @@ test("폼 라디오 블록을 추가하면 에디터 캔버스에 그룹 라벨
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 폼 라디오 블록을 하나 추가한다. // 폼 라디오 블록을 하나 추가한다.
await page.getByRole("button", { name: "폼 라디오 추가" }).click(); await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
const radioBlock = blocks.nth(0); const radioBlock = blocks.nth(0);
@@ -1126,7 +1144,7 @@ test("폼 체크박스 블록을 추가하면 에디터 캔버스에 체크박
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 폼 체크박스 블록을 하나 추가한다. // 폼 체크박스 블록을 하나 추가한다.
await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
const checkboxBlock = blocks.nth(0); const checkboxBlock = blocks.nth(0);
@@ -1144,7 +1162,7 @@ test("폼 입력 필드의 타입과 Placeholder 를 우측 패널에서 변경
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
const inputBlock = blocks.nth(0); const inputBlock = blocks.nth(0);
@@ -1167,7 +1185,7 @@ test("폼 셀렉트 블록의 옵션 목록을 우측 패널에서 수정하면
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "셀렉트 추가" }).click(); await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
const selectBlock = blocks.nth(0); const selectBlock = blocks.nth(0);
@@ -1208,7 +1226,7 @@ test("폼 입력 블록의 스타일(텍스트/배경/모서리)을 우측 패
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 폼 입력 블록을 하나 추가한다. // 폼 입력 블록을 하나 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
const inputBlock = blocks.nth(0); const inputBlock = blocks.nth(0);
+67 -67
View File
@@ -10,15 +10,15 @@ test("/preview 페이지는 에디터 크롬 없이 컨텐츠만 보여야 한
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
// 에디터 좌측 패널용 버튼들은 나타나지 않아야 한다. // 에디터 좌측 패널용 버튼들은 나타나지 않아야 한다.
await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0); await expect(page.getByRole("button", { name: "텍스트" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Hero 템플릿 추가" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Hero 템플릿" })).toHaveCount(0);
}); });
test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 Hero 콘텐츠가 보여야 한다", async ({ page }) => { test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 Hero 콘텐츠가 보여야 한다", async ({ page }) => {
// 에디터에서 Hero 템플릿을 추가한다. // 에디터에서 Hero 템플릿을 추가한다.
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click(); await page.getByRole("button", { name: "Hero 템플릿" }).click();
// 헤드라인과 CTA 버튼이 에디터에서 생성되었는지 한 번 확인한다. // 헤드라인과 CTA 버튼이 에디터에서 생성되었는지 한 번 확인한다.
const editorCanvas = page.getByTestId("editor-canvas"); const editorCanvas = page.getByTestId("editor-canvas");
@@ -36,8 +36,8 @@ test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 H
// 프리뷰에서는 CTA를 실제 링크(<a>)로 렌더링하므로 텍스트 기준으로만 검증한다. // 프리뷰에서는 CTA를 실제 링크(<a>)로 렌더링하므로 텍스트 기준으로만 검증한다.
await expect(page.getByText("지금 시작하기")).toBeVisible(); await expect(page.getByText("지금 시작하기")).toBeVisible();
// 프리뷰 화면에는 에디터용 "텍스트 블록 추가" 버튼이 없어야 한다. // 프리뷰 화면에는 에디터용 "텍스트" 버튼이 없어야 한다.
await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0); await expect(page.getByRole("button", { name: "텍스트" })).toHaveCount(0);
}); });
test("프리뷰 페이지에서 에디터로 돌아가는 링크가 있어야 한다", async ({ page }) => { test("프리뷰 페이지에서 에디터로 돌아가는 링크가 있어야 한다", async ({ page }) => {
@@ -58,7 +58,7 @@ test("프리뷰 페이지에서 에디터로 돌아가는 링크가 있어야
test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의 Feature 섹션이 보여야 한다", async ({ page }) => { test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의 Feature 섹션이 보여야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "Features 템플릿 추가" }).click(); await page.getByRole("button", { name: "기능 템플릿" }).click();
const editorCanvas = page.getByTestId("editor-canvas"); const editorCanvas = page.getByTestId("editor-canvas");
await expect(editorCanvas.getByText("Feature 1 제목")).toBeVisible(); await expect(editorCanvas.getByText("Feature 1 제목")).toBeVisible();
@@ -77,7 +77,7 @@ test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의
test("에디터에서 CTA 템플릿을 추가하면 프리뷰에서 CTA 텍스트와 버튼이 보여야 한다", async ({ page }) => { test("에디터에서 CTA 템플릿을 추가하면 프리뷰에서 CTA 텍스트와 버튼이 보여야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "CTA 템플릿 추가" }).click(); await page.getByRole("button", { name: "CTA 템플릿" }).click();
const editorCanvas = page.getByTestId("editor-canvas"); const editorCanvas = page.getByTestId("editor-canvas");
await expect(editorCanvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible(); await expect(editorCanvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
@@ -95,10 +95,10 @@ test("모바일 뷰에서도 프리뷰 페이지가 깨지지 않고 주요 템
// 에디터에서 여러 템플릿을 추가한다. // 에디터에서 여러 템플릿을 추가한다.
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click(); await page.getByRole("button", { name: "Hero 템플릿" }).click();
await page.getByRole("button", { name: "Features 템플릿 추가" }).click(); await page.getByRole("button", { name: "기능 템플릿" }).click();
await page.getByRole("button", { name: "Pricing 템플릿 추가" }).click(); await page.getByRole("button", { name: "상품 템플릿" }).click();
await page.getByRole("button", { name: "Testimonials 템플릿 추가" }).click(); await page.getByRole("button", { name: "후기 템플릿" }).click();
// 모바일 뷰포트로 전환한다. // 모바일 뷰포트로 전환한다.
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
@@ -127,7 +127,7 @@ test("텍스트 블록의 정렬/폰트 크기/색상이 프리뷰에도 반영
await page.goto("/editor"); await page.goto("/editor");
// 텍스트 블록을 추가한다. // 텍스트 블록을 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); await page.getByRole("button", { name: "텍스트" }).first().click();
// 기본 텍스트 대신 프리뷰에서 쉽게 찾을 수 있도록 내용도 함께 수정한다. // 기본 텍스트 대신 프리뷰에서 쉽게 찾을 수 있도록 내용도 함께 수정한다.
const textTextarea = page.getByLabel("선택한 텍스트 블록 내용"); const textTextarea = page.getByLabel("선택한 텍스트 블록 내용");
@@ -175,8 +175,8 @@ test("구분선/리스트 블록도 프리뷰에서 렌더링되어야 한다",
// 에디터에서 구분선과 리스트 블록을 추가한다. // 에디터에서 구분선과 리스트 블록을 추가한다.
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "구분선 블록 추가" }).click(); await page.getByRole("button", { name: "구분선" }).click();
await page.getByRole("button", { name: "리스트 블록 추가" }).click(); await page.getByRole("button", { name: "리스트" }).click();
// 에디터 캔버스에서 리스트 항목 일부가 보이는지 확인해 sanity check 를 한다. // 에디터 캔버스에서 리스트 항목 일부가 보이는지 확인해 sanity check 를 한다.
const editorCanvas = page.getByTestId("editor-canvas"); const editorCanvas = page.getByTestId("editor-canvas");
@@ -195,7 +195,7 @@ test("리스트 블록 스타일 속성이 프리뷰에도 반영되어야 한
await page.goto("/editor"); await page.goto("/editor");
// 리스트 블록을 추가한다. // 리스트 블록을 추가한다.
await page.getByRole("button", { name: "리스트 블록 추가" }).click(); await page.getByRole("button", { name: "리스트" }).click();
// 테스트를 위해 리스트 아이템을 2개 이상으로 만든다 (첫 번째 li 에 margin-bottom 이 적용되도록). // 테스트를 위해 리스트 아이템을 2개 이상으로 만든다 (첫 번째 li 에 margin-bottom 이 적용되도록).
const itemsTextarea = page.getByLabel("리스트 아이템들"); const itemsTextarea = page.getByLabel("리스트 아이템들");
@@ -271,8 +271,8 @@ test("폼 입력/셀렉트 블록도 FormBlock 없이 프리뷰에서 보여야
await page.goto("/editor"); await page.goto("/editor");
// 폼 입력/셀렉트 블록을 각각 하나씩 추가한다. // 폼 입력/셀렉트 블록을 각각 하나씩 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
await page.getByRole("button", { name: "셀렉트 추가" }).click(); await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const editorCanvas = page.getByTestId("editor-canvas"); const editorCanvas = page.getByTestId("editor-canvas");
@@ -293,7 +293,7 @@ test("폼 입력/셀렉트 블록도 FormBlock 없이 프리뷰에서 보여야
test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => { test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -323,7 +323,7 @@ test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰
test("폼 라디오 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => { test("폼 라디오 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click(); await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -356,7 +356,7 @@ test("폼 라디오 옵션 라벨 이미지 업로드 후 에디터/프리뷰
test("폼 체크박스 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => { test("폼 체크박스 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -390,7 +390,7 @@ test("폼 체크박스 옵션 라벨 이미지 업로드 후 에디터/프리뷰
test("폼 라디오 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => { test("폼 라디오 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click(); await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -423,7 +423,7 @@ test("폼 입력 필드 스타일 속성이 프리뷰에도 반영되어야 한
await page.goto("/editor"); await page.goto("/editor");
// 폼 입력 블록을 추가한다. // 폼 입력 블록을 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
// 우측 속성 패널에서 폼 입력 스타일을 설정한다. // 우측 속성 패널에서 폼 입력 스타일을 설정한다.
// 라벨/필드 텍스트 등 기본 값은 그대로 두고, 스타일 속성만 눈에 띄게 변경한다. // 라벨/필드 텍스트 등 기본 값은 그대로 두고, 스타일 속성만 눈에 띄게 변경한다.
@@ -483,7 +483,7 @@ test("폼 입력 필드 스타일 속성이 프리뷰에도 반영되어야 한
test("폼 입력 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 입력 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -512,7 +512,7 @@ test("폼 입력 고정 너비가 프리뷰에서도 em 스케일로 반영되
test("버튼 타이포 px 입력이 프리뷰에서는 em 스케일로 렌더링되어야 한다", async ({ page }) => { test("버튼 타이포 px 입력이 프리뷰에서는 em 스케일로 렌더링되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "버튼 블록 추가" }).click(); await page.getByRole("button", { name: "버튼" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -554,7 +554,7 @@ test("버튼 타이포 px 입력이 프리뷰에서는 em 스케일로 렌더링
test("버튼 고정 너비와 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("버튼 고정 너비와 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "버튼 블록 추가" }).click(); await page.getByRole("button", { name: "버튼" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -603,7 +603,7 @@ test("버튼 고정 너비와 패딩 px 입력이 프리뷰에서도 em 스케
test("이미지 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("이미지 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "이미지 블록 추가" }).click(); await page.getByRole("button", { name: "이미지" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -634,7 +634,7 @@ test("이미지 고정 너비가 프리뷰에서도 em 스케일로 반영되어
test("이미지 블록에서 로컬 파일 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => { test("이미지 블록에서 로컬 파일 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "이미지 블록 추가" }).click(); await page.getByRole("button", { name: "이미지" }).click();
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -671,7 +671,7 @@ test("이미지 블록에서 로컬 파일 업로드 후 에디터/프리뷰 모
test("구분선 고정 길이와 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("구분선 고정 길이와 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "구분선 블록 추가" }).click(); await page.getByRole("button", { name: "구분선" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -718,7 +718,7 @@ test("구분선 고정 길이와 여백 px 입력이 프리뷰에서도 em 스
test("섹션 padding/maxWidth/gap px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("섹션 padding/maxWidth/gap px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "섹션 블록 추가" }).click(); await page.getByRole("button", { name: "섹션" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -780,7 +780,7 @@ test("섹션 padding/maxWidth/gap px 입력이 프리뷰에서도 em 스케일
test("구분선 고정 길이 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("구분선 고정 길이 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "구분선 블록 추가" }).click(); await page.getByRole("button", { name: "구분선" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -809,7 +809,7 @@ test("구분선 고정 길이 px 입력이 프리뷰에서도 em 스케일로
test("구분선 상하 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("구분선 상하 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "구분선 블록 추가" }).click(); await page.getByRole("button", { name: "구분선" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -837,7 +837,7 @@ test("구분선 상하 여백 px 입력이 프리뷰에서도 em 스케일로
test("리스트 아이템 간 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("리스트 아이템 간 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "리스트 블록 추가" }).click(); await page.getByRole("button", { name: "리스트" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -866,7 +866,7 @@ test("리스트 아이템 간 여백 px 입력이 프리뷰에서도 em 스케
test("리스트 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("리스트 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "리스트 블록 추가" }).click(); await page.getByRole("button", { name: "리스트" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -894,7 +894,7 @@ test("리스트 글자 크기 px 입력이 프리뷰에서도 em 스케일로
test("폼 입력 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 입력 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -922,7 +922,7 @@ test("폼 입력 줄간격 px 입력이 프리뷰에서도 em 스케일로 반
test("폼 입력 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 입력 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -950,7 +950,7 @@ test("폼 입력 자간 px 입력이 프리뷰에서도 em 스케일로 반영
test("폼 입력 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 입력 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
const paddingSidebar = page.getByTestId("properties-sidebar"); const paddingSidebar = page.getByTestId("properties-sidebar");
@@ -978,7 +978,7 @@ test("폼 입력 가로 패딩 px 입력이 프리뷰에서도 em 스케일로
test("폼 입력 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 입력 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
const paddingSidebar = page.getByTestId("properties-sidebar"); const paddingSidebar = page.getByTestId("properties-sidebar");
@@ -1006,7 +1006,7 @@ test("폼 입력 세로 패딩 px 입력이 프리뷰에서도 em 스케일로
test("폼 입력 인라인 라벨 간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 입력 인라인 라벨 간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1035,7 +1035,7 @@ test("폼 입력 인라인 라벨 간격 px 입력이 프리뷰에서도 em 스
test("폼 체크박스 옵션 간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 체크박스 옵션 간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1065,7 +1065,7 @@ test("폼 체크박스 옵션 간격 px 입력이 프리뷰에서도 em 스케
test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (고정 너비 설정 시)", async ({ page }) => { test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (고정 너비 설정 시)", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 블록 추가" }).click(); await page.getByRole("button", { name: "폼 컨트롤러" }).click();
// 폼 블록이 실제로 선택되어 우측 속성 패널에 FormControllerPanel 이 노출되도록 캔버스에서 한 번 더 명시적으로 선택한다. // 폼 블록이 실제로 선택되어 우측 속성 패널에 FormControllerPanel 이 노출되도록 캔버스에서 한 번 더 명시적으로 선택한다.
const editorCanvas = page.getByTestId("editor-canvas"); const editorCanvas = page.getByTestId("editor-canvas");
@@ -1087,7 +1087,7 @@ test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지
test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (상하 여백 설정 시)", async ({ page }) => { test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (상하 여백 설정 시)", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 블록 추가" }).click(); await page.getByRole("button", { name: "폼 컨트롤러" }).click();
// 앞선 테스트들과의 상호 영향으로 selectedBlockId 가 어긋나는 경우를 방지하기 위해, // 앞선 테스트들과의 상호 영향으로 selectedBlockId 가 어긋나는 경우를 방지하기 위해,
// 방금 추가한 폼 블록을 캔버스에서 다시 선택해 속성 패널을 확실히 FormBlock 기준으로 맞춘다. // 방금 추가한 폼 블록을 캔버스에서 다시 선택해 속성 패널을 확실히 FormBlock 기준으로 맞춘다.
@@ -1109,7 +1109,7 @@ test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지
test("폼 라디오 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 라디오 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click(); await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1137,7 +1137,7 @@ test("폼 라디오 줄간격 px 입력이 프리뷰에서도 em 스케일로
test("폼 라디오 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 라디오 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click(); await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1165,7 +1165,7 @@ test("폼 라디오 자간 px 입력이 프리뷰에서도 em 스케일로 반
test("폼 체크박스 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 체크박스 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1193,7 +1193,7 @@ test("폼 체크박스 가로 패딩 px 입력이 프리뷰에서도 em 스케
test("폼 체크박스 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 체크박스 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1221,7 +1221,7 @@ test("폼 체크박스 세로 패딩 px 입력이 프리뷰에서도 em 스케
test("폼 셀렉트 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 셀렉트 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click(); await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1249,7 +1249,7 @@ test("폼 셀렉트 가로 패딩 px 입력이 프리뷰에서도 em 스케일
test("폼 셀렉트 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 셀렉트 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click(); await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1277,7 +1277,7 @@ test("폼 셀렉트 세로 패딩 px 입력이 프리뷰에서도 em 스케일
test("폼 셀렉트 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 셀렉트 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click(); await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1305,7 +1305,7 @@ test("폼 셀렉트 글자 크기 px 입력이 프리뷰에서도 em 스케일
test("폼 셀렉트 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 셀렉트 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click(); await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1333,7 +1333,7 @@ test("폼 셀렉트 줄간격 px 입력이 프리뷰에서도 em 스케일로
test("폼 셀렉트 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 셀렉트 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click(); await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1361,7 +1361,7 @@ test("폼 셀렉트 자간 px 입력이 프리뷰에서도 em 스케일로 반
test("폼 체크박스 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 체크박스 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1389,7 +1389,7 @@ test("폼 체크박스 글자 크기 px 입력이 프리뷰에서도 em 스케
test("폼 라디오 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 라디오 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click(); await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1417,7 +1417,7 @@ test("폼 라디오 글자 크기 px 입력이 프리뷰에서도 em 스케일
test("폼 입력 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 입력 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1445,7 +1445,7 @@ test("폼 입력 글자 크기 px 입력이 프리뷰에서도 em 스케일로
test("폼 셀렉트 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 셀렉트 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click(); await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1474,7 +1474,7 @@ test("폼 셀렉트 고정 너비가 프리뷰에서도 em 스케일로 반영
test("폼 라디오 그룹 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 라디오 그룹 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click(); await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1503,7 +1503,7 @@ test("폼 라디오 그룹 고정 너비가 프리뷰에서도 em 스케일로
test("폼 체크박스 그룹 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("폼 체크박스 그룹 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar"); const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1536,9 +1536,9 @@ test("폼 컨트롤러에 매핑한 폼 요소와 버튼이 프리뷰에서 함
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
// 폼 입력 요소와 버튼, 폼 블록을 순서대로 추가한다. // 폼 입력 요소와 버튼, 폼 블록을 순서대로 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
await page.getByRole("button", { name: "버튼 블록 추가" }).click(); await page.getByRole("button", { name: "버튼" }).click();
await page.getByRole("button", { name: "폼 블록 추가" }).click(); await page.getByRole("button", { name: "폼 컨트롤러" }).click();
// 방금 추가된 폼 블록을 선택한다. // 방금 추가된 폼 블록을 선택한다.
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
@@ -1585,9 +1585,9 @@ test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으
const blocks = canvas.getByTestId("editor-block"); const blocks = canvas.getByTestId("editor-block");
// Form A: 입력 필드 + 버튼 + 폼 블록을 추가한 뒤, 즉시 컨트롤러를 매핑한다. // Form A: 입력 필드 + 버튼 + 폼 블록을 추가한 뒤, 즉시 컨트롤러를 매핑한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
await page.getByRole("button", { name: "버튼 블록 추가" }).click(); await page.getByRole("button", { name: "버튼" }).click();
await page.getByRole("button", { name: "폼 블록 추가" }).click(); await page.getByRole("button", { name: "폼 컨트롤러" }).click();
let count = await blocks.count(); let count = await blocks.count();
const formBlockA = blocks.nth(count - 1); const formBlockA = blocks.nth(count - 1);
@@ -1603,9 +1603,9 @@ test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으
await submitSelect.selectOption({ index: 1 }); await submitSelect.selectOption({ index: 1 });
// Form B: 입력 필드 + 버튼 + 폼 블록을 다시 추가한 뒤, 즉시 컨트롤러를 매핑한다. // Form B: 입력 필드 + 버튼 + 폼 블록을 다시 추가한 뒤, 즉시 컨트롤러를 매핑한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "입력(Input)" }).click();
await page.getByRole("button", { name: "버튼 블록 추가" }).click(); await page.getByRole("button", { name: "버튼" }).first().click();
await page.getByRole("button", { name: "폼 블록 추가" }).click(); await page.getByRole("button", { name: "폼 컨트롤러" }).click();
count = await blocks.count(); count = await blocks.count();
const formBlockB = blocks.nth(count - 1); const formBlockB = blocks.nth(count - 1);
@@ -1647,7 +1647,7 @@ test("섹션 패딩/컬럼 간격 수치가 프리뷰에서도 em 스케일에
await page.goto("/editor"); await page.goto("/editor");
// 섹션 블록을 직접 추가한다. // 섹션 블록을 직접 추가한다.
await page.getByRole("button", { name: "섹션 블록 추가" }).click(); await page.getByRole("button", { name: "섹션" }).click();
// 캔버스에서 첫 번째 섹션 블록을 선택한다. // 캔버스에서 첫 번째 섹션 블록을 선택한다.
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
@@ -1705,7 +1705,7 @@ test("섹션 패딩/컬럼 간격 수치가 프리뷰에서도 em 스케일에
test("구분선 여백/길이 수치가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { test("구분선 여백/길이 수치가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor"); await page.goto("/editor");
await page.getByRole("button", { name: "구분선 블록 추가" }).click(); await page.getByRole("button", { name: "구분선" }).click();
const canvas = page.getByTestId("editor-canvas"); const canvas = page.getByTestId("editor-canvas");
const dividerBlock = canvas.getByTestId("editor-block").first(); const dividerBlock = canvas.getByTestId("editor-block").first();
+65 -19
View File
@@ -52,28 +52,25 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
Object.values(templateActions).forEach((fn) => fn.mockReset()); Object.values(templateActions).forEach((fn) => fn.mockReset());
}); });
it("Hero 템플릿 추가 버튼 클릭 시 addHeroTemplateSection 이 호출되어야 한다", () => { it("Hero 템플릿 버튼 클릭 시 addHeroTemplateSection 이 호출되어야 한다", () => {
render(<BlocksSidebar />); render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "Hero 템플릿" });
const button = screen.getByRole("button", { name: "Hero 템플릿 추가" });
fireEvent.click(button); fireEvent.click(button);
expect(templateActions.addHeroTemplateSection).toHaveBeenCalledTimes(1); expect(templateActions.addHeroTemplateSection).toHaveBeenCalledTimes(1);
}); });
it("Features 템플릿 추가 버튼 클릭 시 addFeaturesTemplateSection 이 호출되어야 한다", () => { it("Features 템플릿 버튼 클릭 시 addFeaturesTemplateSection 이 호출되어야 한다", () => {
render(<BlocksSidebar />); render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "기능 템플릿" });
const button = screen.getByRole("button", { name: "Features 템플릿 추가" });
fireEvent.click(button); fireEvent.click(button);
expect(templateActions.addFeaturesTemplateSection).toHaveBeenCalledTimes(1); expect(templateActions.addFeaturesTemplateSection).toHaveBeenCalledTimes(1);
}); });
it("CTA 템플릿 추가 버튼 클릭 시 addCtaTemplateSection 이 호출되어야 한다", () => { it("CTA 템플릿 버튼 클릭 시 addCtaTemplateSection 이 호출되어야 한다", () => {
render(<BlocksSidebar />); render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "CTA 템플릿" });
const button = screen.getByRole("button", { name: "CTA 템플릿 추가" });
fireEvent.click(button); fireEvent.click(button);
expect(templateActions.addCtaTemplateSection).toHaveBeenCalledTimes(1); expect(templateActions.addCtaTemplateSection).toHaveBeenCalledTimes(1);
@@ -81,13 +78,12 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
it("FAQ/Pricing/Testimonials/Blog/Team/Footer 템플릿 버튼도 각각의 액션을 호출해야 한다", () => { it("FAQ/Pricing/Testimonials/Blog/Team/Footer 템플릿 버튼도 각각의 액션을 호출해야 한다", () => {
render(<BlocksSidebar />); render(<BlocksSidebar />);
fireEvent.click(screen.getByRole("button", { name: "FAQ 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "FAQ 템플릿 추가" })); fireEvent.click(screen.getByRole("button", { name: "상품 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "Pricing 템플릿 추가" })); fireEvent.click(screen.getByRole("button", { name: "후기 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "Testimonials 템플릿 추가" })); fireEvent.click(screen.getByRole("button", { name: "블로그 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "Blog 템플릿 추가" })); fireEvent.click(screen.getByRole("button", { name: "Team 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "Team 템플릿 추가" })); fireEvent.click(screen.getByRole("button", { name: "Footer 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "Footer 템플릿 추가" }));
expect(templateActions.addFaqTemplateSection).toHaveBeenCalledTimes(1); expect(templateActions.addFaqTemplateSection).toHaveBeenCalledTimes(1);
expect(templateActions.addPricingTemplateSection).toHaveBeenCalledTimes(1); expect(templateActions.addPricingTemplateSection).toHaveBeenCalledTimes(1);
@@ -111,10 +107,9 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
expect(screen.getByText("페이지 푸터 섹션")); expect(screen.getByText("페이지 푸터 섹션"));
}); });
it("비디오 블록 추가 버튼 클릭 시 addVideoBlock 이 호출되어야 한다", () => { it("비디오 버튼 클릭 시 addVideoBlock 이 호출되어야 한다", () => {
render(<BlocksSidebar />); render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "비디오" });
const button = screen.getByRole("button", { name: "비디오 블록 추가" });
fireEvent.click(button); fireEvent.click(button);
expect(otherActions.addVideoBlock).toHaveBeenCalledTimes(1); expect(otherActions.addVideoBlock).toHaveBeenCalledTimes(1);
@@ -165,4 +160,55 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
expect(pricingThumb.children.length).toBe(3); expect(pricingThumb.children.length).toBe(3);
expect(teamThumb.children.length).toBe(3); expect(teamThumb.children.length).toBe(3);
}); });
it("블록 섹션 타이틀을 클릭하면 블록 버튼들을 접고 펼 수 있어야 한다", () => {
render(<BlocksSidebar />);
// 초기에는 텍스트 버튼이 보여야 한다.
expect(screen.getByRole("button", { name: "텍스트" })).toBeDefined();
const blockToggle = screen.getByRole("button", { name: "블록" });
fireEvent.click(blockToggle);
// 접힌 상태에서는 텍스트 버튼이 보이지 않아야 한다.
expect(screen.queryByRole("button", { name: "텍스트" })).toBeNull();
// 다시 클릭하면 펼쳐져야 한다.
fireEvent.click(blockToggle);
expect(screen.getByRole("button", { name: "텍스트" })).toBeDefined();
});
it("폼 요소 섹션 타이틀을 클릭하면 폼 관련 버튼들을 접고 펼 수 있어야 한다", () => {
render(<BlocksSidebar />);
// 초기에는 폼 입력 버튼이 보여야 한다.
expect(screen.getByRole("button", { name: "입력(Input)" })).toBeDefined();
const formToggle = screen.getByRole("button", { name: "폼 요소" });
fireEvent.click(formToggle);
// 접힌 상태에서는 폼 입력 버튼이 보이지 않아야 한다.
expect(screen.queryByRole("button", { name: "입력(Input)" })).toBeNull();
// 다시 클릭하면 펼쳐져야 한다.
fireEvent.click(formToggle);
expect(screen.getByRole("button", { name: "입력(Input)" })).toBeDefined();
});
it("템플릿 섹션 타이틀을 클릭하면 템플릿 카드들을 접고 펼 수 있어야 한다", () => {
render(<BlocksSidebar />);
// 초기에는 Hero 템플릿 버튼이 보여야 한다.
expect(screen.getByRole("button", { name: "Hero 템플릿" })).toBeDefined();
const templateToggle = screen.getByRole("button", { name: "템플릿" });
fireEvent.click(templateToggle);
// 접힌 상태에서는 Hero 템플릿 버튼이 보이지 않아야 한다.
expect(screen.queryByRole("button", { name: "Hero 템플릿" })).toBeNull();
// 다시 클릭하면 펼쳐져야 한다.
fireEvent.click(templateToggle);
expect(screen.getByRole("button", { name: "Hero 템플릿" })).toBeDefined();
});
}); });
+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 { 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 type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import EditorPage from "@/app/editor/page"; import EditorPage from "@/app/editor/page";
// EditorPage Export 미리보기 UX TDD // EditorPage 상단 메뉴 UX TDD
// - 상단 메뉴에 "Export 미리보기" 항목이 노출되검증한다. // - Export 미리보기 기능 제거 후, 상단 메뉴에 더 이상 "Export 미리보기" 항목이 노출되지 않아야 한다.
// - Export 미리보기 모달에서 "미리보기 새로고침" 클릭 시 // - 대신 프로젝트 저장/불러오기와 연계된 "프로젝트 목록" 항목이 메뉴에 노출되어야 한다.
// - /api/export/preview 엔드포인트를 호출하고,
// - 반환된 HTML 을 iframe srcdoc 으로 렌더하는지 확인한다.
let mockState: any; let mockState: any;
@@ -88,61 +86,17 @@ vi.mock("next/link", () => {
}; };
}); });
describe("EditorPage - Export 미리보기", () => { describe("EditorPage - 상단 메뉴 (Export 미리보기 제거)", () => {
it("메뉴에서 Export 미리보기 항목을 클릭하면 Export 미리보기 모달이 열려야 한다", () => { it("메뉴를 열었을 때 'Export 미리보기' 항목은 보이지 않고, '프로젝트 목록' 항목이 노출되어야 한다", () => {
render(<EditorPage />); render(<EditorPage />);
const menuButton = screen.getByText("메뉴"); const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton); fireEvent.click(menuButton);
const previewMenuItem = screen.getByText("Export 미리보기"); const removedItem = screen.queryByText("Export 미리보기");
fireEvent.click(previewMenuItem); expect(removedItem).toBeNull();
const modalTitle = screen.getByText("Export 미리보기"); const projectListItem = screen.getByText("프로젝트 목록");
expect(modalTitle).toBeTruthy(); expect(projectListItem).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 미리보기 본문");
}); });
}); });
+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();
});
});
+2
View File
@@ -6,6 +6,8 @@ import EditorPage from "@/app/editor/page";
let mockState: any; let mockState: any;
beforeEach(() => { beforeEach(() => {
window.localStorage.clear();
const baseProjectConfig: ProjectConfig = { const baseProjectConfig: ProjectConfig = {
title: "멀티 선택 테스트", title: "멀티 선택 테스트",
slug: "multi-select", slug: "multi-select",
+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", () => { 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 버튼이 노출되어야 한다", () => { it("프리뷰 헤더에 ZIP Export 버튼이 노출되어야 한다", () => {
render(<PreviewPage />); render(<PreviewPage />);
+231
View File
@@ -0,0 +1,231 @@
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);
// DELETE 요청이 성공적으로 처리된 뒤에는 목록에서 해당 프로젝트들이 사라져야 한다.
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();
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import type { Block } from "@/features/editor/state/editorStore";
import { PropertiesSidebar } from "@/app/editor/panels/PropertiesSidebar";
describe("PropertiesSidebar HELP 튜토리얼 모달", () => {
afterEach(() => {
cleanup();
});
const createTextBlock = (): Block => ({
id: "text-1",
type: "text",
props: {
text: "도움말 테스트",
align: "left",
size: "base",
} as any,
sectionId: null,
columnId: null,
});
const defaultProps = (blocks: Block[], selectedBlockId: string | null) => ({
blocks,
selectedBlockId,
selectedListItemId: null,
updateBlock: vi.fn(),
removeBlock: vi.fn(),
duplicateBlock: vi.fn(),
editingBlockId: null,
setEditingText: vi.fn(),
onMoveSelectedItemUp: vi.fn(),
onMoveSelectedItemDown: vi.fn(),
onIndentSelectedItem: vi.fn(),
onOutdentSelectedItem: vi.fn(),
});
it("텍스트 블록이 선택된 상태에서 HELP 버튼을 노출해야 한다", () => {
const textBlock = createTextBlock();
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
const helpButton = screen.getByRole("button", { name: "HELP" });
expect(helpButton).toBeDefined();
});
it("HELP 버튼 클릭 시 현재 블록 타입에 맞는 튜토리얼 모달이 열려야 한다", () => {
const textBlock = createTextBlock();
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
const helpButton = screen.getByRole("button", { name: "HELP" });
fireEvent.click(helpButton);
expect(screen.getByText("텍스트 블록 튜토리얼")).toBeDefined();
expect(screen.getByText(/제목과 본문 텍스트를 입력할 때 사용하는 기본 블록입니다/)).toBeDefined();
});
it("모달의 닫기 버튼을 클릭하면 튜토리얼 모달이 닫혀야 한다", () => {
const textBlock = createTextBlock();
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
const helpButton = screen.getByRole("button", { name: "HELP" });
fireEvent.click(helpButton);
const closeButton = screen.getByRole("button", { name: "닫기" });
fireEvent.click(closeButton);
expect(screen.queryByText("텍스트 블록 튜토리얼")).toBeNull();
});
});
+20
View File
@@ -221,6 +221,11 @@ describe("formHelpers - editor tokens", () => {
widthMode: "fixed", widthMode: "fixed",
widthPx: 260, widthPx: 260,
borderRadius: "lg", borderRadius: "lg",
paddingX: 12,
paddingY: 6,
fontSizeCustom: "14px",
lineHeightCustom: "20px",
letterSpacingCustom: "1px",
} as any); } as any);
expect(tokens.fieldStyle.color).toBe("#ff0000"); expect(tokens.fieldStyle.color).toBe("#ff0000");
@@ -228,6 +233,11 @@ describe("formHelpers - editor tokens", () => {
expect(tokens.fieldStyle.borderColor).toBe("#654321"); expect(tokens.fieldStyle.borderColor).toBe("#654321");
expect(tokens.fieldStyle.width).toBe("260px"); expect(tokens.fieldStyle.width).toBe("260px");
expect(tokens.fieldStyle.borderRadius).toBe(9999); expect(tokens.fieldStyle.borderRadius).toBe(9999);
expect(tokens.fieldStyle.paddingInline).toBe("12px");
expect(tokens.fieldStyle.paddingBlock).toBe("6px");
expect(tokens.fieldStyle.fontSize).toBe("14px");
expect(tokens.fieldStyle.lineHeight).toBe("20px");
expect(tokens.fieldStyle.letterSpacing).toBe("1px");
}); });
it("computeFormOptionGroupEditorTokens: 에디터 라디오/체크박스 그룹 필드 스타일을 계산해야 한다", () => { it("computeFormOptionGroupEditorTokens: 에디터 라디오/체크박스 그룹 필드 스타일을 계산해야 한다", () => {
@@ -239,6 +249,11 @@ describe("formHelpers - editor tokens", () => {
widthMode: "fixed", widthMode: "fixed",
widthPx: 280, widthPx: 280,
borderRadius: "lg", borderRadius: "lg",
paddingX: 8,
paddingY: 4,
fontSizeCustom: "15px",
lineHeightCustom: "22px",
letterSpacingCustom: "0.5px",
} as any); } as any);
expect(tokens.fieldStyle.color).toBe("#ff0000"); expect(tokens.fieldStyle.color).toBe("#ff0000");
@@ -246,6 +261,11 @@ describe("formHelpers - editor tokens", () => {
expect(tokens.fieldStyle.borderColor).toBe("#654321"); expect(tokens.fieldStyle.borderColor).toBe("#654321");
expect(tokens.fieldStyle.width).toBe("280px"); expect(tokens.fieldStyle.width).toBe("280px");
expect(tokens.fieldStyle.borderRadius).toBe(9999); expect(tokens.fieldStyle.borderRadius).toBe(9999);
expect(tokens.fieldStyle.paddingInline).toBe("8px");
expect(tokens.fieldStyle.paddingBlock).toBe("4px");
expect(tokens.fieldStyle.fontSize).toBe("15px");
expect(tokens.fieldStyle.lineHeight).toBe("22px");
expect(tokens.fieldStyle.letterSpacing).toBe("0.5px");
}); });
}); });
+3 -3
View File
@@ -256,9 +256,9 @@ describe("프리뷰와 정적 내보내기 일치 (고수준)", () => {
expect(previewHtml).toContain('class="pb-list'); expect(previewHtml).toContain('class="pb-list');
expect(staticHtml).toContain('class="pb-list"'); expect(staticHtml).toContain('class="pb-list"');
// 리스트 아이템(li)은 margin-bottom 인라인 스타일을 사용하지 않아야 한다. // 프리뷰에서는 리스트 gap 이 li 의 margin-bottom 인라인 스타일로 표현된다.
// divider 등 다른 요소에서 margin-bottom 을 사용하는 것은 허용한다. // Export HTML 은 여전히 li 인라인 margin-bottom 을 사용하지 않고 CSS 에서 처리한다.
expect(previewHtml).not.toMatch(/<li[^>]*style=\"[^\"]*margin-bottom:/); expect(previewHtml).toMatch(/<li[^>]*style=\"[^\"]*margin-bottom:/);
expect(staticHtml).not.toMatch(/<li[^>]*style=\"[^\"]*margin-bottom:/); expect(staticHtml).not.toMatch(/<li[^>]*style=\"[^\"]*margin-bottom:/);
}, },
); );
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
@@ -0,0 +1 @@
dummy-section-video
@@ -0,0 +1 @@
dummy-section-video
@@ -0,0 +1 @@
dummy-section-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-poster
+1
View File
@@ -0,0 +1 @@
dummy-poster
+1
View File
@@ -0,0 +1 @@
dummy-poster