diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index aa26aaf..cefdcca 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -343,13 +343,24 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): } // 컨트롤러 전용 FormBlock: fieldIds 기반으로 개별 필드 블록과 연결되는 순수 컨트롤러 폼만 생성한다. - // - Export 에서는 레이아웃/스타일을 가지지 않고, id/메서드만 가지는 최소한의
요소만 만든다. + // - Export 에서는 레이아웃/스타일 대신 id/메서드/액션만 가지는 최소한의 요소만 만든다. + // - action 은 퍼블릭 페이지에서 우리 서버의 forms/submit 엔드포인트로 전송하기 위한 고정 경로다. const formId = `form_${block.id}`; const method = props.method ?? "POST"; const methodAttr = ` method="${method.toLowerCase()}"`; + const actionAttr = ` action="/api/forms/submit"`; + + // 정적 Export 에서도 FormBlock 설정(전송 대상/웹훅 설정 등)을 함께 전달할 수 있도록 + // preview 렌더러와 동일하게 __config hidden 필드에 FormBlockProps 전체를 JSON 으로 담아둔다. + const configJson = JSON.stringify(props ?? {}); + const escapedConfig = escapeAttr(configJson); const parts: string[] = []; - parts.push(`
`); + parts.push( + `
`, + ); + parts.push(``); + parts.push("
"); return parts.join(""); } @@ -618,7 +629,53 @@ export async function POST(request: Request) { zip.file("builder.css", "/* builder.css 를 찾을 수 없습니다. */\n"); } - const mainJs = `console.log("Page Builder static export loaded");`; + const mainJs = [ + "(function(){", + " function pbInitFormControllers(){", + " var forms = document.querySelectorAll(\"form.pb-form-controller\");", + " if (!forms || !forms.length) { return; }", + " forms.forEach(function(form){", + " form.addEventListener(\"submit\", function(event){", + " if (!form) { return; }", + " if (event && typeof event.preventDefault === 'function') { event.preventDefault(); }", + " var formData = new FormData(form);", + " var configInput = form.querySelector('input[name=\"__config\"]');", + " var config = null;", + " if (configInput && configInput.value) {", + " try { config = JSON.parse(configInput.value); } catch (e) { config = null; }", + " }", + " var action = form.getAttribute('action') || '/api/forms/submit';", + " fetch(action, { method: 'POST', body: formData })", + " .then(function(res){ return res.json().catch(function(){ return {}; }); })", + " .then(function(data){", + " var ok = data && data.ok;", + " var message = data && data.message;", + " if (ok) {", + " var success = message || (config && config.successMessage) || '성공적으로 전송되었습니다.';", + " if (typeof alert === 'function') { alert(success); }", + " try { form.reset(); } catch (e) {}", + " } else {", + " var errorMsg = message || (config && config.errorMessage) || '전송 중 오류가 발생했습니다.';", + " if (typeof alert === 'function') { alert(errorMsg); }", + " }", + " })", + " .catch(function(){", + " var errorMsg = (config && config.errorMessage) || '전송 중 오류가 발생했습니다.';", + " if (typeof alert === 'function') { alert(errorMsg); }", + " });", + " });", + " });", + " }", + " if (typeof document !== 'undefined') {", + " if (document.readyState === 'loading') {", + " document.addEventListener('DOMContentLoaded', pbInitFormControllers);", + " } else {", + " pbInitFormControllers();", + " }", + " }", + " console.log('Page Builder static export loaded');", + "})();", + ].join("\n"); zip.file("main.js", mainJs); const zipArrayBuffer = await zip.generateAsync({ type: "arraybuffer" }); diff --git a/src/app/api/forms/submit/route.ts b/src/app/api/forms/submit/route.ts index e105738..8e70f5c 100644 --- a/src/app/api/forms/submit/route.ts +++ b/src/app/api/forms/submit/route.ts @@ -31,8 +31,12 @@ export async function POST(req: Request) { return NextResponse.json({ ok: true, message: successMessage }); } + if (submitTarget === "both") { + console.log("[forms/submit][internal]", { name, email, message }); + } + // 2) webhook: destinationUrl 로 서버에서 POST (Google Sheets 등 포함) - if (submitTarget === "webhook") { + if (submitTarget === "webhook" || submitTarget === "both") { const destinationUrl = config?.destinationUrl; if (!destinationUrl) { return NextResponse.json( diff --git a/src/features/editor/state/editorStore.ts b/src/features/editor/state/editorStore.ts index ad2c461..c6c39a2 100644 --- a/src/features/editor/state/editorStore.ts +++ b/src/features/editor/state/editorStore.ts @@ -643,7 +643,7 @@ export interface FormFieldConfig { } // 폼 블록 속성 -export type FormSubmitTarget = "internal" | "webhook"; +export type FormSubmitTarget = "internal" | "webhook" | "both"; export type FormPayloadFormat = "form" | "json"; export interface FormBlockProps { diff --git a/tests/api/export.spec.ts b/tests/api/export.spec.ts index e3c6300..c440a90 100644 --- a/tests/api/export.spec.ts +++ b/tests/api/export.spec.ts @@ -65,6 +65,11 @@ describe("/api/export", () => { expect(cssEntry).toBeTruthy(); expect(jsEntry).toBeTruthy(); + const mainJs = await jsEntry!.async("string"); + expect(mainJs).toContain("pbInitFormControllers"); + expect(mainJs).toContain('querySelectorAll("form.pb-form-controller")'); + expect(mainJs).toContain("fetch("); + const html = await indexEntry!.async("string"); expect(html).toContain("내보내기 테스트 페이지"); expect(html).toContain("ZIP 내보내기 테스트"); @@ -475,6 +480,7 @@ describe("/api/export", () => { // -
const formId = "form_form_1"; expect(html).toMatch(new RegExp(`]*id=\\"${formId}\\"`)); + expect(html).toMatch(new RegExp(`]*id=\\"${formId}\\"[^>]*action=\\"/api/forms/submit\\"`)); // 컨트롤러 필드 블록(formInput/formSelect) 이 렌더한 input/select 는 각각 name 과 form 속성을 가져야 한다. // name="name" input 은 required + form="form_form_1" 이어야 한다. @@ -489,6 +495,7 @@ describe("/api/export", () => { const formHtml = html.slice(formStart, formEnd); expect(formHtml).not.toContain('name="name"'); expect(formHtml).not.toContain('name="plan"'); + expect(formHtml).toContain('name="__config"'); }); diff --git a/tests/api/formExportSubmit.integration.spec.ts b/tests/api/formExportSubmit.integration.spec.ts new file mode 100644 index 0000000..2631822 --- /dev/null +++ b/tests/api/formExportSubmit.integration.spec.ts @@ -0,0 +1,108 @@ +import "dotenv/config"; +import { describe, it, expect, vi } from "vitest"; +import JSZip from "jszip"; + +import type { Block, ProjectConfig } from "@/features/editor/state/editorStore"; + +const BASE_URL = "http://localhost"; + +// 정적 Export + main.js + /api/forms/submit 를 묶어서 검증하는 통합 테스트. +// - /api/export 로 ZIP 을 생성해서 index.html / main.js 를 추출한다. +// - jsdom DOM 에 index.html 을 주입하고, main.js 스크립트를 실제로 실행한다. +// - form.pb-form-controller 에서 submit 이벤트를 발생시키면 +// main.js 가 submit 을 가로채고 /api/forms/submit 로 fetch 를 호출해야 한다. + +describe("정적 Export 폼 컨트롤러 통합", () => { + it("Export된 index.html + main.js 에서 submit 시 /api/forms/submit 로 fetch 를 호출해야 한다", async () => { + const blocks: Block[] = [ + { + id: "form_1", + type: "form", + props: { + kind: "contact", + submitTarget: "internal", + fieldIds: ["input_name"], + submitButtonId: null, + formWidthMode: "full", + }, + } as any, + { + id: "input_name", + type: "formInput", + props: { + label: "이름", + formFieldName: "name", + required: true, + }, + } as any, + ]; + + const projectConfig: ProjectConfig = { + title: "폼 통합 테스트", + slug: "form-integration-test", + canvasPreset: "full", + }; + + const payload = { blocks, projectConfig }; + + const { POST: handleExport } = await import("@/app/api/export/route"); + + const res = await handleExport( + new Request(`${BASE_URL}/api/export`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }), + ); + + expect(res.status).toBe(200); + + const arrayBuffer = await res.arrayBuffer(); + const zip = await JSZip.loadAsync(arrayBuffer); + + const indexEntry = zip.file("index.html"); + const jsEntry = zip.file("main.js"); + + expect(indexEntry).toBeTruthy(); + expect(jsEntry).toBeTruthy(); + + const html = await indexEntry!.async("string"); + const mainJs = await jsEntry!.async("string"); + + // jsdom DOM 구성: export 된 HTML 을 그대로 주입한다. + document.documentElement.innerHTML = html; + + const form = document.querySelector("form.pb-form-controller") as HTMLFormElement | null; + expect(form).not.toBeNull(); + + // name 필드에 값 채우기 (FormData 에 실제 값이 들어가도록) + const nameInput = document.querySelector('input[name="name"]') as HTMLInputElement | null; + if (nameInput) { + nameInput.value = "Alice"; + } + + // fetch 를 목킹하여 main.js 의 submit 가로채기 동작을 검증한다. + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ ok: true, message: "ok" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + (globalThis as any).fetch = fetchMock; + + // main.js 실행 (IIFE 코드이므로 평가만 하면 즉시 pbInitFormControllers 가 동작한다.) + // eslint-disable-next-line no-new-func + new Function(mainJs)(); + + // submit 이벤트 발생: main.js 가 이를 가로채고 fetch 를 호출해야 한다. + const submitEvent = new Event("submit", { bubbles: true, cancelable: true }); + form!.dispatchEvent(submitEvent); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, options] = fetchMock.mock.calls[0] as [any, RequestInit]; + + expect(url).toBe("/api/forms/submit"); + expect(options.method).toBe("POST"); + expect(options.body).toBeInstanceOf(FormData); + }); +}); diff --git a/tests/api/formsSubmit.spec.ts b/tests/api/formsSubmit.spec.ts index 0b946b2..d9fc277 100644 --- a/tests/api/formsSubmit.spec.ts +++ b/tests/api/formsSubmit.spec.ts @@ -185,6 +185,49 @@ describe("/api/forms/submit", () => { expect(json.message).toBe(config.successMessage); }); + it("both 모드에서는 internal 처리 후 webhook 으로도 전송되어야 한다", async () => { + const destinationUrl = `${BASE_URL}/webhook-both`; + + const config: FormBlockProps = { + kind: "contact", + submitTarget: "both", + destinationUrl, + method: "POST", + headers: { Authorization: "Bearer both-token" }, + extraParams: { source: "builder-both" }, + successMessage: "양쪽 모두 전송되었습니다.", + errorMessage: "both 모드 전송 실패", + fieldIds: [], + submitButtonId: null, + }; + + const fetchMock = vi.fn(async () => new Response("ok", { status: 200 })); + (globalThis as any).fetch = fetchMock; + + const formData = new FormData(); + formData.append("email_address", "both@example.com"); + formData.append("__config", JSON.stringify(config)); + + const { POST: handleSubmit } = await import("@/app/api/forms/submit/route"); + + const res = await handleSubmit( + new Request(`${BASE_URL}/api/forms/submit`, { + method: "POST", + body: formData, + }), + ); + + expect(res.status).toBe(200); + const json = (await res.json()) as any; + expect(json.ok).toBe(true); + expect(json.message).toBe(config.successMessage); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(destinationUrl); + expect(options.method).toBe("POST"); + }); + it("webhook 모드에서 destinationUrl 이 없으면 400 과 destinationUrl_missing 및 errorMessage 가 포함되어야 한다", async () => { const config: FormBlockProps = { kind: "contact", diff --git a/uploads/test-image-1764275860002 b/uploads/test-image-1764275860002 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764275860002 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764277529423 b/uploads/test-image-1764277529423 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764277529423 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764275860006 b/uploads/test-section-bg-1764275860006 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764275860006 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764277529427 b/uploads/test-section-bg-1764277529427 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764277529427 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764275860010 b/uploads/test-section-bg-video-1764275860010 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764275860010 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764277529432 b/uploads/test-section-bg-video-1764277529432 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764277529432 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-video-1764275860009 b/uploads/test-video-1764275860009 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764275860009 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764277529431 b/uploads/test-video-1764277529431 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764277529431 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-poster-1764275860004 b/uploads/test-video-poster-1764275860004 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764275860004 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764277529425 b/uploads/test-video-poster-1764277529425 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764277529425 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file