CI: feature/form-submissions-pages #30
@@ -165,6 +165,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
// - formBlockIdToFormDomId: key = FormBlock id, value = <form> 요소의 DOM id
|
// - formBlockIdToFormDomId: key = FormBlock id, value = <form> 요소의 DOM id
|
||||||
const fieldIdToFormId = new Map<string, string>();
|
const fieldIdToFormId = new Map<string, string>();
|
||||||
const formBlockIdToFormDomId = new Map<string, string>();
|
const formBlockIdToFormDomId = new Map<string, string>();
|
||||||
|
const submitButtonIdToFormDomId = new Map<string, string>();
|
||||||
const requiredFieldIdSet = new Set<string>();
|
const requiredFieldIdSet = new Set<string>();
|
||||||
// 테스트 및 기존 Export HTML 과의 호환성을 위해 FormBlock 의 DOM id 규칙을 다음과 같이 정의한다.
|
// 테스트 및 기존 Export HTML 과의 호환성을 위해 FormBlock 의 DOM id 규칙을 다음과 같이 정의한다.
|
||||||
// - block.id 가 "form_숫자" 패턴이면 기존과 동일하게 formDomId = `form_${block.id}` (예: form_1 → form_form_1)
|
// - block.id 가 "form_숫자" 패턴이면 기존과 동일하게 formDomId = `form_${block.id}` (예: form_1 → form_form_1)
|
||||||
@@ -190,6 +191,11 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const submitButtonIdRaw = props.submitButtonId;
|
||||||
|
if (typeof submitButtonIdRaw === "string" && submitButtonIdRaw.trim() !== "") {
|
||||||
|
submitButtonIdToFormDomId.set(submitButtonIdRaw, formDomId);
|
||||||
|
}
|
||||||
|
|
||||||
const requiredFieldIds = Array.isArray(props.requiredFieldIds) ? props.requiredFieldIds : [];
|
const requiredFieldIds = Array.isArray(props.requiredFieldIds) ? props.requiredFieldIds : [];
|
||||||
for (const fieldId of requiredFieldIds) {
|
for (const fieldId of requiredFieldIds) {
|
||||||
if (typeof fieldId === "string" && fieldId.trim() !== "") {
|
if (typeof fieldId === "string" && fieldId.trim() !== "") {
|
||||||
@@ -284,6 +290,13 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
innerHtml = `<img src="${imgSrc}" alt="${imgAlt}" />${innerHtml}`;
|
innerHtml = `<img src="${imgSrc}" alt="${imgAlt}" />${innerHtml}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const submitFormId = submitButtonIdToFormDomId.get(block.id);
|
||||||
|
if (submitFormId) {
|
||||||
|
return `<div class="${tokens.alignClass}"><button type="submit" form="${escapeAttr(
|
||||||
|
submitFormId,
|
||||||
|
)}" class="${btnClasses}"${styleAttr}>${innerHtml}</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
return `<div class="${tokens.alignClass}"><a href="${escapeAttr(
|
return `<div class="${tokens.alignClass}"><a href="${escapeAttr(
|
||||||
href,
|
href,
|
||||||
)}" class="${btnClasses}"${styleAttr}>${innerHtml}</a></div>`;
|
)}" class="${btnClasses}"${styleAttr}>${innerHtml}</a></div>`;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import type { Block, ButtonBlockProps, FormBlockProps } from "@/features/editor/state/editorStore";
|
import type { Block, ButtonBlockProps, FormBlockProps } from "@/features/editor/state/editorStore";
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||||||
|
import { computeFormControllerPublicTokens } from "@/features/editor/utils/formHelpers";
|
||||||
|
|
||||||
interface FormControllerPanelProps {
|
interface FormControllerPanelProps {
|
||||||
block: Block; // type === "form"
|
block: Block; // type === "form"
|
||||||
@@ -13,8 +15,59 @@ interface FormControllerPanelProps {
|
|||||||
export function FormControllerPanel({ block, blocks, selectedBlockId, updateBlock }: FormControllerPanelProps) {
|
export function FormControllerPanel({ block, blocks, selectedBlockId, updateBlock }: FormControllerPanelProps) {
|
||||||
if (!selectedBlockId || block.id !== selectedBlockId) return null;
|
if (!selectedBlockId || block.id !== selectedBlockId) return null;
|
||||||
|
|
||||||
|
const [showSheetsGuide, setShowSheetsGuide] = useState(false);
|
||||||
|
|
||||||
const formProps = block.props as FormBlockProps;
|
const formProps = block.props as FormBlockProps;
|
||||||
|
|
||||||
|
const sheetsScriptExample = (() => {
|
||||||
|
const tokens = computeFormControllerPublicTokens(block, blocks);
|
||||||
|
const fieldNames = tokens.fields.map((f) => f.name).filter((name) => !!name);
|
||||||
|
|
||||||
|
if (fieldNames.length === 0) {
|
||||||
|
return (
|
||||||
|
"function doPost(e) {\n" +
|
||||||
|
' var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("시트1");\n' +
|
||||||
|
" if (!sheet) { sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; }\n" +
|
||||||
|
" var params = e.parameter; // x-www-form-urlencoded\n" +
|
||||||
|
" var row = [\n" +
|
||||||
|
' params.name || "",\n' +
|
||||||
|
' params.email || "",\n' +
|
||||||
|
' params.message || "",\n' +
|
||||||
|
" new Date(),\n" +
|
||||||
|
" ];\n" +
|
||||||
|
" sheet.appendRow(row);\n" +
|
||||||
|
" return ContentService\n" +
|
||||||
|
" .createTextOutput(JSON.stringify({ ok: true }))\n" +
|
||||||
|
" .setMimeType(ContentService.MimeType.JSON);\n" +
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerNames = [...fieldNames, "createdAt"];
|
||||||
|
const rowLines = fieldNames.map((name) => ` params.${name} || "",`);
|
||||||
|
rowLines.push(" new Date(),");
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("function doPost(e) {");
|
||||||
|
lines.push(' var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("시트1");');
|
||||||
|
lines.push(" if (!sheet) { sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; }");
|
||||||
|
lines.push(" var params = e.parameter; // x-www-form-urlencoded");
|
||||||
|
lines.push(" // 시트의 1행 헤더는 다음과 같이 맞춰 주세요:");
|
||||||
|
lines.push(` // ${headerNames.join(", ")}`);
|
||||||
|
lines.push(" var row = [");
|
||||||
|
for (const line of rowLines) {
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
lines.push(" ];");
|
||||||
|
lines.push(" sheet.appendRow(row);");
|
||||||
|
lines.push(" return ContentService");
|
||||||
|
lines.push(" .createTextOutput(JSON.stringify({ ok: true }))");
|
||||||
|
lines.push(" .setMimeType(ContentService.MimeType.JSON);");
|
||||||
|
lines.push("}");
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 text-xs">
|
<div className="space-y-4 text-xs">
|
||||||
<p className="text-slate-400">
|
<p className="text-slate-400">
|
||||||
@@ -40,9 +93,9 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formProps.submitTarget === "webhook" && (
|
{formProps.submitTarget === "webhook" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<span className="text-slate-400">Webhook / Google Sheets URL</span>
|
<span className="text-slate-400">Webhook URL (예: Google Apps Script 웹 앱 URL)</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"
|
||||||
placeholder="예: https://script.google.com/macros/s/.../exec"
|
placeholder="예: https://script.google.com/macros/s/.../exec"
|
||||||
@@ -126,6 +179,16 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] text-slate-100 hover:bg-slate-800"
|
||||||
|
onClick={() => setShowSheetsGuide(true)}
|
||||||
|
>
|
||||||
|
Google Sheets 연동 가이드
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -325,6 +388,41 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showSheetsGuide && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70">
|
||||||
|
<div className="w-full max-w-xl rounded border border-slate-800 bg-slate-950 px-4 py-3 shadow-lg">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h4 className="text-[11px] font-semibold text-slate-100">Google Sheets 연동 가이드</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[11px] text-slate-400 hover:text-slate-100"
|
||||||
|
onClick={() => setShowSheetsGuide(false)}
|
||||||
|
aria-label="Google Sheets 연동 가이드 닫기"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400 leading-relaxed">
|
||||||
|
구글 시트 주소를 그대로 입력하는 것이 아니라, 시트에 연결된 Google Apps Script 웹 앱 URL 을 입력해야 합니다.
|
||||||
|
아래 순서를 따르면 코드를 잘 모르는 사용자도 연동할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-[11px] text-slate-400 mt-2">
|
||||||
|
<li>Google Sheets 에서 새 스프레드시트를 만들고, 1행에 name, email, message 같은 헤더를 추가합니다.</li>
|
||||||
|
<li>시트 메뉴의 "확장 프로그램 → 앱스 스크립트" 를 열고 아래 예제 코드를 붙여넣습니다.</li>
|
||||||
|
<li>"배포 → 새 배포" 에서 웹 앱으로 배포한 뒤, 발급된 웹 앱 URL(…/exec) 을 Webhook URL 칸에 붙여넣습니다.</li>
|
||||||
|
</ol>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<span className="text-[11px] text-slate-400">예시 Apps Script 코드</span>
|
||||||
|
<textarea
|
||||||
|
className="w-full h-40 rounded border border-slate-800 bg-slate-950 px-2 py-1 text-[11px] font-mono text-slate-200"
|
||||||
|
readOnly
|
||||||
|
value={sheetsScriptExample}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ function EditorPageInner() {
|
|||||||
const [projectMessage, setProjectMessage] = useState("");
|
const [projectMessage, setProjectMessage] = useState("");
|
||||||
// 멀티 선택(MVP)을 위한 로컬 선택 상태: 단일 선택(selectedBlockId)과 병행 사용한다.
|
// 멀티 선택(MVP)을 위한 로컬 선택 상태: 단일 선택(selectedBlockId)과 병행 사용한다.
|
||||||
const [selectedBlockIds, setSelectedBlockIds] = useState<string[]>([]);
|
const [selectedBlockIds, setSelectedBlockIds] = useState<string[]>([]);
|
||||||
|
const [initializedNewProjectFromQuery, setInitializedNewProjectFromQuery] = useState(false);
|
||||||
|
|
||||||
const historyLength = useEditorStore((state) => (state as any).history?.length ?? 0);
|
const historyLength = useEditorStore((state) => (state as any).history?.length ?? 0);
|
||||||
const futureLength = useEditorStore((state) => (state as any).future?.length ?? 0);
|
const futureLength = useEditorStore((state) => (state as any).future?.length ?? 0);
|
||||||
@@ -176,6 +177,75 @@ function EditorPageInner() {
|
|||||||
}
|
}
|
||||||
}, [initialSlugFromQuery, projectConfig?.slug, updateProjectConfig]);
|
}, [initialSlugFromQuery, projectConfig?.slug, updateProjectConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchParams) return;
|
||||||
|
|
||||||
|
if (initializedNewProjectFromQuery) return;
|
||||||
|
|
||||||
|
const newParam = searchParams.get("new");
|
||||||
|
if (newParam !== "1") return;
|
||||||
|
|
||||||
|
if (blocks.length > 0) {
|
||||||
|
replaceBlocks([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProjectConfig({
|
||||||
|
title: "새 페이지",
|
||||||
|
slug: "my-landing",
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasBgColorHex: "#020617",
|
||||||
|
bodyBgColorHex: "#020617",
|
||||||
|
headHtml: "",
|
||||||
|
trackingScript: "",
|
||||||
|
seoTitle: "",
|
||||||
|
seoDescription: "",
|
||||||
|
seoOgImageUrl: "",
|
||||||
|
seoCanonicalUrl: "",
|
||||||
|
seoNoIndex: false,
|
||||||
|
} as ProjectConfig);
|
||||||
|
|
||||||
|
resetHistory();
|
||||||
|
setInitializedNewProjectFromQuery(true);
|
||||||
|
}, [searchParams, initializedNewProjectFromQuery, blocks.length, replaceBlocks, updateProjectConfig, resetHistory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchParams) return;
|
||||||
|
|
||||||
|
const slugParam = searchParams.get("slug");
|
||||||
|
if (slugParam && slugParam.trim().length > 0) {
|
||||||
|
// URL 에 slug 쿼리 파라미터가 있으면 기존 프로젝트 편집 중이므로,
|
||||||
|
// 자동 슬러그 생성은 동작시키지 않는다.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSlugRaw = projectConfig?.slug;
|
||||||
|
const currentSlug = typeof currentSlugRaw === "string" ? currentSlugRaw.trim() : "";
|
||||||
|
|
||||||
|
// 초기 상태("my-landing") 또는 비어 있는 경우에만 자동 슬러그를 한 번 생성한다.
|
||||||
|
if (currentSlug && currentSlug !== "my-landing") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const datePart = [
|
||||||
|
now.getFullYear().toString().padStart(4, "0"),
|
||||||
|
(now.getMonth() + 1).toString().padStart(2, "0"),
|
||||||
|
now.getDate().toString().padStart(2, "0"),
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
const timePart = [
|
||||||
|
now.getHours().toString().padStart(2, "0"),
|
||||||
|
now.getMinutes().toString().padStart(2, "0"),
|
||||||
|
now.getSeconds().toString().padStart(2, "0"),
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
const randomPart = Math.random().toString(36).slice(2, 6);
|
||||||
|
|
||||||
|
const generatedSlug = `page-${datePart}-${timePart}-${randomPart}`;
|
||||||
|
|
||||||
|
updateProjectConfig({ slug: generatedSlug });
|
||||||
|
}, [searchParams, projectConfig?.slug, updateProjectConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authChecked) return;
|
if (!authChecked) return;
|
||||||
if (!initialSlugFromQuery) return;
|
if (!initialSlugFromQuery) return;
|
||||||
@@ -817,6 +887,52 @@ function EditorPageInner() {
|
|||||||
}
|
}
|
||||||
}, [blocks, projectConfig, authUserId, authChecked]);
|
}, [blocks, projectConfig, authUserId, authChecked]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authChecked) return;
|
||||||
|
if (!authUserId) return;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const slugRaw = projectConfig?.slug;
|
||||||
|
const slug = typeof slugRaw === "string" ? slugRaw.trim() : "";
|
||||||
|
if (!slug || slug === "my-landing") return;
|
||||||
|
|
||||||
|
const titleRaw = projectConfig?.title;
|
||||||
|
const title = (typeof titleRaw === "string" ? titleRaw.trim() : "") || "제목 없음";
|
||||||
|
|
||||||
|
const intervalEnv = process.env.NEXT_PUBLIC_EDITOR_SERVER_AUTOSAVE_MS;
|
||||||
|
const intervalMs = intervalEnv ? Number(intervalEnv) || 20000 : 20000;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const saveOnce = async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch("/api/projects", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
contentJson: blocks,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 서버 자동 저장 실패는 치명적이지 않으므로 조용히 무시한다.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
void saveOnce();
|
||||||
|
}, intervalMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [authChecked, authUserId, projectConfig?.slug, projectConfig?.title, blocks]);
|
||||||
|
|
||||||
// 에디터 전역에서 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을 이용한 선택 블록 이동을 처리한다.
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface PublicProjectPageClientProps {
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublicProjectPageClient({ html }: PublicProjectPageClientProps) {
|
||||||
|
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined" || typeof window === "undefined") return;
|
||||||
|
|
||||||
|
let toastTimeout: number | null = null;
|
||||||
|
|
||||||
|
const showToast = (message: string) => {
|
||||||
|
setToastMessage(message);
|
||||||
|
if (toastTimeout !== null) {
|
||||||
|
window.clearTimeout(toastTimeout);
|
||||||
|
}
|
||||||
|
toastTimeout = window.setTimeout(() => {
|
||||||
|
setToastMessage(null);
|
||||||
|
}, 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const forms = document.querySelectorAll<HTMLFormElement>(
|
||||||
|
'form.pb-form-controller, form[action="/api/forms/submit"]',
|
||||||
|
);
|
||||||
|
if (!forms || forms.length === 0) return;
|
||||||
|
|
||||||
|
forms.forEach((form) => {
|
||||||
|
if (form.dataset.pbInitialized === "1") return;
|
||||||
|
form.dataset.pbInitialized = "1";
|
||||||
|
|
||||||
|
form.addEventListener("submit", (event) => {
|
||||||
|
if (!form) return;
|
||||||
|
if (event && typeof event.preventDefault === "function") {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const configInput = form.querySelector<HTMLInputElement>('input[name="__config"]');
|
||||||
|
let config: any = null;
|
||||||
|
if (configInput && configInput.value) {
|
||||||
|
try {
|
||||||
|
config = JSON.parse(configInput.value);
|
||||||
|
} catch {
|
||||||
|
config = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = form.getAttribute("action") || "/api/forms/submit";
|
||||||
|
|
||||||
|
fetch(action, { method: "POST", body: formData })
|
||||||
|
.then((res) => res.json().catch(() => ({})))
|
||||||
|
.then((data: any) => {
|
||||||
|
const ok = data && data.ok;
|
||||||
|
const message = data && data.message;
|
||||||
|
if (ok) {
|
||||||
|
const success =
|
||||||
|
message || (config && config.successMessage) || "성공적으로 전송되었습니다.";
|
||||||
|
showToast(success);
|
||||||
|
try {
|
||||||
|
form.reset();
|
||||||
|
} catch {}
|
||||||
|
} else {
|
||||||
|
const errorMsg =
|
||||||
|
message || (config && config.errorMessage) || "전송 중 오류가 발생했습니다.";
|
||||||
|
showToast(errorMsg);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
const errorMsg =
|
||||||
|
(config && config.errorMessage) || "전송 중 오류가 발생했습니다.";
|
||||||
|
showToast(errorMsg);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (toastTimeout !== null) {
|
||||||
|
window.clearTimeout(toastTimeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
{toastMessage ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
right: "16px",
|
||||||
|
bottom: "16px",
|
||||||
|
zIndex: 50,
|
||||||
|
maxWidth: "320px",
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: "rgba(15, 23, 42, 0.95)",
|
||||||
|
color: "#e5e7eb",
|
||||||
|
fontSize: "14px",
|
||||||
|
lineHeight: "1.4",
|
||||||
|
boxShadow: "0 10px 25px rgba(15, 23, 42, 0.6)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toastMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { PrismaClient } from "@prisma/client";
|
|||||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
import { buildStaticHtml } from "@/features/export/buildStaticHtml";
|
import { buildStaticHtml } from "@/features/export/buildStaticHtml";
|
||||||
import { getCachedPublicProject } from "@/features/projects/publicCache";
|
import { getCachedPublicProject } from "@/features/projects/publicCache";
|
||||||
|
import PublicProjectPageClient from "./PublicProjectPageClient";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -75,5 +76,5 @@ export default async function PublicProjectPage({ params }: PageParams) {
|
|||||||
const mainMatch = fullHtml.match(/<main[\s\S]*?<\/main>/);
|
const mainMatch = fullHtml.match(/<main[\s\S]*?<\/main>/);
|
||||||
const mainHtml = mainMatch ? mainMatch[0] : "";
|
const mainHtml = mainMatch ? mainMatch[0] : "";
|
||||||
|
|
||||||
return <div dangerouslySetInnerHTML={{ __html: mainHtml }} />;
|
return <PublicProjectPageClient html={mainHtml} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ export default function ProjectsPage() {
|
|||||||
<span>전체 제출 내역</span>
|
<span>전체 제출 내역</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/editor"
|
href="/editor?new=1"
|
||||||
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"
|
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" />
|
<FilePlus2 className="w-4 h-4" aria-hidden="true" />
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ body {
|
|||||||
|
|
||||||
.pb-section-inner {
|
.pb-section-inner {
|
||||||
padding-inline: 1.5rem;
|
padding-inline: 1.5rem;
|
||||||
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pb-section-columns {
|
.pb-section-columns {
|
||||||
|
|||||||
@@ -2114,7 +2114,7 @@ describe("/api/export", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("스타일 테스트", () => {
|
describe("buildStaticHtml", () => {
|
||||||
it("text 블록은 pb 텍스트 스타일 클래스를 포함해야 한다", async () => {
|
it("text 블록은 pb 텍스트 스타일 클래스를 포함해야 한다", async () => {
|
||||||
const blocks: Block[] = [
|
const blocks: Block[] = [
|
||||||
{
|
{
|
||||||
@@ -2545,6 +2545,50 @@ describe("/api/export", () => {
|
|||||||
expect(html).toContain("섹션 레이아웃 텍스트");
|
expect(html).toContain("섹션 레이아웃 텍스트");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("FormBlock.submitButtonId 로 매핑된 버튼은 해당 form 을 submit 하는 button 요소로 Export 되어야 한다", async () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_1",
|
||||||
|
type: "form",
|
||||||
|
props: {
|
||||||
|
fieldIds: ["field_email"],
|
||||||
|
requiredFieldIds: [],
|
||||||
|
submitButtonId: "submit_btn",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: "field_email",
|
||||||
|
type: "formInput",
|
||||||
|
props: {
|
||||||
|
label: "이메일",
|
||||||
|
formFieldName: "email",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: "submit_btn",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "폼 제출",
|
||||||
|
href: "#",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const projectConfig: ProjectConfig = {
|
||||||
|
title: "폼 제출 버튼 매핑 테스트",
|
||||||
|
slug: "form-submit-button-mapping-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
const { buildStaticHtml } = await import("@/app/api/export/route");
|
||||||
|
|
||||||
|
const html = buildStaticHtml(blocks, projectConfig);
|
||||||
|
|
||||||
|
expect(html).toContain('<form id="form_form_1" class="pb-form-controller" method="post" action="/api/forms/submit"');
|
||||||
|
expect(html).toContain('type="submit" form="form_form_1"');
|
||||||
|
expect(html).toContain("폼 제출");
|
||||||
|
});
|
||||||
|
|
||||||
it("formInput 블록의 textColorCustom 은 정적 HTML에서 input 텍스트 색상 스타일로 반영되어야 한다", async () => {
|
it("formInput 블록의 textColorCustom 은 정적 HTML에서 input 텍스트 색상 스타일로 반영되어야 한다", async () => {
|
||||||
const blocks: Block[] = [
|
const blocks: Block[] = [
|
||||||
{
|
{
|
||||||
@@ -3178,6 +3222,7 @@ describe("/api/export", () => {
|
|||||||
expect(css).toContain(".pb-root-inner");
|
expect(css).toContain(".pb-root-inner");
|
||||||
expect(css).toContain("max-width: 72rem");
|
expect(css).toContain("max-width: 72rem");
|
||||||
expect(css).toContain(".pb-section-inner");
|
expect(css).toContain(".pb-section-inner");
|
||||||
|
expect(css).toContain("margin-inline: auto");
|
||||||
expect(css).toContain(".pb-section-columns");
|
expect(css).toContain(".pb-section-columns");
|
||||||
expect(css).toContain("gap: 2rem");
|
expect(css).toContain("gap: 2rem");
|
||||||
expect(css).toContain(".pb-section-column");
|
expect(css).toContain(".pb-section-column");
|
||||||
|
|||||||
@@ -155,3 +155,106 @@ test("프리뷰 폼 제출 후 제출 내역 페이지에서 데이터가 보여
|
|||||||
await expect(page.getByText(messageValue)).toBeVisible();
|
await expect(page.getByText(messageValue)).toBeVisible();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("퍼블릭 페이지 폼 제출 후 제출 내역 페이지에서 데이터가 보여야 한다", async ({ page, request }) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const email = `public-form-e2e-${now}@example.com`;
|
||||||
|
const password = "public-form-e2e-password";
|
||||||
|
const projectSlug = `public-form-e2e-project-${now}`;
|
||||||
|
|
||||||
|
const signupRes = await request.post("/api/auth/signup", {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(signupRes.status()).toBe(201);
|
||||||
|
|
||||||
|
const setCookieHeader = signupRes.headers()["set-cookie"];
|
||||||
|
expect(setCookieHeader).toBeTruthy();
|
||||||
|
|
||||||
|
const cookieHeaderString = Array.isArray(setCookieHeader)
|
||||||
|
? setCookieHeader.join("; ")
|
||||||
|
: (setCookieHeader as string);
|
||||||
|
|
||||||
|
const match = cookieHeaderString.match(/pb_access=([^;]+)/);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
|
||||||
|
const accessToken = match![1];
|
||||||
|
|
||||||
|
await page.context().addCookies([
|
||||||
|
{
|
||||||
|
name: "pb_access",
|
||||||
|
value: accessToken,
|
||||||
|
domain: "localhost",
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: "Lax",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto("/editor");
|
||||||
|
|
||||||
|
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||||
|
await propertiesSidebar.getByLabel("프로젝트 제목").fill("E2E 퍼블릭 폼 프로젝트");
|
||||||
|
await propertiesSidebar.getByLabel("프로젝트 주소 (slug)").fill(projectSlug);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||||
|
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||||
|
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||||
|
await page.getByRole("button", { name: "버튼" }).click();
|
||||||
|
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
||||||
|
|
||||||
|
const fieldMappingGroup = page.getByRole("group", { name: "폼 필드 매핑" });
|
||||||
|
const fieldCheckboxes = fieldMappingGroup.locator("> label > input[type='checkbox']");
|
||||||
|
const fieldCount = await fieldCheckboxes.count();
|
||||||
|
for (let i = 0; i < fieldCount; i += 1) {
|
||||||
|
await fieldCheckboxes.nth(i).check({ force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitSelect = page.getByRole("combobox", { name: "Submit 버튼" });
|
||||||
|
await submitSelect.selectOption({ index: 1 });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "메뉴 ▼" }).click();
|
||||||
|
await page.getByRole("button", { name: "프로젝트 저장/불러오기" }).click();
|
||||||
|
await page.getByRole("button", { name: "저장 (로컬 + 서버)" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/_?projects/);
|
||||||
|
await expect(page.getByText(projectSlug)).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto(`/p/${projectSlug}`);
|
||||||
|
|
||||||
|
const nameValue = "홍길동";
|
||||||
|
const emailValue = `public-submitted-${now}@example.com`;
|
||||||
|
const messageValue = "퍼블릭 E2E 테스트 메시지";
|
||||||
|
|
||||||
|
const inputs = page.locator("input.pb-input");
|
||||||
|
const inputCount = await inputs.count();
|
||||||
|
expect(inputCount).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
await inputs.nth(0).fill(nameValue);
|
||||||
|
await inputs.nth(1).fill(emailValue);
|
||||||
|
if (inputCount >= 3) {
|
||||||
|
await inputs.nth(2).fill(messageValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "버튼" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("성공적으로 전송되었습니다.")).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto("/projects");
|
||||||
|
await expect(page).toHaveURL(/\/_?projects/);
|
||||||
|
|
||||||
|
const submissionsRow = page.locator("tr", { hasText: projectSlug }).first();
|
||||||
|
await submissionsRow.getByRole("link", { name: "폼 제출 내역" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/projects/${projectSlug}/submissions`));
|
||||||
|
await expect(page.getByRole("heading", { name: "폼 제출 내역" })).toBeVisible();
|
||||||
|
await expect(page.getByText(projectSlug)).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByText(nameValue)).toBeVisible();
|
||||||
|
await expect(page.getByText(emailValue)).toBeVisible();
|
||||||
|
if (inputCount >= 3) {
|
||||||
|
await expect(page.getByText(messageValue)).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||||
|
import EditorPage from "@/app/editor/page";
|
||||||
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
let mockState: any;
|
||||||
|
let searchParamsSlug: string | null = null;
|
||||||
|
let searchParamsNew: string | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const baseProjectConfig: ProjectConfig = {
|
||||||
|
title: "기존 프로젝트",
|
||||||
|
slug: "existing-slug",
|
||||||
|
canvasPreset: "full",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
mockState = {
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: "blk_existing",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "기존 프로젝트 블록",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as Block[],
|
||||||
|
projectConfig: baseProjectConfig,
|
||||||
|
selectedBlockId: null as string | null,
|
||||||
|
selectedListItemId: null as string | null,
|
||||||
|
undo: vi.fn(),
|
||||||
|
redo: vi.fn(),
|
||||||
|
removeBlock: vi.fn(),
|
||||||
|
duplicateBlock: vi.fn(),
|
||||||
|
selectBlock: vi.fn(),
|
||||||
|
selectListItem: vi.fn(),
|
||||||
|
replaceBlocks: vi.fn((nextBlocks: Block[]) => {
|
||||||
|
mockState.blocks = nextBlocks;
|
||||||
|
}),
|
||||||
|
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;
|
||||||
|
}),
|
||||||
|
resetHistory: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
window.localStorage.clear();
|
||||||
|
searchParamsSlug = null;
|
||||||
|
searchParamsNew = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
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>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: (key: string) => {
|
||||||
|
if (key === "slug") return searchParamsSlug;
|
||||||
|
if (key === "new") return searchParamsNew;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EditorPage - 프로젝트 목록에서 새 프로젝트 만들기", () => {
|
||||||
|
it("/editor?new=1 로 진입하면 기존 프로젝트 상태를 초기화하고 새 캔버스를 열어야 한다", async () => {
|
||||||
|
searchParamsNew = "1";
|
||||||
|
|
||||||
|
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/auth/me" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ id: "user-1", email: "user@example.com", tokenVersion: 1 }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(Array.isArray(mockState.blocks)).toBe(true);
|
||||||
|
expect(mockState.blocks.length).toBe(0);
|
||||||
|
|
||||||
|
const slug = (mockState.projectConfig as ProjectConfig).slug;
|
||||||
|
expect(slug).not.toBe("existing-slug");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||||
|
import EditorPage from "@/app/editor/page";
|
||||||
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
let mockState: any;
|
||||||
|
let searchParamsSlug: string | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const baseProjectConfig: ProjectConfig = {
|
||||||
|
title: "서버 자동저장 테스트",
|
||||||
|
slug: "my-landing",
|
||||||
|
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,
|
||||||
|
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;
|
||||||
|
}),
|
||||||
|
resetHistory: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
window.localStorage.clear();
|
||||||
|
searchParamsSlug = null;
|
||||||
|
(process.env as any).NEXT_PUBLIC_EDITOR_SERVER_AUTOSAVE_MS = "10";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
delete (process.env as any).NEXT_PUBLIC_EDITOR_SERVER_AUTOSAVE_MS;
|
||||||
|
});
|
||||||
|
|
||||||
|
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>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: (key: string) => (key === "slug" ? searchParamsSlug : null),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EditorPage - 서버 자동 저장", () => {
|
||||||
|
it("로그인된 사용자가 새 페이지를 편집하면 자동 생성된 slug 로 /api/projects 에 주기적으로 저장해야 한다", async () => {
|
||||||
|
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/auth/me" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ id: "user-1", email: "user@example.com", tokenVersion: 1 }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/api/projects" && method === "POST") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ slug: JSON.parse((init?.body as string) ?? "{}").slug }),
|
||||||
|
{
|
||||||
|
status: 201,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.some(
|
||||||
|
([url, options]: any[]) => url === "/api/auth/me" && (options?.method ?? "GET") === "GET",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.some(
|
||||||
|
([url, options]: any[]) => url === "/api/projects" && (options?.method ?? "GET") === "POST",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectCall = fetchMock.mock.calls.find(
|
||||||
|
([url, options]: any[]) => url === "/api/projects" && (options?.method ?? "GET") === "POST",
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
const [, options] = projectCall;
|
||||||
|
const body = JSON.parse((options.body as string) ?? "{}");
|
||||||
|
|
||||||
|
expect(typeof body.slug).toBe("string");
|
||||||
|
expect(body.slug).not.toBe("my-landing");
|
||||||
|
expect(mockState.projectConfig.slug).toBe(body.slug);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("authUserId 가 없으면 /api/projects 자동 저장을 호출하지 않아야 한다", async () => {
|
||||||
|
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/auth/me" && method === "GET") {
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.some(
|
||||||
|
([url]: any[]) => url === "/api/auth/me",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectCalls = fetchMock.mock.calls.filter(
|
||||||
|
([url, options]: any[]) => url === "/api/projects" && (options?.method ?? "GET") === "POST",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(projectCalls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("slug 쿼리 파라미터로 기존 프로젝트를 열었을 때는 해당 slug 로 서버 자동 저장을 해야 한다", async () => {
|
||||||
|
searchParamsSlug = "existing-slug";
|
||||||
|
mockState.projectConfig.slug = "my-landing" as any;
|
||||||
|
|
||||||
|
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/auth/me" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ id: "user-1", email: "user@example.com", tokenVersion: 1 }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/api/projects/existing-slug" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
slug: "existing-slug",
|
||||||
|
title: "기존 프로젝트",
|
||||||
|
contentJson: mockState.blocks,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/api/projects" && method === "POST") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ slug: JSON.parse((init?.body as string) ?? "{}").slug }),
|
||||||
|
{
|
||||||
|
status: 201,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.some(
|
||||||
|
([url, options]: any[]) =>
|
||||||
|
url === "/api/projects/existing-slug" && (options?.method ?? "GET") === "GET",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.some(
|
||||||
|
([url, options]: any[]) =>
|
||||||
|
url === "/api/projects" && (options?.method ?? "GET") === "POST",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectCall = fetchMock.mock.calls.find(
|
||||||
|
([url, options]: any[]) => url === "/api/projects" && (options?.method ?? "GET") === "POST",
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
expect(projectCall).toBeTruthy();
|
||||||
|
|
||||||
|
const [, options] = projectCall;
|
||||||
|
const body = JSON.parse((options.body as string) ?? "{}");
|
||||||
|
|
||||||
|
expect(body.slug).toBe("existing-slug");
|
||||||
|
expect(mockState.projectConfig.slug).toBe("existing-slug");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -151,4 +151,75 @@ describe("FormControllerPanel", () => {
|
|||||||
// 기본 선택 값은 FormBlock.submitButtonId 이어야 한다.
|
// 기본 선택 값은 FormBlock.submitButtonId 이어야 한다.
|
||||||
expect(select.value).toBe("btn-1");
|
expect(select.value).toBe("btn-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("submitTarget 이 webhook 인 경우 Google Sheets 연동 가이드 텍스트를 보여줘야 한다", () => {
|
||||||
|
const formBlock = makeFormBlock("form-webhook", {
|
||||||
|
submitTarget: "webhook",
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormControllerPanel
|
||||||
|
block={formBlock}
|
||||||
|
blocks={[formBlock]}
|
||||||
|
selectedBlockId="form-webhook"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const guideButton = screen.getByRole("button", { name: "Google Sheets 연동 가이드" });
|
||||||
|
guideButton.click();
|
||||||
|
|
||||||
|
// Google Sheets 연동 가이드 헤딩과 Apps Script 안내 문구가 노출되어야 한다.
|
||||||
|
expect(screen.getByText("Google Sheets 연동 가이드")).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Apps Script 웹 앱 URL/i),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Google Sheets 가이드 모달의 Apps Script 코드가 컨트롤러에 매핑된 전송 키와 작성일시를 포함해야 한다", async () => {
|
||||||
|
const formBlock = makeFormBlock("form-webhook", {
|
||||||
|
submitTarget: "webhook",
|
||||||
|
fieldIds: ["input-name", "input-email"],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const inputNameBlock: Block = {
|
||||||
|
id: "input-name",
|
||||||
|
type: "formInput",
|
||||||
|
props: {
|
||||||
|
label: "이름",
|
||||||
|
formFieldName: "name",
|
||||||
|
} as any,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const inputEmailBlock: Block = {
|
||||||
|
id: "input-email",
|
||||||
|
type: "formInput",
|
||||||
|
props: {
|
||||||
|
label: "이메일",
|
||||||
|
formFieldName: "email",
|
||||||
|
} as any,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormControllerPanel
|
||||||
|
block={formBlock}
|
||||||
|
blocks={[formBlock, inputNameBlock, inputEmailBlock]}
|
||||||
|
selectedBlockId="form-webhook"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const guideButton = screen.getByRole("button", { name: "Google Sheets 연동 가이드" });
|
||||||
|
fireEvent.click(guideButton);
|
||||||
|
|
||||||
|
const scriptTextarea = (await screen.findByDisplayValue(/function doPost/)) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
expect(scriptTextarea.value).toContain("params.name || \"\"");
|
||||||
|
expect(scriptTextarea.value).toContain("params.email || \"\"");
|
||||||
|
expect(scriptTextarea.value).toContain("new Date()");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-section-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-section-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-section-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-poster
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-poster
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-poster
|
||||||
Reference in New Issue
Block a user