Compare commits

...

19 Commits

Author SHA1 Message Date
jaybe a6004a5caf CI 정책 수정
CI / test (push) Successful in 3m18s
CI / pr_and_merge (push) Successful in 1m11s
2025-11-30 18:52:58 +09:00
jaybe c0ddb39562 CI 정책 변경
CI / test (push) Successful in 5m15s
CI / pr_and_merge (push) Successful in 1m6s
CI / test (pull_request) Successful in 6m25s
CI / pr_and_merge (pull_request) Has been skipped
2025-11-30 18:07:05 +09:00
jaybe 278195772a e2e오류 수정
CI / test (push) Successful in 12m30s
CI / pr_and_merge (push) Successful in 1m6s
CI / test (pull_request) Has been cancelled
CI / pr_and_merge (pull_request) Has been cancelled
2025-11-30 17:25:44 +09:00
jaybe 0cf67b5619 리스트/에디터 스타일 정리 및 버그 수정
CI / test (push) Failing after 7m55s
CI / pr_and_merge (push) Has been skipped
2025-11-30 16:52:27 +09:00
jaybe 17bfac62ed 프로젝트 저장 및 목록
CI / test (push) Failing after 7m39s
CI / pr_and_merge (push) Has been skipped
2025-11-29 22:05:25 +09:00
jaybe 1f085bd64c 스타일 수정
CI / test (push) Failing after 44m2s
CI / pr_and_merge (push) Has been skipped
2025-11-29 15:11:49 +09:00
jaybe c9a62d479c form export 수정 및 통합테스트
CI / test (push) Successful in 49m26s
CI / pr_and_merge (push) Has been skipped
2025-11-28 14:25:30 +09:00
jaybe c7b6097bd9 Merge pull request 'CI: feature/bugfix-form-video-preview' (#12) from feature/bugfix-form-video-preview into main
CI / test (push) Successful in 53m24s
CI / pr_and_merge (push) Has been skipped
2025-11-27 17:52:44 +00:00
jaybe 6e5e229ae5 CI오류 수정-3
CI / test (push) Successful in 12m8s
CI / pr_and_merge (push) Successful in 1m18s
CI / test (pull_request) Successful in 51m49s
CI / pr_and_merge (pull_request) Has been skipped
2025-11-28 02:39:23 +09:00
jaybe af84bcbe8d Merge pull request 'CI: feature/bugfix-form-video-preview' (#11) from feature/bugfix-form-video-preview into main
CI / test (push) Failing after 42m51s
CI / pr_and_merge (push) Has been skipped
2025-11-27 15:43:04 +00:00
jaybe 55906f9d3d CI오류 수정-2
CI / test (push) Successful in 12m12s
CI / pr_and_merge (push) Successful in 1m17s
CI / test (pull_request) Successful in 52m3s
CI / pr_and_merge (pull_request) Has been skipped
2025-11-28 00:29:44 +09:00
jaybe 23bc96b3a1 Merge pull request 'CI: feature/bugfix-form-video-preview' (#10) from feature/bugfix-form-video-preview into main
CI / test (push) Failing after 44m0s
CI / pr_and_merge (push) Has been skipped
2025-11-27 11:42:26 +00:00
jaybe d4cf2f0225 CI오류 수정
CI / test (push) Successful in 12m10s
CI / pr_and_merge (push) Successful in 1m11s
CI / test (pull_request) Failing after 45m59s
CI / pr_and_merge (pull_request) Has been skipped
2025-11-27 20:29:08 +09:00
jaybe c5c9d17e8b 비디오 태그 및 폼 블록 정리
CI / test (push) Failing after 7m30s
CI / pr_and_merge (push) Has been skipped
2025-11-27 19:37:32 +09:00
jaybe 1405280ed3 merge: 16.1 Export 미리보기 UX main 반영
CI / test (push) Failing after 7m21s
CI / pr_and_merge (push) Has been skipped
2025-11-27 12:11:13 +09:00
jaybe 41e4238290 feat: 16.1 Export 미리보기 UX 추가
CI / test (push) Failing after 7m22s
CI / pr_and_merge (push) Has been skipped
2025-11-27 12:09:49 +09:00
jaybe e179035fbc Merge branch 'feature/15.2-nav-footer'
CI / pr_and_merge (push) Blocked by required conditions
CI / test (push) Has been cancelled
2025-11-27 11:39:44 +09:00
jaybe e1c91b1668 feat: 15.2 푸터/내비게이션 Export 동기화 TDD
CI / test (push) Failing after 7m19s
CI / pr_and_merge (push) Has been skipped
2025-11-27 11:39:33 +09:00
jaybe 860eff514a Merge branch 'feature/15.1-seo-head-meta' 2025-11-27 11:21:06 +09:00
201 changed files with 4970 additions and 945 deletions
+4 -23
View File
@@ -5,24 +5,17 @@ on:
branches:
- main
- feature/**
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.56.1-jammy
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: |
npm ci
@@ -39,6 +32,7 @@ jobs:
npx prisma generate
- name: Run unit tests
if: github.event_name == 'push'
env:
# API 유닛 테스트에서도 PrismaClient가 필요하므로,
# generate 단계와 동일한 더미 DATABASE_URL 을 사용한다.
@@ -48,21 +42,8 @@ jobs:
run: |
npm test
# Playwright 브라우저는 용량이 크기 때문에 캐시를 사용해 설치 시간을 단축한다.
- name: Cache Playwright browsers
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install Playwright browsers
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
run: |
npx playwright install --with-deps
- name: Run Playwright E2E tests
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
npx playwright test
+10
View File
@@ -14,6 +14,7 @@
"@prisma/client": "^6.19.0",
"dotenv": "^17.2.3",
"jszip": "^3.10.1",
"lucide-react": "^0.555.0",
"next": "^16.0.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -7089,6 +7090,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.555.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz",
"integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+1
View File
@@ -20,6 +20,7 @@
"@prisma/client": "^6.19.0",
"dotenv": "^17.2.3",
"jszip": "^3.10.1",
"lucide-react": "^0.555.0",
"next": "^16.0.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import { buildStaticHtml } from "@/app/api/export/route";
// /api/export/preview 라우트는 Export 결과 HTML 을 ZIP 없이 바로 반환하는
// 경량 프리뷰용 엔드포인트이다. 에디터에서 Export 미리보기 UX 를 제공하기 위해 사용한다.
type PreviewRequestBody = {
blocks: Block[];
projectConfig?: ProjectConfig;
};
export async function POST(request: Request) {
let body: PreviewRequestBody;
try {
// JSON 본문을 파싱해서 blocks + projectConfig 를 추출한다.
body = (await request.json()) as PreviewRequestBody;
} catch {
// 잘못된 JSON 요청에 대해서는 400 에러를 반환한다.
return NextResponse.json({ message: "잘못된 JSON 요청입니다." }, { status: 400 });
}
const blocks = Array.isArray(body.blocks) ? body.blocks : [];
const projectConfig = body.projectConfig;
// Export 본문 HTML 은 기존 buildStaticHtml 을 재사용해서 생성한다.
// 이렇게 하면 /api/export ZIP 라우트와 동일한 정적 HTML 을 얻을 수 있다.
const html = buildStaticHtml(blocks, projectConfig);
// Export 미리보기는 ZIP 이 아니라 순수 HTML 문자열을 반환한다.
return new NextResponse(html, {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
},
});
}
+99 -98
View File
@@ -160,6 +160,23 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const sectionBlocks = blocks.filter((b) => b.type === "section");
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
// Form 컨트롤러(FormBlock)와 개별 필드 블록(formInput/formSelect/...) 를 연결하기 위한 맵.
// - key: 필드 블록 id (fieldIds 에 포함된 id)
// - value: 해당 필드가 속한 <form> 요소의 DOM id (예: form_form_1)
const fieldIdToFormId = new Map<string, string>();
for (const block of blocks) {
if (block.type !== "form") continue;
const props = (block.props ?? {}) as FormBlockProps;
const fieldIds = Array.isArray(props.fieldIds) ? props.fieldIds : [];
if (fieldIds.length === 0) continue;
const formDomId = `form_${block.id}`;
for (const fieldId of fieldIds) {
if (typeof fieldId === "string" && fieldId.trim() !== "") {
fieldIdToFormId.set(fieldId, formDomId);
}
}
}
const renderBlock = (block: Block): string => {
if (block.type === "text") {
const props = (block.props ?? {}) as TextBlockProps;
@@ -316,107 +333,35 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const props = (block.props ?? {}) as FormBlockProps;
const tokens = computeFormBlockExportTokens(block, blocks);
const controllerFields = tokens.hasControllerFields ? tokens.controllerFields : [];
const fallbackFields = tokens.fallbackFields;
const hasControllerFields = controllerFields.length > 0;
const hasFallbackFields = fallbackFields.length > 0;
// FormBlock 이 컨트롤러/필드 정보를 전혀 가지고 있지 않으면 정적 HTML 에서는 아무 것도 렌더하지 않는다.
if (!hasControllerFields && !hasFallbackFields) {
// FormBlock 이 컨트롤러 필드 정보를 전혀 가지고 있지 않으면
// Export 레이어에서는 아무 것도 렌더하지 않는다 (fallback fields 기반 pb-form 도 생성하지 않음).
if (!hasControllerFields) {
return "";
}
const formParts: string[] = [];
const formStyleAttr = tokens.formStyleParts.length > 0 ? ` style="${tokens.formStyleParts.join(";")}"` : "";
// 컨트롤러 전용 FormBlock: fieldIds 기반으로 개별 필드 블록과 연결되는 순수 컨트롤러 폼만 생성한다.
// - Export 에서는 레이아웃/스타일 대신 id/메서드/액션만 가지는 최소한의 <form> 요소만 만든다.
// - action 은 퍼블릭 페이지에서 우리 서버의 forms/submit 엔드포인트로 전송하기 위한 고정 경로다.
const formId = `form_${block.id}`;
const method = props.method ?? "POST";
const methodAttr = ` method="${method.toLowerCase()}"`;
const actionAttr = ` action="/api/forms/submit"`;
formParts.push(`<form class="pb-form" method="post"${formStyleAttr}>`);
// 정적 Export 에서도 FormBlock 설정(전송 대상/웹훅 설정 등)을 함께 전달할 수 있도록
// preview 렌더러와 동일하게 __config hidden 필드에 FormBlockProps 전체를 JSON 으로 담아둔다.
const configJson = JSON.stringify(props ?? {});
const escapedConfig = escapeAttr(configJson);
if (hasControllerFields) {
for (const fieldBlock of controllerFields) {
const anyProps: any = fieldBlock.props ?? {};
const name = typeof anyProps.formFieldName === "string" ? anyProps.formFieldName : fieldBlock.id;
const label =
typeof anyProps.label === "string" || typeof anyProps.groupLabel === "string"
? (anyProps.label ?? anyProps.groupLabel)
: name;
const textColorRaw =
typeof anyProps.textColorCustom === "string" && anyProps.textColorCustom.trim() !== ""
? anyProps.textColorCustom.trim()
: "";
const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
const fieldTextStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
formParts.push(`<div class="pb-form-field">`);
formParts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label ?? "")}</label>`);
if (fieldBlock.type === "formInput") {
const type = anyProps.inputType === "email" ? "email" : "text";
const required = anyProps.required ? " required" : "";
formParts.push(
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
label ?? "",
)}"${required}${fieldTextStyleAttr} />`,
);
} else if (fieldBlock.type === "formSelect") {
const required = anyProps.required ? " required" : "";
const options: FormSelectOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
formParts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}${fieldTextStyleAttr}>`);
for (const opt of options) {
formParts.push(
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
);
}
formParts.push(`</select>`);
} else if (fieldBlock.type === "formCheckbox") {
const options = Array.isArray(anyProps.options) ? anyProps.options : [];
for (const opt of options) {
formParts.push(
`<label class="pb-form-option"${fieldTextStyleAttr}><input type="checkbox" name="${escapeAttr(
name,
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
);
}
} else if (fieldBlock.type === "formRadio") {
const options = Array.isArray(anyProps.options) ? anyProps.options : [];
for (const opt of options) {
formParts.push(
`<label class="pb-form-option"${fieldTextStyleAttr}><input type="radio" name="${escapeAttr(
name,
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
);
}
}
formParts.push(`</div>`);
}
} else if (hasFallbackFields) {
for (const field of fallbackFields) {
const required = field.required ? " required" : "";
const label = field.label ?? field.name;
formParts.push(`<div class="pb-form-field">`);
formParts.push(`<label class="pb-form-label">${escapeHtml(label)}</label>`);
if (field.type === "textarea") {
formParts.push(
`<textarea class="pb-textarea" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(
label,
)}"${required}></textarea>`,
);
} else {
formParts.push(
`<input class="pb-input" type="${field.type}" name="${escapeAttr(
field.name,
)}" placeholder="${escapeAttr(label)}"${required} />`,
);
}
formParts.push(`</div>`);
}
}
formParts.push(`<button type="submit" class="pb-btn-base pb-btn-size-md pb-btn-variant-solid-primary">제출</button>`);
formParts.push(`</form>`);
return formParts.join("");
const parts: string[] = [];
parts.push(
`<form id="${escapeAttr(formId)}" class="pb-form-controller"${methodAttr}${actionAttr}>`,
);
parts.push(`<input type="hidden" name="__config" value="${escapedConfig}" />`);
parts.push("</form>");
return parts.join("");
}
if (block.type === "divider") {
@@ -449,13 +394,15 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const required = props.required ? " required" : "";
const tokens = computeFormInputExportTokens(props, { escapeAttr });
const formId = fieldIdToFormId.get(block.id);
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
return [
'<div class="pb-form-field">',
`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`,
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
label,
)}"${required}${tokens.inputStyleAttr} />`,
)}"${required}${tokens.inputStyleAttr}${formAttr} />`,
"</div>",
].join("");
}
@@ -469,11 +416,15 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const options: FormSelectOption[] = Array.isArray(props.options) ? props.options : [];
const tokens = computeFormSelectExportTokens(props, { escapeAttr });
const formId = fieldIdToFormId.get(block.id);
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
const parts: string[] = [];
parts.push('<div class="pb-form-field">');
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
parts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}${tokens.selectStyleAttr}>`);
parts.push(
`<select class="pb-select" name="${escapeAttr(name)}"${required}${tokens.selectStyleAttr}${formAttr}>`,
);
for (const opt of options) {
parts.push(
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
@@ -495,6 +446,8 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const options = Array.isArray(props.options) ? props.options : [];
const tokens = computeFormCheckboxExportTokens(props, { escapeAttr });
const formId = fieldIdToFormId.get(block.id);
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
const parts: string[] = [];
parts.push('<div class="pb-form-field">');
@@ -503,7 +456,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
parts.push(
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="checkbox" name="${escapeAttr(
name,
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
)}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}</label>`,
);
}
parts.push("</div>");
@@ -521,6 +474,8 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const options = Array.isArray(props.options) ? props.options : [];
const tokens = computeFormRadioExportTokens(props, { escapeAttr });
const formId = fieldIdToFormId.get(block.id);
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
const parts: string[] = [];
parts.push('<div class="pb-form-field">');
@@ -529,7 +484,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
parts.push(
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="radio" name="${escapeAttr(
name,
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
)}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}</label>`,
);
}
parts.push("</div>");
@@ -674,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" });
+5 -1
View File
@@ -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(
+21 -25
View File
@@ -6,35 +6,31 @@ import { PrismaClient } from "@/generated/prisma/client";
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
let prisma: PrismaClient | null = null;
try {
prisma = new PrismaClient();
} catch (error) {
// DB 가 준비되지 않은 환경에서는 mimeType 조회 없이 기본값만 사용한다.
console.error("PrismaClient 초기화 실패 - 기본 MIME 타입으로만 응답합니다.", error);
prisma = null;
if (process.env.DATABASE_URL && process.env.DATABASE_URL.trim() !== "") {
try {
prisma = new PrismaClient();
} catch (error) {
// DB 가 준비되지 않은 환경에서는 mimeType 조회 없이 기본값만 사용한다.
console.error("PrismaClient 초기화 실패 - 기본 MIME 타입으로만 응답합니다.", error);
prisma = null;
}
}
interface Params {
params: {
id: string;
};
}
export async function GET(request: Request) {
// Next.js 15 이후 params 가 Promise 로 전달되는 경고를 피하기 위해,
// 동적 세그먼트는 request.url 의 path 에서 직접 파싱한다.
let id: string | undefined;
export async function GET(request: Request, ctx: Params) {
// Next.js 가 params 를 항상 보장하지 않는 상황을 대비해, URL 경로에서 id 를 보완적으로 파싱한다.
let id: string | undefined = ctx?.params?.id;
if (!id) {
try {
const url = new URL(request.url);
const segments = url.pathname.split("/").filter(Boolean);
// [..., "api", "image", ":id"] 형태를 기대한다.
const last = segments[segments.length - 1];
if (last && last !== "image") {
id = last;
}
} catch {
// URL 파싱 실패 시에는 그대로 둔다.
try {
const url = new URL(request.url);
const segments = url.pathname.split("/").filter(Boolean);
// [..., "api", "image", ":id"] 형태를 기대한다.
const last = segments[segments.length - 1];
if (last && last !== "image") {
id = last;
}
} catch {
// URL 파싱 실패 시에는 그대로 둔다.
}
if (!id) {
+9 -7
View File
@@ -8,13 +8,15 @@ import { PrismaClient } from "@/generated/prisma/client";
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
let prisma: PrismaClient | null = null;
try {
prisma = new PrismaClient();
} catch (error) {
// DB 가 준비되지 않은 개발/테스트 환경에서도 업로드 자체는 동작할 수 있도록,
// Prisma 초기화 실패는 치명적 오류로 취급하지 않고 로그만 남긴다.
console.error("PrismaClient 초기화 실패 - 파일 시스템 전용 모드로 동작합니다.", error);
prisma = null;
if (process.env.DATABASE_URL && process.env.DATABASE_URL.trim() !== "") {
try {
prisma = new PrismaClient();
} catch (error) {
// DB 가 준비되지 않은 개발/테스트 환경에서도 업로드 자체는 동작할 수 있도록,
// Prisma 초기화 실패는 치명적 오류로 취급하지 않고 로그만 남긴다.
console.error("PrismaClient 초기화 실패 - 파일 시스템 전용 모드로 동작합니다.", error);
prisma = null;
}
}
export async function POST(request: Request) {
+27 -8
View File
@@ -3,14 +3,8 @@ import { PrismaClient } from "@/generated/prisma/client";
const prisma = new PrismaClient();
interface Params {
params: {
slug: string;
};
}
export async function GET(_request: Request, { params }: Params) {
const { slug } = params;
export async function GET(_request: Request, context: { params: { slug: string } | Promise<{ slug: string }> }) {
const { slug } = await context.params;
const project = await prisma.project.findUnique({
where: { slug },
@@ -22,3 +16,28 @@ export async function GET(_request: Request, { params }: Params) {
return NextResponse.json(project, { status: 200 });
}
export async function DELETE(
_request: Request,
context: { params: { slug: string } | Promise<{ slug: string }> },
) {
const { slug } = await context.params;
try {
await prisma.project.delete({
where: { slug },
});
return NextResponse.json({ message: "프로젝트가 삭제되었습니다." }, { status: 200 });
} catch (error: any) {
// Prisma NotFound 에러 코드(P2025)가 떨어지는 경우 404 로 매핑한다.
if (error?.code === "P2025") {
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
}
return NextResponse.json(
{ message: "프로젝트 삭제 중 오류가 발생했습니다.", error: error?.message },
{ status: 500 },
);
}
}
+31 -2
View File
@@ -3,6 +3,30 @@ import { PrismaClient } from "@/generated/prisma/client";
const prisma = new PrismaClient();
export async function GET(_request: Request) {
try {
const projects = await prisma.project.findMany({
orderBy: { createdAt: "desc" },
take: 50,
select: {
id: true,
title: true,
slug: true,
status: true,
createdAt: true,
updatedAt: true,
},
});
return NextResponse.json(projects, { status: 200 });
} catch (error: any) {
return NextResponse.json(
{ message: "프로젝트 목록을 가져오는 중 오류가 발생했습니다.", error: error?.message },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
const body = await request.json();
const { title, slug, contentJson } = body;
@@ -12,8 +36,13 @@ export async function POST(request: Request) {
}
try {
const project = await prisma.project.create({
data: {
const project = await prisma.project.upsert({
where: { slug },
update: {
title,
contentJson,
},
create: {
title,
slug,
contentJson,
+1 -1
View File
@@ -86,7 +86,7 @@ export function EditorCanvas(props: EditorCanvasProps) {
>
{blocks.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-slate-500 text-xs">
"텍스트 블록 추가" .
"텍스트" .
</div>
) : (
<DndContext
@@ -1,6 +1,7 @@
"use client";
import type { Block, ButtonBlockProps, FormBlockProps } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
interface FormControllerPanelProps {
block: Block; // type === "form"
@@ -129,6 +130,65 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
)}
</div>
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
<h3 className="text-[11px] font-semibold text-slate-200"> </h3>
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={formProps.formWidthMode ?? "auto"}
onChange={(e) =>
updateBlock(selectedBlockId, {
formWidthMode: e.target.value as FormBlockProps["formWidthMode"],
} as any)
}
>
<option value="auto"></option>
<option value="full"> </option>
<option value="fixed"> </option>
</select>
</label>
{(formProps.formWidthMode ?? "auto") === "fixed" && (
<NumericPropertyControl
label="폼 고정 너비 (px)"
unitLabel="(px)"
value={typeof formProps.formWidthPx === "number" ? formProps.formWidthPx : 480}
min={160}
max={1200}
step={10}
presets={[
{ id: "sm", label: "좁게", value: 320 },
{ id: "md", label: "보통", value: 480 },
{ id: "lg", label: "넓게", value: 640 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
formWidthPx: v,
} as any)
}
/>
)}
<NumericPropertyControl
label="폼 위/아래 여백 (px)"
unitLabel="(px)"
value={typeof formProps.marginYPx === "number" ? formProps.marginYPx : 24}
min={0}
max={160}
step={4}
presets={[
{ id: "none", label: "없음", value: 0 },
{ id: "compact", label: "좁게", value: 16 },
{ id: "normal", label: "보통", value: 24 },
{ id: "spacious", label: "넓게", value: 40 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
marginYPx: v,
} as any)
}
/>
</div>
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
<h3 className="text-[11px] font-semibold text-slate-200"> </h3>
<label className="flex flex-col gap-1 text-xs text-slate-400">
+192 -39
View File
@@ -3,6 +3,7 @@
import type { CSSProperties, ReactNode } from "react";
import { Fragment, useEffect, useState } from "react";
import Link from "next/link";
import { FilePlus2, Trash2, Eye, Pencil, ListChecks, Undo2, Redo2 } from "lucide-react";
import {
DndContext,
PointerSensor,
@@ -117,6 +118,7 @@ export default function EditorPage() {
const projectConfig = useEditorStore(
(state) => (state as any).projectConfig as ProjectConfig,
);
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
@@ -127,13 +129,15 @@ export default function EditorPage() {
const [exportJson, setExportJson] = useState("");
const [importJson, setImportJson] = useState("");
const [projectTitle, setProjectTitle] = useState("");
const [projectSlug, setProjectSlug] = useState("");
const [loadSlug, setLoadSlug] = useState("");
const [projectMessage, setProjectMessage] = useState("");
// 멀티 선택(MVP)을 위한 로컬 선택 상태: 단일 선택(selectedBlockId)과 병행 사용한다.
const [selectedBlockIds, setSelectedBlockIds] = useState<string[]>([]);
const 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 [activeModal, setActiveModal] = useState<"project" | "json" | null>(null);
@@ -206,7 +210,11 @@ export default function EditorPage() {
const handleExportJson = () => {
try {
const payload = JSON.stringify(blocks, null, 2);
const snapshot = {
blocks,
projectConfig,
};
const payload = JSON.stringify(snapshot, null, 2);
setExportJson(payload);
setImportJson(payload);
} catch {
@@ -223,31 +231,42 @@ export default function EditorPage() {
const handleApplyImportJson = () => {
if (!importJson.trim()) return;
try {
const parsed = JSON.parse(importJson);
if (!Array.isArray(parsed)) return;
// 최소한의 구조 검증: id, type, props.text 가 있는 블록만 사용
const safeBlocks = parsed.filter((b) => b && typeof b.id === "string" && b.type === "text" && b.props && typeof b.props.text === "string");
replaceBlocks(safeBlocks);
// 내보내기 영역도 최신 상태로 업데이트
setExportJson(JSON.stringify(safeBlocks, null, 2));
const parsed = JSON.parse(importJson) as {
blocks?: Block[];
projectConfig?: ProjectConfig;
};
if (!parsed || typeof parsed !== "object") return;
if (!Array.isArray(parsed.blocks)) return;
replaceBlocks(parsed.blocks as any);
if (parsed.projectConfig && typeof parsed.projectConfig === "object") {
updateProjectConfig(parsed.projectConfig as ProjectConfig);
}
setExportJson(JSON.stringify(parsed, null, 2));
} catch {
// 파싱 에러는 무시 (추후에는 토스트 등으로 피드백 가능)
}
};
const handleSaveProject = async () => {
const slug = projectSlug.trim();
const slug = (projectConfig.slug ?? "").trim();
if (!slug) {
setProjectMessage("프로젝트 주소를 입력해 주세요.");
return;
}
const title = (projectConfig.title ?? "").trim() || "제목 없음";
try {
// 1) 로컬스토리지에 현재 상태 저장 (슬러그 기준)
if (typeof window !== "undefined") {
const localKey = `pb:project:${slug}`;
const payload = {
title: projectTitle.trim() || "제목 없음",
title,
contentJson: blocks,
};
window.localStorage.setItem(localKey, JSON.stringify(payload));
@@ -260,7 +279,7 @@ export default function EditorPage() {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: projectTitle.trim() || "제목 없음",
title,
slug,
contentJson: blocks,
}),
@@ -273,6 +292,10 @@ export default function EditorPage() {
const data = await response.json();
setProjectMessage(`프로젝트가 저장되었습니다: ${data.slug}`);
if (typeof window !== "undefined") {
window.location.href = "/projects";
}
} catch (error) {
console.error(error);
setProjectMessage("프로젝트 저장 중 오류가 발생했습니다.");
@@ -280,7 +303,7 @@ export default function EditorPage() {
};
const handleLoadProject = async () => {
const slug = loadSlug.trim() || projectSlug.trim();
const slug = (projectConfig.slug ?? "").trim();
if (!slug) {
setProjectMessage("불러올 주소를 입력해 주세요.");
return;
@@ -325,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 { active } = event;
setActiveDragId(String(active.id));
@@ -491,6 +551,48 @@ export default function EditorPage() {
const rootBlocks = blocks.filter((block) => !block.sectionId);
useEffect(() => {
if (typeof window === "undefined") return;
const slug = projectConfig?.slug?.trim();
if (!slug) return;
const key = `pb:autosave:${slug}`;
const raw = window.localStorage.getItem(key);
if (!raw) return;
try {
const parsed = JSON.parse(raw) as { blocks?: Block[]; projectConfig?: ProjectConfig };
if (Array.isArray(parsed.blocks)) {
replaceBlocks(parsed.blocks as any);
}
if (parsed.projectConfig) {
updateProjectConfig(parsed.projectConfig as ProjectConfig);
}
} catch {
return;
}
}, [projectConfig?.slug, replaceBlocks, updateProjectConfig]);
useEffect(() => {
if (typeof window === "undefined") return;
const slug = projectConfig?.slug?.trim();
if (!slug) return;
const key = `pb:autosave:${slug}`;
const payload = {
blocks,
projectConfig,
};
try {
window.localStorage.setItem(key, JSON.stringify(payload));
} catch {
return;
}
}, [blocks, projectConfig]);
// 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z),
// Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제,
// ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다.
@@ -642,25 +744,57 @@ export default function EditorPage() {
return (
<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">
<div className="flex items-baseline gap-3">
<h1 className="text-xl font-semibold">Page Editor</h1>
<p className="text-xs text-slate-400"> MVP용 </p>
<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-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>
<p className="text-xs text-slate-400"> MVP용 </p>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<Link
href="/preview"
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
href="/projects"
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
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">
<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"
onClick={() => setMenuOpen((prev) => !prev)}
>
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span></span>
<span className="text-[9px]"></span>
</button>
{menuOpen && (
@@ -673,7 +807,10 @@ export default function EditorPage() {
setActiveModal("project");
}}
>
/
<span className="inline-flex items-center gap-2">
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span> /</span>
</span>
</button>
<button
type="button"
@@ -683,7 +820,23 @@ export default function EditorPage() {
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
type="button"
className="w-full px-3 py-2 text-left hover:bg-red-900/60 text-red-100 border-t border-slate-800"
onClick={() => {
setMenuOpen(false);
void handleDeleteProject();
}}
>
<span className="inline-flex items-center gap-2">
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</span>
</button>
<button
type="button"
@@ -693,7 +846,10 @@ export default function EditorPage() {
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
type="button"
@@ -705,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>
</div>
)}
@@ -763,16 +922,16 @@ export default function EditorPage() {
<span className="text-slate-400"> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={projectTitle}
onChange={(e) => setProjectTitle(e.target.value)}
value={projectConfig.title ?? ""}
onChange={(e) => updateProjectConfig({ title: e.target.value })}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> (: my-landing)</span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={projectSlug}
onChange={(e) => setProjectSlug(e.target.value)}
value={projectConfig.slug ?? ""}
onChange={(e) => updateProjectConfig({ slug: e.target.value })}
/>
</label>
<div className="flex gap-2">
@@ -791,14 +950,6 @@ export default function EditorPage() {
</button>
</div>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> ( )</span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={loadSlug}
onChange={(e) => setLoadSlug(e.target.value)}
/>
</label>
{projectMessage && (
<p className="text-[11px] text-slate-300 mt-1">{projectMessage}</p>
)}
@@ -807,6 +958,8 @@ export default function EditorPage() {
</div>
)}
{/* Export 미리보기 기능은 제거되었습니다. */}
{activeModal === "json" && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
<div className="w-full max-w-2xl rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
+463 -359
View File
@@ -1,7 +1,23 @@
"use client";
import { useCallback } from "react";
import { useCallback, useState } from "react";
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() {
@@ -69,377 +85,465 @@ export function BlocksSidebar() {
[addFooterTemplateSection],
);
const [isBlocksOpen, setIsBlocksOpen] = useState(true);
const [isFormsOpen, setIsFormsOpen] = useState(true);
const [isTemplatesOpen, setIsTemplatesOpen] = useState(true);
return (
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3 overflow-y-auto pb-scroll">
<h2 className="font-medium"></h2>
<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"
onClick={handleAddText}
>
</button>
<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"
onClick={handleAddButton}
>
</button>
<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"
onClick={handleAddImage}
>
</button>
<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"
onClick={handleAddVideo}
>
</button>
<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"
onClick={handleAddDivider}
>
</button>
<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"
onClick={handleAddList}
>
</button>
<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"
onClick={handleAddSection}
>
</button>
<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 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
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"
onClick={handleAddText}
>
<span className="inline-flex items-center gap-2">
<Type className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</button>
<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"
onClick={handleAddButton}
>
<span className="inline-flex items-center gap-2">
<MousePointerClick className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</button>
<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"
onClick={handleAddImage}
>
<span className="inline-flex items-center gap-2">
<ImageIcon className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</button>
<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"
onClick={handleAddVideo}
>
<span className="inline-flex items-center gap-2">
<Video className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</button>
<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"
onClick={handleAddDivider}
>
<span className="inline-flex items-center gap-2">
<Minus className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</button>
<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"
onClick={handleAddList}
>
<span className="inline-flex items-center gap-2">
<List className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</button>
<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"
onClick={handleAddSection}
>
<span className="inline-flex items-center gap-2">
<LayoutTemplate className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</button>
</>
)}
<div className="pt-3 border-t border-slate-800 mt-3 space-y-2">
<h3 className="text-[11px] font-medium text-slate-300"> </h3>
<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"
onClick={handleAddFormBlock}
>
</button>
<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"
onClick={handleAddFormInput}
>
</button>
<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"
onClick={handleAddFormSelect}
>
</button>
<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"
onClick={handleAddFormRadio}
>
</button>
<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"
onClick={handleAddFormCheckbox}
>
</button>
<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
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"
onClick={handleAddFormBlock}
>
<span className="inline-flex items-center gap-2">
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</span>
</button>
<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"
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
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"
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
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"
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
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"
onClick={handleAddFormCheckbox}
>
<span className="inline-flex items-center gap-2">
<SquareCheck className="w-3 h-3" aria-hidden="true" />
<span> (Checkbox)</span>
</span>
</button>
</>
)}
</div>
<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>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400"> · CTA</p>
<div className="space-y-2">
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
Hero 릿
</button>
<div
data-testid="template-preview-hero"
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex h-full w-full flex-col items-center justify-center gap-[2px]">
<div className="h-[2px] w-3/4 bg-slate-700/70" />
<div className="h-[2px] w-1/2 bg-slate-700/50" />
<div className="h-2 w-4 rounded-[1px] bg-slate-700/70 mt-[1px]" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">
Hero ( + + )
</p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
CTA 릿
</button>
<div
data-testid="template-preview-cta"
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex h-full w-full items-center gap-[2px]">
<div className="flex-1 flex flex-col justify-center gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-2/3 bg-slate-700/50" />
{isTemplatesOpen && (
<div className="space-y-3">
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400"> · CTA</p>
<div className="space-y-2">
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
Hero 릿
</button>
<div
data-testid="template-preview-hero"
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex h-full w-full flex-col items-center justify-center gap-[2px]">
<div className="h-[2px] w-3/4 bg-slate-700/70" />
<div className="h-[2px] w-1/2 bg-slate-700/50" />
<div className="h-2 w-4 rounded-[1px] bg-slate-700/70 mt-[1px]" />
</div>
</div>
<div className="w-3 h-2 rounded-[1px] bg-slate-700/70" />
</div>
<p className="text-[10px] text-slate-400 leading-snug">
Hero ( + + )
</p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
CTA 릿
</button>
<div
data-testid="template-preview-cta"
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex h-full w-full items-center gap-[2px]">
<div className="flex-1 flex flex-col justify-center gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-2/3 bg-slate-700/50" />
</div>
<div className="w-3 h-2 rounded-[1px] bg-slate-700/70" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">(CTA) </p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400"> </p>
<div className="space-y-2">
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
릿
</button>
<div
data-testid="template-preview-features"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-full bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-full bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-full bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">3 </p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
FAQ 릿
</button>
<div
data-testid="template-preview-faq"
className="shrink-0 flex h-6 w-10 items-start gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="w-2 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
</div>
<div className="flex-1 flex flex-col gap-[2px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-full bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> (FAQ) </p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
릿
</button>
<div
data-testid="template-preview-pricing"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
<div className="h-[1px] w-1/2 bg-slate-700/70" />
<div className="h-[1px] w-3/4 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-sky-700/50 bg-sky-900/20 rounded-[1px]">
<div className="h-[1px] w-1/2 bg-sky-500/70" />
<div className="h-[1px] w-3/4 bg-sky-500/50" />
<div className="h-[2px] w-3/4 bg-sky-500/70 mt-[1px] rounded-[1px]" />
</div>
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
<div className="h-[1px] w-1/2 bg-slate-700/70" />
<div className="h-[1px] w-3/4 bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">/ </p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
릿
</button>
<div
data-testid="template-preview-blog"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400">/</p>
<div className="space-y-2">
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
릿
</button>
<div
data-testid="template-preview-testimonials"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-1/2 bg-slate-700/70" />
</div>
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-1/2 bg-slate-700/70" />
</div>
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-1/2 bg-slate-700/70" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> (Testimonials) </p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
Team 릿
</button>
<div
data-testid="template-preview-team"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col items-center gap-[1px]">
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col items-center gap-[1px]">
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col items-center gap-[1px]">
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400">/</p>
<div className="space-y-2">
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
Footer 릿
</button>
<div
data-testid="template-preview-footer"
className="shrink-0 flex h-6 w-10 items-center justify-between gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 h-[2px] bg-slate-700/70" />
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-full bg-slate-700/50" />
</div>
<div className="flex-1 h-[1px] bg-slate-700/40" />
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">(CTA) </p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400"> </p>
<div className="space-y-2">
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
Features 릿
</button>
<div
data-testid="template-preview-features"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-full bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-full bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-full bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">3 </p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
FAQ 릿
</button>
<div
data-testid="template-preview-faq"
className="shrink-0 flex h-6 w-10 items-start gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="w-2 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
</div>
<div className="flex-1 flex flex-col gap-[2px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-full bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> (FAQ) </p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
Pricing 릿
</button>
<div
data-testid="template-preview-pricing"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
<div className="h-[1px] w-1/2 bg-slate-700/70" />
<div className="h-[1px] w-3/4 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-sky-700/50 bg-sky-900/20 rounded-[1px]">
<div className="h-[1px] w-1/2 bg-sky-500/70" />
<div className="h-[1px] w-3/4 bg-sky-500/50" />
<div className="h-[2px] w-3/4 bg-sky-500/70 mt-[1px] rounded-[1px]" />
</div>
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
<div className="h-[1px] w-1/2 bg-slate-700/70" />
<div className="h-[1px] w-3/4 bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">/ </p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
Blog 릿
</button>
<div
data-testid="template-preview-blog"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400">/</p>
<div className="space-y-2">
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
Testimonials 릿
</button>
<div
data-testid="template-preview-testimonials"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-1/2 bg-slate-700/70" />
</div>
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-1/2 bg-slate-700/70" />
</div>
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-1/2 bg-slate-700/70" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> (Testimonials) </p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
Team 릿
</button>
<div
data-testid="template-preview-team"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col items-center gap-[1px]">
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col items-center gap-[1px]">
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col items-center gap-[1px]">
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400">/</p>
<div className="space-y-2">
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
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}
>
Footer 릿
</button>
<div
data-testid="template-preview-footer"
className="shrink-0 flex h-6 w-10 items-center justify-between gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 h-[2px] bg-slate-700/70" />
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-full bg-slate-700/50" />
</div>
<div className="flex-1 h-[1px] bg-slate-700/40" />
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</div>
</div>
</div>
)}
</div>
</aside>
);
+673 -4
View File
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import type { Block, ButtonBlockProps, FormBlockProps, ListBlockProps, SectionBlockProps, TextBlockProps, VideoBlockProps } from "@/features/editor/state/editorStore";
import { ButtonPropertiesPanel } from "./ButtonPropertiesPanel";
import { TextPropertiesPanel } from "./TextPropertiesPanel";
@@ -14,6 +15,18 @@ import { FormCheckboxPropertiesPanel } from "../forms/FormCheckboxPropertiesPane
import { FormRadioPropertiesPanel } from "../forms/FormRadioPropertiesPanel";
import { FormControllerPanel } from "../forms/FormControllerPanel";
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 {
blocks: Block[];
@@ -48,16 +61,621 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
onOutdentSelectedItem,
} = 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 (
<aside
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) => {
// 속성 패널 안에서 발생한 키 입력은 에디터 단축키/텍스트 블록 편집으로 전달되지 않도록 막는다.
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 ? (
<div className="space-y-3">
<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"
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
type="button"
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)}
>
<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>
</div>
{(() => {
@@ -224,6 +858,41 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
) : (
<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>
);
}
+84 -6
View File
@@ -1,6 +1,7 @@
"use client";
import type { CSSProperties } from "react";
import { useEffect } from "react";
import Link from "next/link";
import { useEditorStore } from "@/features/editor/state/editorStore";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
@@ -8,6 +9,31 @@ import { PublicPageRenderer } from "@/features/editor/components/PublicPageRende
export default function PreviewPage() {
const blocks = useEditorStore((state) => state.blocks);
const projectConfig = useEditorStore((state) => (state as any).projectConfig);
const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
useEffect(() => {
if (typeof window === "undefined") return;
const slug = (projectConfig as any)?.slug?.trim?.();
if (!slug) return;
const key = `pb:autosave:${slug}`;
const raw = window.localStorage.getItem(key);
if (!raw) return;
try {
const parsed = JSON.parse(raw) as { blocks?: any[]; projectConfig?: any };
if (Array.isArray(parsed.blocks)) {
replaceBlocks(parsed.blocks as any);
}
if (parsed.projectConfig) {
updateProjectConfig(parsed.projectConfig as any);
}
} catch {
return;
}
}, [projectConfig?.slug, replaceBlocks, updateProjectConfig]);
const canvasStyle: CSSProperties = {};
const preset = projectConfig?.canvasPreset ?? "full";
@@ -17,6 +43,41 @@ export default function PreviewPage() {
mainStyle.backgroundColor = projectConfig.bodyBgColorHex;
}
const handleExportZip = async () => {
try {
const payload = {
blocks,
projectConfig,
};
const response = await fetch("/api/export", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const slug = (projectConfig?.slug ?? "").trim() || "page-builder-export";
const link = document.createElement("a");
link.href = url;
link.download = `${slug}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error("프리뷰 ZIP 내보내기 중 오류", error);
}
};
const widthPx =
typeof projectConfig?.canvasWidthPx === "number" && projectConfig.canvasWidthPx > 0
? projectConfig.canvasWidthPx
@@ -43,12 +104,29 @@ export default function PreviewPage() {
<h1 className="text-xl font-semibold">Page Preview</h1>
<p className="text-xs text-slate-400"> </p>
</div>
<Link
href="/editor"
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-xs text-slate-100 hover:bg-slate-800"
>
</Link>
<div className="flex items-center gap-2 text-xs">
<button
type="button"
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
onClick={() => {
void handleExportZip();
}}
>
(ZIP)
</button>
<Link
href="/projects"
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-xs text-slate-100 hover:bg-slate-800"
>
</Link>
<Link
href="/editor"
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-xs text-slate-100 hover:bg-slate-800"
>
</Link>
</div>
</header>
{/* 에디터 크롬 없이 순수 페이지 형태로 블록들을 렌더링하되, projectConfig 에 따라 최대 폭을 제한한다. */}
<section className="flex flex-1 overflow-auto">
+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>
);
}
@@ -26,7 +26,7 @@ import {
buildVideoEmbedUrl,
computeVideoPublicTokens,
} from "@/features/editor/utils/videoHelpers";
import { computeButtonPublicTokens } from "@/features/editor/utils/buttonHelpers";
import { computeButtonPublicTokens, computeButtonPbTokens } from "@/features/editor/utils/buttonHelpers";
import { computeTextPublicTokens } from "@/features/editor/utils/textHelpers";
import { computeDividerPublicTokens } from "@/features/editor/utils/dividerHelpers";
import { computeImagePublicTokens } from "@/features/editor/utils/imageHelpers";
@@ -50,8 +50,12 @@ export function getSectionLayoutConfig(props: SectionBlockProps) {
? "bg-sky-900"
: "bg-slate-950";
// 섹션 수직 패딩: builder.css 의 pb-section-py-sm/md/lg (1rem/1.5rem/2.5rem)에 대응
// - sm → 1rem → py-4
// - md → 1.5rem → py-6
// - lg → 2.5rem → py-10
const paddingYClass =
props.paddingY === "sm" ? "py-8" : props.paddingY === "lg" ? "py-20" : "py-12";
props.paddingY === "sm" ? "py-4" : props.paddingY === "lg" ? "py-10" : "py-6";
const maxWidthClass =
props.maxWidthMode === "narrow"
@@ -103,6 +107,10 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const [formMessage, setFormMessage] = useState<string>("");
const renderBlock = (block: Block) => {
if ((block as any).type === "form") {
return null;
}
if (block.type === "text") {
const props = block.props as TextBlockProps;
const tokens = computeTextPublicTokens(props);
@@ -211,6 +219,7 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
{props.options.map((opt) => (
<label
key={opt.value}
data-testid="preview-form-checkbox-option-container"
className="inline-flex items-center gap-1"
style={tokens.optionContainerStyle}
>
@@ -297,16 +306,40 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
if (block.type === "button") {
const props = block.props as ButtonBlockProps;
const tokens = computeButtonPublicTokens(props);
const buttonStyle: CSSProperties = { ...tokens.styleOverrides };
const pbTokens = computeButtonPbTokens({
align: props.align ?? "left",
size: props.size ?? "md",
variant: props.variant ?? "solid",
colorPalette: props.colorPalette ?? "primary",
borderRadius: props.borderRadius ?? "md",
fullWidth: props.fullWidth ?? false,
widthMode: props.widthMode ?? undefined,
widthPx: props.widthPx ?? undefined,
paddingX: props.paddingX ?? undefined,
paddingY: props.paddingY ?? undefined,
fillColorCustom: props.fillColorCustom ?? undefined,
strokeColorCustom: props.strokeColorCustom ?? undefined,
textColorCustom: props.textColorCustom ?? undefined,
});
const publicTokens = computeButtonPublicTokens(props);
const buttonStyle: CSSProperties = { ...publicTokens.styleOverrides };
const wrapperClass = pbTokens.alignClass;
const buttonClassName = [
"pb-btn-base",
pbTokens.sizeClass,
pbTokens.variantClass,
pbTokens.radiusClass,
publicTokens.widthClass,
]
.filter(Boolean)
.join(" ");
return (
<div key={block.id} className={tokens.alignClass}>
<a
href={props.href}
className={`inline-flex items-center justify-center text-sm font-medium hover:bg-sky-500 transition-colors ${tokens.widthClass}`}
style={buttonStyle}
>
<div key={block.id} className={wrapperClass}>
<a href={props.href} className={buttonClassName} style={buttonStyle}>
<span className="whitespace-pre-wrap">{props.label}</span>
</a>
</div>
@@ -321,13 +354,13 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
<div
key={block.id}
data-testid="preview-divider"
className={`flex ${tokens.alignClass}`}
className={`flex w-full ${tokens.alignClass}`}
style={{ marginTop: tokens.wrapperMarginEm, marginBottom: tokens.wrapperMarginEm }}
>
<div style={tokens.innerStyle}>
<div className={tokens.innerWidthClass} style={tokens.innerStyle}>
<div
data-testid="preview-divider-line"
className={`${tokens.thicknessClass} ${tokens.innerWidthClass}`}
className={tokens.thicknessClass}
style={tokens.lineStyle}
/>
</div>
@@ -362,26 +395,19 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
return (
<ListTag
className={tokens.alignClass}
className={`pb-list ${tokens.alignClass}`}
style={{
...tokens.listStyle,
listStyleType:
tokens.bulletStyle === "none"
? "none"
: tokens.bulletStyle,
paddingLeft: 12 + level * 8,
}}
>
{nodes.map((node, index) => (
<li
key={node.id}
style={
index < nodes.length - 1
? {
marginBottom: `${tokens.gapEm}em`,
}
: undefined
}
style={index < nodes.length - 1 ? { marginBottom: `${tokens.gapEm}em` } : undefined}
>
{node.text}
{node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)}
@@ -398,6 +424,10 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const props = block.props as VideoBlockProps;
const rawUrl = normalizeVideoSourceUrl(props.sourceUrl ?? "");
if (!rawUrl) {
return null;
}
const platform = resolveVideoPlatform(rawUrl, props.platform ?? "auto");
const embedUrl = buildVideoEmbedUrl(rawUrl, platform, { enableVimeoEmbed: true });
+1 -1
View File
@@ -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 {
+7 -5
View File
@@ -64,17 +64,19 @@ export function computeButtonPbTokens(input: ButtonStyleInput): ButtonPbTokens {
const widthMode = input.widthMode ?? (input.fullWidth ? "full" : "auto");
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) {
inlineStyles.push(`width:${input.widthPx}px`);
inlineStyles.push(`width:${pxToEm(input.widthPx)}`);
}
if (typeof input.paddingX === "number") {
inlineStyles.push(`padding-left:${input.paddingX}px`);
inlineStyles.push(`padding-right:${input.paddingX}px`);
const paddingInline = pxToEm(input.paddingX);
inlineStyles.push(`padding-left:${paddingInline}`);
inlineStyles.push(`padding-right:${paddingInline}`);
}
if (typeof input.paddingY === "number") {
inlineStyles.push(`padding-top:${input.paddingY}px`);
inlineStyles.push(`padding-bottom:${input.paddingY}px`);
const paddingBlock = pxToEm(input.paddingY);
inlineStyles.push(`padding-top:${paddingBlock}`);
inlineStyles.push(`padding-bottom:${paddingBlock}`);
}
if (input.fillColorCustom && input.fillColorCustom.trim() !== "") {
+2 -1
View File
@@ -19,8 +19,9 @@ export const computeDividerExportTokens = (
const colorRaw = typeof props.colorHex === "string" ? props.colorHex.trim() : "";
const color = colorRaw !== "" ? colorRaw : "#475569";
const marginY = typeof props.marginYPx === "number" && props.marginYPx > 0 ? props.marginYPx : 16;
const marginEm = pxToEm(marginY);
const style = `border:0;border-bottom:${thickness} solid ${escapeAttr(color)};margin:${marginY}px 0;`;
const style = `border:0;border-bottom:${thickness} solid ${escapeAttr(color)};margin:${marginEm} 0;`;
return { style };
};
+84 -20
View File
@@ -84,20 +84,21 @@ export const computeFormInputExportTokens = (
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
const px = props.paddingX;
inputStyleParts.push(`padding-left:${px}px`);
inputStyleParts.push(`padding-right:${px}px`);
const em = props.paddingX / 16;
inputStyleParts.push(`padding-left:${em}em`);
inputStyleParts.push(`padding-right:${em}em`);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
const py = props.paddingY;
inputStyleParts.push(`padding-top:${py}px`);
inputStyleParts.push(`padding-bottom:${py}px`);
const em = props.paddingY / 16;
inputStyleParts.push(`padding-top:${em}em`);
inputStyleParts.push(`padding-bottom:${em}em`);
}
const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth });
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
inputStyleParts.push(`width:${props.widthPx}px`);
const em = props.widthPx / 16;
inputStyleParts.push(`width:${em}em`);
}
const radiusPx = computeRadiusPx(props.borderRadius);
@@ -127,19 +128,20 @@ export const computeFormSelectExportTokens = (
const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth });
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
selectStyleParts.push(`width:${props.widthPx}px`);
const em = props.widthPx / 16;
selectStyleParts.push(`width:${em}em`);
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
const px = props.paddingX;
selectStyleParts.push(`padding-left:${px}px`);
selectStyleParts.push(`padding-right:${px}px`);
const em = props.paddingX / 16;
selectStyleParts.push(`padding-left:${em}em`);
selectStyleParts.push(`padding-right:${em}em`);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
const py = props.paddingY;
selectStyleParts.push(`padding-top:${py}px`);
selectStyleParts.push(`padding-bottom:${py}px`);
const em = props.paddingY / 16;
selectStyleParts.push(`padding-top:${em}em`);
selectStyleParts.push(`padding-bottom:${em}em`);
}
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
@@ -176,15 +178,15 @@ const computeOptionGroupExportTokensBase = (
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
const px = props.paddingX;
optionStyleParts.push(`padding-left:${px}px`);
optionStyleParts.push(`padding-right:${px}px`);
const em = props.paddingX / 16;
optionStyleParts.push(`padding-left:${em}em`);
optionStyleParts.push(`padding-right:${em}em`);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
const py = props.paddingY;
optionStyleParts.push(`padding-top:${py}px`);
optionStyleParts.push(`padding-bottom:${py}px`);
const em = props.paddingY / 16;
optionStyleParts.push(`padding-top:${em}em`);
optionStyleParts.push(`padding-bottom:${em}em`);
}
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
@@ -261,6 +263,27 @@ export const computeFormInputEditorTokens = (props: FormInputBlockProps): FormIn
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 isInline = labelLayout === "inline";
@@ -322,6 +345,27 @@ export const computeFormSelectEditorTokens = (props: FormSelectBlockProps): Form
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 };
};
@@ -354,6 +398,26 @@ export const computeFormOptionGroupEditorTokens = (
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 };
};
+2 -1
View File
@@ -27,7 +27,8 @@ export function computeImageExportStyles(input: ImageExportStyleInput): ImageExp
const widthMode: ImageWidthMode = input.widthMode ?? "auto";
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) {
imgStyleParts.push(`width:${input.widthPx}px`);
const widthEm = input.widthPx / 16;
imgStyleParts.push(`width:${widthEm}em`);
}
const radiusToken: ImageBorderRadiusToken = input.borderRadius ?? "md";
+9 -1
View File
@@ -134,7 +134,15 @@ export const computeListPublicTokens = (props: ListBlockProps): ListPublicTokens
}
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
listStyle.lineHeight = props.lineHeightCustom;
const lineHeightValue = props.lineHeightCustom.trim();
if (lineHeightValue.endsWith("px")) {
const lineHeightEm = convertPxStringToEm(lineHeightValue);
if (lineHeightEm) {
listStyle.lineHeight = lineHeightEm;
}
} else {
listStyle.lineHeight = lineHeightValue;
}
}
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
listStyle.color = props.textColorCustom;
+44 -1
View File
@@ -276,6 +276,17 @@ export interface TextPublicTokens {
styleOverrides: CSSProperties;
}
const pxToEm = (px: number, base = 16) => `${px / base}em`;
const convertPxStringToEm = (value?: string | null) => {
if (!value) return null;
const match = value.trim().match(/-?\d+(?:\.\d+)?/);
if (!match) return null;
const px = parseFloat(match[0]);
if (!Number.isFinite(px)) return null;
return pxToEm(px);
};
export function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens {
const alignClass =
props.align === "center"
@@ -308,7 +319,15 @@ export function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens
props.fontSizeCustom &&
props.fontSizeCustom.trim() !== ""
) {
styleOverrides.fontSize = props.fontSizeCustom;
const fontSizeValue = props.fontSizeCustom.trim();
if (fontSizeValue.endsWith("px")) {
const fontSizeEm = convertPxStringToEm(fontSizeValue);
if (fontSizeEm) {
styleOverrides.fontSize = fontSizeEm;
}
} else {
styleOverrides.fontSize = fontSizeValue;
}
}
if (props.colorCustom && props.colorCustom.trim() !== "") {
@@ -319,6 +338,30 @@ export function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens
styleOverrides.backgroundColor = props.backgroundColorCustom.trim();
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
const lineHeightValue = props.lineHeightCustom.trim();
if (lineHeightValue.endsWith("px")) {
const lineHeightEm = convertPxStringToEm(lineHeightValue);
if (lineHeightEm) {
styleOverrides.lineHeight = lineHeightEm;
}
} else {
styleOverrides.lineHeight = lineHeightValue;
}
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
const letterSpacingValue = props.letterSpacingCustom.trim();
if (letterSpacingValue.endsWith("px")) {
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
if (letterSpacingEm) {
styleOverrides.letterSpacing = letterSpacingEm;
}
} else {
styleOverrides.letterSpacing = letterSpacingValue;
}
}
return {
alignClass,
sizeClass,
+5 -4
View File
@@ -101,8 +101,9 @@ export function computeVideoExportTokens(
const widthMode = props.widthMode ?? "auto";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
wrapperStyleParts.push(`width:${props.widthPx}px`);
videoStyleParts.push(`width:${props.widthPx}px`);
const widthEm = pxToEm(props.widthPx);
wrapperStyleParts.push(`width:${widthEm}`);
videoStyleParts.push(`width:${widthEm}`);
} else if (widthMode === "full") {
wrapperStyleParts.push("width:100%");
videoStyleParts.push("width:100%");
@@ -113,11 +114,11 @@ export function computeVideoExportTokens(
}
if (typeof props.cardPaddingPx === "number" && props.cardPaddingPx >= 0) {
wrapperStyleParts.push(`padding:${props.cardPaddingPx}px`);
wrapperStyleParts.push(`padding:${pxToEm(props.cardPaddingPx)}`);
}
if (typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0) {
wrapperStyleParts.push(`border-radius:${props.borderRadiusPx}px`);
wrapperStyleParts.push(`border-radius:${pxToEm(props.borderRadiusPx)}`);
}
const align =
+7
View File
@@ -263,6 +263,7 @@ body {
font-size: 0.75rem;
font-weight: 500;
border-width: 1px;
text-decoration: none;
}
.pb-btn-size-xs {
padding: 0.125rem 0.5rem;
@@ -408,6 +409,12 @@ body {
max-width: 40rem;
}
/* Form 컨트롤러용 최소 폼: 레이아웃에 영향이 없도록 margin/padding 을 0 으로 고정한다. */
.pb-form-controller {
margin: 0;
padding: 0;
}
.pb-form-field {
display: flex;
flex-direction: column;
+89
View File
@@ -0,0 +1,89 @@
import "dotenv/config";
import { describe, it, expect } from "vitest";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import { buildStaticHtml } from "@/app/api/export/route";
const BASE_URL = "http://localhost";
// /api/export/preview TDD:
// - blocks + projectConfig 를 받아 HTML 문자열을 바로 반환하는 경량 엔드포인트.
// - 기존 buildStaticHtml 과 동일한 HTML 을 반환해야 한다.
describe("/api/export/preview", () => {
it("POST /api/export/preview 는 blocks + projectConfig 로부터 HTML 문자열을 반환해야 한다", async () => {
const blocks: Block[] = [
{
id: "blk_preview_text",
type: "text",
props: {
text: "Export 미리보기 테스트",
align: "center",
size: "lg",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "Export 미리보기 페이지",
slug: "export-preview",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handlePreview } = await import("@/app/api/export/preview/route");
const res = await handlePreview(
new Request(`${BASE_URL}/api/export/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
const html = await res.text();
expect(html).toContain("<title>Export 미리보기 페이지</title>");
expect(html).toContain("Export 미리보기 테스트");
});
it("/api/export/preview 의 HTML 은 동일 입력에 대해 buildStaticHtml 결과와 동일해야 한다", async () => {
const blocks: Block[] = [
{
id: "blk_preview_compare",
type: "text",
props: {
text: "Preview vs Export 동일성 테스트",
align: "left",
size: "base",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "동일성 테스트 페이지",
slug: "preview-equality-test",
canvasPreset: "full",
};
const payload = { blocks, projectConfig };
const { POST: handlePreview } = await import("@/app/api/export/preview/route");
const res = await handlePreview(
new Request(`${BASE_URL}/api/export/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
const previewHtml = await res.text();
const staticHtml = buildStaticHtml(blocks, projectConfig);
expect(previewHtml).toBe(staticHtml);
});
});
+166 -38
View File
@@ -7,6 +7,7 @@ import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
import { createFeaturesTemplateBlocks } from "@/app/editor/templates/featuresTemplate";
import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate";
import { createFooterTemplateBlocks } from "@/app/editor/templates/footerTemplate";
import { buildStaticHtml } from "@/app/api/export/route";
const BASE_URL = "http://localhost";
@@ -64,6 +65,16 @@ 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 builderCss = await cssEntry!.async("string");
// 버튼 베이스 클래스는 정적 Export 에서도 밑줄이 보이지 않도록 text-decoration:none 을 포함해야 한다.
expect(builderCss).toContain(".pb-btn-base");
expect(builderCss).toContain("text-decoration: none");
const html = await indexEntry!.async("string");
expect(html).toContain("<title>내보내기 테스트 페이지</title>");
expect(html).toContain("ZIP 내보내기 테스트");
@@ -403,7 +414,7 @@ describe("/api/export", () => {
);
});
it("폼 블록과 컨트롤러 필드 블록들은 정적 HTML에서도 폼 요소로 렌더되어야 한다", async () => {
it("FormBlock 은 Export 레이어에서 컨트롤러 역할만 하고, 필드 블록들은 form 속성으로 해당 폼과 연결되어야 한다", async () => {
const blocks: Block[] = [
{
id: "form_1",
@@ -469,18 +480,31 @@ describe("/api/export", () => {
const html = await indexEntry!.async("string");
// form 요소와 기본 input/select 가 포함되어야 한다.
expect(html).toContain("<form");
expect(html).toContain("name=\"name\"");
expect(html).toContain("name=\"plan\"");
expect(html).toContain("<select");
// required: formInput(name) 은 required, formSelect(plan) 은 required 가 없어야 한다.
expect(html).toMatch(/name=\"name\"[^>]*required/);
expect(html).not.toMatch(/name=\"plan\"[^>]*required/);
// FormBlock 은 컨트롤러 전용이므로, Export 에서는 id 를 가진 단일 <form> 컨테이너만 생성하고
// 실제 필드 레이아웃은 개별 formInput/formSelect 블록이 담당한다.
// - <form id="form_form_1" ...>
const formId = "form_form_1";
expect(html).toMatch(new RegExp(`<form[^>]*id=\\"${formId}\\"`));
expect(html).toMatch(new RegExp(`<form[^>]*id=\\"${formId}\\"[^>]*action=\\"/api/forms/submit\\"`));
// 컨트롤러 필드 블록(formInput/formSelect) 이 렌더한 input/select 는 각각 name 과 form 속성을 가져야 한다.
// name="name" input 은 required + form="form_form_1" 이어야 한다.
expect(html).toMatch(/name=\"name\"[^>]*required[^>]*form=\"form_form_1\"/);
// name="plan" select 는 required 는 아니지만 form="form_form_1" 이어야 한다.
expect(html).toMatch(/name=\"plan\"[^>]*form=\"form_form_1\"/);
// FormBlock 이 자체적으로 필드 레이아웃을 중복 생성하지 않도록,
// id 가 form_form_1 인 <form> 내부에는 name="name"/"plan" 필드가 없어야 한다.
const formStart = html.indexOf("<form");
const formEnd = html.indexOf("</form>", formStart);
const formHtml = html.slice(formStart, formEnd);
expect(formHtml).not.toContain('name="name"');
expect(formHtml).not.toContain('name="plan"');
expect(formHtml).toContain('name="__config"');
});
it("FormBlock 에 fieldIds/fields 가 모두 없으면 기본 폼/필드를 내보내지 않아야 한다", async () => {
it("FormBlock 에 fieldIds/fields 가 모두 없으면 Export 에서 아무 <form>/필드도 생성하지 않아야 한다", async () => {
const blocks: Block[] = [
{
id: "form_fallback_1",
@@ -528,6 +552,63 @@ describe("/api/export", () => {
expect(html).not.toMatch(/name=\"message\"/);
});
it("FormBlock 이 fields[] 만 가지고 있고 fieldIds 가 없더라도, Export 레이어에서 자체 pb-form 레이아웃/폼 UI 를 생성하지 않아야 한다", async () => {
const blocks: Block[] = [
{
id: "form_legacy_1",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fieldIds: [],
fields: [
{
id: "f1",
name: "email",
label: "이메일",
type: "email",
required: true,
},
],
} as any,
} as any,
];
const projectConfig: ProjectConfig = {
title: "폼 fallback(fields[]) 내보내기 테스트",
slug: "form-fallback-fields-export-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");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
// 새로운 규칙: FormBlock 은 어떤 경우에도 자체 레이아웃/폼 UI 를 생성하지 않는다.
// - fallback fields 기반 pb-form 도 더 이상 허용하지 않는다.
expect(html).not.toContain("class=\"pb-form\"");
expect(html).not.toContain("<form");
expect(html).not.toMatch(/name=\"email\"/);
});
it("/api/image/:id 기반 이미지 URL은 ZIP 안 images/ 디렉터리로 복사되고 HTML에서 상대 경로로 치환되어야 한다", async () => {
const imageId = `test-image-${Date.now()}`;
const uploadDir = path.join(process.cwd(), "uploads");
@@ -967,8 +1048,8 @@ describe("/api/export", () => {
expect(html).not.toContain(`/api/video/${videoId}`);
// 비디오 카드 스타일이 wrapper style 에 반영되어야 한다.
expect(html).toContain("background-color:#123456");
expect(html).toContain("padding:24px");
expect(html).toContain("border-radius:16px");
expect(html).toContain("padding:1.5em");
expect(html).toContain("border-radius:1em");
});
it("섹션 backgroundVideoSrc 가 /api/video/:id 인 경우 ZIP videos/ 및 상대 경로로 치환되고 섹션 안에 배경 비디오 video 태그로 렌더되어야 한다", async () => {
@@ -1081,7 +1162,7 @@ describe("/api/export", () => {
expect(html).toContain("pb-divider");
// 스타일 검증: medium 두께, 지정 색상, marginYPx 기반 여백이 style 속성에 포함되어야 한다.
expect(html).toContain("border-bottom:2px solid #123456");
expect(html).toContain("margin:32px 0;");
expect(html).toContain("margin:2em 0;");
});
it("image 블록은 정적 HTML에서 카드 스타일(배경/너비/둥글기)이 반영되어야 한다", async () => {
@@ -1133,7 +1214,7 @@ describe("/api/export", () => {
// 카드 배경색이 wrapper div style 에 반영되어야 한다.
expect(html).toContain("background-color:#123456");
// 고정 너비/둥글기 스타일이 img style 에 반영되어야 한다.
expect(html).toContain("width:480px");
expect(html).toContain("width:30em");
expect(html).toContain("border-radius:32px");
});
@@ -1446,6 +1527,49 @@ describe("/api/export", () => {
expect(html).toContain("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.");
expect(html).toContain("CTA 버튼");
});
it("Footer 템플릿 섹션은 export HTML 의 마지막 섹션으로 렌더되고 푸터 텍스트를 포함해야 한다", async () => {
const sectionId = "footer_section_export";
const { blocks } = createFooterTemplateBlocks({ sectionId, createId: createIdFactory() });
const html = await exportTemplateHtml(blocks as Block[]);
expect(html).toContain("MyLanding");
expect(html).toContain("더 나은 웹사이트를 위한 최고의 선택.");
expect(html).toContain("서비스 소개");
expect(html).toContain("문의하기");
expect(html).toContain(" 2025 MyLanding.");
const lastSectionIndex = html.lastIndexOf("<section");
expect(lastSectionIndex).toBeGreaterThan(-1);
const lastSectionHtml = html.slice(lastSectionIndex);
expect(lastSectionHtml).toContain(" 2025 MyLanding.");
});
it("루트 버튼 내비게이션 링크는 Export HTML 에서 동일한 라벨과 href 로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
id: "nav_btn_1",
type: "button",
props: {
label: "요금제",
href: "/pricing",
align: "left",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "내비게이션 링크 테스트",
slug: "nav-link-test",
canvasPreset: "full",
};
const html = buildStaticHtml(blocks, projectConfig);
expect(html).toContain('<a href="/pricing"');
expect(html).toContain(">요금제</a>");
});
});
describe("스타일 테스트", () => {
@@ -1743,7 +1867,11 @@ describe("/api/export", () => {
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("<form");
// 새로운 규칙: FormBlock 은 Export 에서 자체 폼 레이아웃을 생성하지 않는다.
// - backgroundColorCustom 이 있더라도 form 요소/컨테이너에 배경색이 적용되지 않아야 한다.
// - pb-form 기반 레이아웃도 생성되지 않아야 한다.
expect(html).not.toContain("class=\"pb-form\"");
expect(html).not.toContain("background-color:#111111");
});
@@ -2107,11 +2235,11 @@ describe("/api/export", () => {
const html = await indexEntry!.async("string");
expect(html).toContain("name=\"email\"");
expect(html).toContain("width:320px");
expect(html).toContain("padding-left:16px");
expect(html).toContain("padding-right:16px");
expect(html).toContain("padding-top:8px");
expect(html).toContain("padding-bottom:8px");
expect(html).toContain("width:20em");
expect(html).toContain("padding-left:1em");
expect(html).toContain("padding-right:1em");
expect(html).toContain("padding-top:0.5em");
expect(html).toContain("padding-bottom:0.5em");
expect(html).toContain("background-color:#123456");
expect(html).toContain("border-color:#ff0000");
expect(html).toContain("border-radius:6px");
@@ -2169,11 +2297,11 @@ describe("/api/export", () => {
const html = await indexEntry!.async("string");
expect(html).toContain("name=\"category\"");
expect(html).toContain("width:240px");
expect(html).toContain("padding-left:16px");
expect(html).toContain("padding-right:16px");
expect(html).toContain("padding-top:8px");
expect(html).toContain("padding-bottom:8px");
expect(html).toContain("width:15em");
expect(html).toContain("padding-left:1em");
expect(html).toContain("padding-right:1em");
expect(html).toContain("padding-top:0.5em");
expect(html).toContain("padding-bottom:0.5em");
expect(html).toContain("background-color:#123456");
expect(html).toContain("border-color:#ff0000");
expect(html).toContain("border-radius:6px");
@@ -2226,10 +2354,10 @@ describe("/api/export", () => {
const html = await indexEntry!.async("string");
expect(html).toContain("name=\"features\"");
expect(html).toContain("padding-left:8px");
expect(html).toContain("padding-right:8px");
expect(html).toContain("padding-top:4px");
expect(html).toContain("padding-bottom:4px");
expect(html).toContain("padding-left:0.5em");
expect(html).toContain("padding-right:0.5em");
expect(html).toContain("padding-top:0.25em");
expect(html).toContain("padding-bottom:0.25em");
expect(html).toContain("background-color:#123456");
expect(html).toContain("border-color:#ff0000");
expect(html).toContain("border-radius:6px");
@@ -2282,10 +2410,10 @@ describe("/api/export", () => {
const html = await indexEntry!.async("string");
expect(html).toContain("name=\"plan\"");
expect(html).toContain("padding-left:8px");
expect(html).toContain("padding-right:8px");
expect(html).toContain("padding-top:4px");
expect(html).toContain("padding-bottom:4px");
expect(html).toContain("padding-left:0.5em");
expect(html).toContain("padding-right:0.5em");
expect(html).toContain("padding-top:0.25em");
expect(html).toContain("padding-bottom:0.25em");
expect(html).toContain("background-color:#123456");
expect(html).toContain("border-color:#ff0000");
expect(html).toContain("border-radius:6px");
@@ -2389,11 +2517,11 @@ describe("/api/export", () => {
const html = await indexEntry!.async("string");
// widthMode=fixed, widthPx, paddingX/paddingY, fillColorCustom, strokeColorCustom, textColorCustom 이 style 속성에 포함되어야 한다.
expect(html).toContain("width:240px");
expect(html).toContain("padding-left:16px");
expect(html).toContain("padding-right:16px");
expect(html).toContain("padding-top:8px");
expect(html).toContain("padding-bottom:8px");
expect(html).toContain("width:15em");
expect(html).toContain("padding-left:1em");
expect(html).toContain("padding-right:1em");
expect(html).toContain("padding-top:0.5em");
expect(html).toContain("padding-bottom:0.5em");
expect(html).toContain("background-color:#123456");
expect(html).toContain("border-color:#ff0000");
expect(html).toContain("color:#00ff00");
@@ -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);
});
});
+43
View File
@@ -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",
+203 -1
View File
@@ -9,13 +9,80 @@ vi.mock("@/generated/prisma/client", () => {
class PrismaClientMock {
project = {
create: async ({ data }: any) => {
const project = { id: inMemoryProjects.length + 1, ...data };
const now = new Date();
const project = {
id: String(inMemoryProjects.length + 1),
status: "DRAFT",
createdAt: now,
updatedAt: now,
...data,
};
inMemoryProjects.push(project);
return project;
},
findUnique: async ({ where: { slug } }: any) => {
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
},
upsert: async ({ where: { slug }, update, create }: any) => {
const index = inMemoryProjects.findIndex((p) => p.slug === slug);
if (index >= 0) {
const existing = inMemoryProjects[index];
const updated = {
...existing,
...update,
updatedAt: new Date(),
};
inMemoryProjects[index] = updated;
return updated;
}
const now = new Date();
const project = {
id: String(inMemoryProjects.length + 1),
status: "DRAFT",
createdAt: now,
updatedAt: now,
...create,
};
inMemoryProjects.push(project);
return project;
},
delete: async ({ where: { slug } }: any) => {
const index = inMemoryProjects.findIndex((p) => p.slug === slug);
if (index === -1) {
const error: any = new Error("Project not found");
error.code = "P2025";
throw error;
}
const [removed] = inMemoryProjects.splice(index, 1);
return removed;
},
findMany: async ({ orderBy, take, select }: any = {}) => {
let items = [...inMemoryProjects];
if (orderBy?.createdAt === "desc") {
items.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
if (typeof take === "number") {
items = items.slice(0, take);
}
if (select) {
return items.map((p) => {
const picked: any = {};
for (const key of Object.keys(select)) {
if (select[key]) {
picked[key] = p[key];
}
}
return picked;
});
}
return items;
},
};
}
@@ -70,4 +137,139 @@ describe("/api/projects", () => {
expect(fetched.slug).toBe(payload.slug);
expect(fetched.contentJson).toEqual(payload.contentJson);
});
it("GET /api/projects 는 최근 생성 순으로 프로젝트 목록을 반환해야 한다", async () => {
const { POST: createProject } = await import("@/app/api/projects/route");
const firstPayload = {
title: "첫 번째 프로젝트",
slug: "first-project",
contentJson: [],
};
const secondPayload = {
title: "두 번째 프로젝트",
slug: "second-project",
contentJson: [],
};
await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(firstPayload),
}),
);
await new Promise((resolve) => setTimeout(resolve, 5));
await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(secondPayload),
}),
);
const { GET: listProjects } = await import("@/app/api/projects/route");
const res = await listProjects(new Request(`${BASE_URL}/api/projects`));
expect(res.status).toBe(200);
const list = (await res.json()) as any[];
expect(list.length).toBeGreaterThanOrEqual(2);
expect(list[0].slug).toBe("second-project");
expect(list[1].slug).toBe("first-project");
});
it("DELETE /api/projects/[slug] 로 프로젝트를 삭제할 수 있어야 한다", async () => {
const { POST: createProject } = await import("@/app/api/projects/route");
const slug = `delete-slug-${Date.now()}`;
const payload = {
title: "삭제 테스트 프로젝트",
slug,
contentJson: [],
};
await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
const { DELETE: deleteProject, GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
const deleteResponse = await deleteProject(
new Request(`${BASE_URL}/api/projects/${slug}`, { method: "DELETE" }),
{ params: { slug } } as any,
);
expect(deleteResponse.status).toBe(200);
const getAfterDelete = await getProjectBySlug(
new Request(`${BASE_URL}/api/projects/${slug}`),
{ params: { slug } } as any,
);
expect(getAfterDelete.status).toBe(404);
});
it("동일 slug 로 두 번 POST 하면 두 번째 요청은 기존 프로젝트를 업데이트해야 한다", async () => {
const { POST: handleProject } = await import("@/app/api/projects/route");
const slug = "duplicate-slug";
const firstPayload = {
title: "첫 번째 제목",
slug,
contentJson: [
{
id: "blk_first",
type: "text",
props: { text: "첫 번째 내용", align: "left", size: "base" },
},
],
};
const secondPayload = {
title: "두 번째 제목",
slug,
contentJson: [
{
id: "blk_second",
type: "text",
props: { text: "두 번째 내용", align: "left", size: "base" },
},
],
};
const firstRes = await handleProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(firstPayload),
}),
);
expect(firstRes.status).toBe(201);
const secondRes = await handleProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(secondPayload),
}),
);
expect([200, 201]).toContain(secondRes.status);
const updated = (await secondRes.json()) as any;
expect(updated.slug).toBe(slug);
expect(updated.title).toBe("두 번째 제목");
expect(updated.contentJson).toEqual(secondPayload.contentJson);
});
});
+90 -70
View File
@@ -8,10 +8,10 @@ test("/editor 페이지가 존재하고 Page Editor 헤더를 보여줘야 한
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
});
test("텍스트 블록 추가 버튼을 누르면 캔버스에 '새 텍스트' 블록이 보여야 한다", async ({ page }) => {
test("텍스트 버튼을 누르면 캔버스에 '새 텍스트' 블록이 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const canvas = page.getByTestId("editor-canvas");
await expect(canvas.getByText("새 텍스트")).toBeVisible();
@@ -75,7 +75,7 @@ test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야
await page.goto("/editor");
// 텍스트 블록을 하나 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
// 1단계: 더블클릭 → Enter 로 커밋
// 기존 텍스트 블록을 더블클릭해서 편집 모드로 전환한다.
@@ -109,7 +109,7 @@ test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야
await editor.fill("blur 로 커밋된 텍스트");
// 포커스를 다른 곳(예: 속성 패널의 버튼 추가 텍스트 입력)을 클릭하여 blur 를 발생시킨다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
// blur 이후에는 마지막 입력 값이 캔버스에 반영되어야 한다.
canvasAfterEdit = page.getByTestId("editor-canvas");
@@ -120,7 +120,7 @@ test("속성 패널에서 선택된 텍스트 블록 내용을 수정하면 캔
await page.goto("/editor");
// 텍스트 블록을 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
// 캔버스 블록을 클릭해서 선택한다.
const canvas = page.getByTestId("editor-canvas");
@@ -140,8 +140,8 @@ test("여러 텍스트 블록 중 선택을 전환하면 속성 패널 내용과
await page.goto("/editor");
// 텍스트 블록 두 개를 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const canvas = page.getByTestId("editor-canvas");
const blocks = canvas.getByTestId("editor-block");
@@ -172,7 +172,7 @@ test("텍스트 블록의 정렬과 글자 크기를 속성 패널에서 변경
await page.goto("/editor");
// 텍스트 블록을 하나 추가하고 선택한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const canvas = page.getByTestId("editor-canvas");
const block = canvas.getByTestId("editor-block").nth(0);
await block.click({ force: true });
@@ -196,7 +196,7 @@ test("텍스트 블록 인라인 툴바에서 정렬과 글자 크기를 변경
await page.goto("/editor");
// 텍스트 블록을 하나 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).click();
const canvas = page.getByTestId("editor-canvas");
const block = canvas.getByTestId("editor-block").nth(0);
@@ -226,8 +226,8 @@ test("에디터 상태를 JSON으로 내보내고 다시 불러오면 동일한
const canvas = page.getByTestId("editor-canvas");
// 블록 두 개를 추가하고 각각 다른 내용/스타일을 설정한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const blocks = canvas.getByTestId("editor-block");
const firstBlock = blocks.nth(0);
@@ -290,8 +290,8 @@ test("각 캔버스 블록에 드래그 핸들 UI가 표시되고 선택 상태
const canvas = page.getByTestId("editor-canvas");
// 블록 두 개를 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const blocks = canvas.getByTestId("editor-block");
const firstBlock = blocks.nth(0);
@@ -318,8 +318,8 @@ test("블록 드래그앤드롭으로 순서를 변경할 수 있어야 한다",
const canvas = page.getByTestId("editor-canvas");
// 블록 두 개를 추가하고 서로 다른 텍스트로 구분한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const blocks = canvas.getByTestId("editor-block");
const firstBlock = blocks.nth(0);
@@ -352,8 +352,8 @@ test("리스트 블록에도 드래그 핸들이 있고 선택/드래그가 가
const canvas = page.getByTestId("editor-canvas");
// 텍스트 블록과 리스트 블록을 하나씩 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "리스트" }).click();
const blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(2);
@@ -387,7 +387,7 @@ test("버튼 블록을 추가하고 라벨과 링크를 수정할 수 있어야
const canvas = page.getByTestId("editor-canvas");
// 버튼 블록을 추가한다.
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
await page.getByRole("button", { name: "버튼" }).click();
// 캔버스에 기본 버튼이 렌더되어야 한다.
const button = canvas.getByRole("button", { name: "버튼" });
@@ -410,7 +410,7 @@ test("버튼 블록을 추가하면 속성 패널 기본 색상/패딩 값이
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
await page.getByRole("button", { name: "버튼" }).click();
const button = canvas.getByRole("button", { name: "버튼" });
await expect(button).toBeVisible();
@@ -437,7 +437,7 @@ test("버튼 가로/세로 패딩 px 값을 변경하면 에디터 버튼에도
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();
@@ -470,7 +470,7 @@ test("버튼 가로/세로 패딩 px 값을 변경하면 에디터 버튼에도
test("이미지 블록 고정 너비와 모서리 둥글기 스타일이 에디터에서 적용되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "이미지 블록 추가" }).click();
await page.getByRole("button", { name: "이미지" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -504,7 +504,7 @@ test("에디터 메뉴에서 페이지 파일로 내보내기 (ZIP)를 클릭하
await page.goto("/editor");
// 최소 한 개의 블록을 추가해 내보낼 데이터가 있도록 한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
// 헤더 메뉴를 연다.
await page.getByRole("button", { name: "메뉴 ▼" }).click();
@@ -528,7 +528,7 @@ test("2컬럼 섹션에서 블록을 다른 컬럼 영역으로 드래그하면
const canvas = page.getByTestId("editor-canvas");
// 섹션을 하나 추가하고 레이아웃을 2컬럼으로 변경한다.
await page.getByRole("button", { name: "섹션 블록 추가" }).click();
await page.getByRole("button", { name: "섹션" }).click();
// 섹션을 선택한다 (캔버스 첫 블록).
const sectionBlock = canvas.getByTestId("editor-block").nth(0);
@@ -538,7 +538,7 @@ test("2컬럼 섹션에서 블록을 다른 컬럼 영역으로 드래그하면
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 rightColumn = canvas.locator('[data-section-id][data-column-id]').nth(1);
@@ -563,7 +563,7 @@ test("Hero 템플릿 버튼을 클릭하면 섹션과 기본 텍스트/버튼
const canvas = page.getByTestId("editor-canvas");
// Hero 템플릿을 추가한다.
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
await page.getByRole("button", { name: "Hero 템플릿" }).click();
// 섹션/블록이 하나 이상 존재해야 한다.
const blocks = canvas.getByTestId("editor-block");
@@ -582,7 +582,7 @@ test("Features 템플릿 버튼을 클릭하면 3개의 Feature 항목(제목/
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "Features 템플릿 추가" }).click();
await page.getByRole("button", { name: "기능 템플릿" }).click();
// Feature 제목들이 렌더되어야 한다.
await expect(canvas.getByText("Feature 1 제목")).toBeVisible();
@@ -595,7 +595,7 @@ test("CTA 템플릿 버튼을 클릭하면 CTA 텍스트와 버튼이 포함된
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();
const ctaButton = canvas.getByRole("button", { name: "CTA 버튼" });
@@ -607,13 +607,13 @@ test("FAQ 템플릿 버튼을 클릭하면 질문/답변 텍스트가 세 쌍
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "FAQ 템플릿 추가" }).click();
await page.getByRole("button", { name: "FAQ 템플릿" }).click();
// 기본 FAQ 질문/답변 텍스트들이 렌더되어야 한다.
await expect(canvas.getByText("자주 묻는 질문 1")).toBeVisible();
await expect(canvas.getByText("첫 번째 질문에 대한 답변을 여기에 입력하세요.")).toBeVisible();
await expect(canvas.getByText("자주 묻는 질문 2")).toBeVisible();
await expect(canvas.getByText("자주 묻는 질문 3")).toBeVisible();
await expect(canvas.getByText("서비스 이용료는 얼마인가요?")).toBeVisible();
await expect(canvas.getByText("기본 기능은 무료로 제공되며, 프리미엄 기능은 월 구독료가 발생합니다.")).toBeVisible();
await expect(canvas.getByText("환불 정책은 어떻게 되나요?")).toBeVisible();
await expect(canvas.getByText("팀원 초대 기능이 있나요?")).toBeVisible();
});
test("Pricing 템플릿 버튼을 클릭하면 3개의 요금제 카드가 생성되어야 한다", async ({ page }) => {
@@ -621,7 +621,7 @@ test("Pricing 템플릿 버튼을 클릭하면 3개의 요금제 카드가 생
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("₩9,900/월")).toBeVisible();
@@ -636,7 +636,7 @@ test("Testimonials 템플릿 버튼을 클릭하면 3개의 후기 카드가 생
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();
@@ -648,7 +648,7 @@ test("Blog 템플릿 버튼을 클릭하면 3개의 포스트 카드(제목/요
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("첫 번째 포스트 요약을 여기에 입력하세요.")).toBeVisible();
@@ -661,7 +661,7 @@ test("Team 템플릿 버튼을 클릭하면 3명의 팀 카드가 생성되어
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("Product Designer")).toBeVisible();
@@ -675,7 +675,7 @@ test("템플릿 섹션을 삭제한 뒤 텍스트 블록을 추가하면 캔버
const canvas = page.getByTestId("editor-canvas");
// Hero 템플릿 섹션을 추가한다.
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
await page.getByRole("button", { name: "Hero 템플릿" }).click();
// 섹션 블록을 선택한다 (캔버스의 첫 번째 블록이 섹션이다).
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: "텍스트" }).first().click();
// 새 텍스트 블록이 캔버스에 보여야 한다.
await expect(canvas.getByText("새 텍스트")).toBeVisible();
@@ -696,10 +696,12 @@ test("Footer 템플릿 버튼을 클릭하면 링크/카피라이트 텍스트
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("© 2025 MyLanding. All rights reserved.")).toBeVisible();
await expect(canvas.getByText("서비스 소개")).toBeVisible();
await expect(canvas.getByText("고객지원")).toBeVisible();
await expect(canvas.getByText("© 2025 MyLanding.")).toBeVisible();
await expect(canvas.getByText("All rights reserved.")).toBeVisible();
});
test("Cmd/Ctrl+Z로 블록 추가를 Undo 하고 Cmd/Ctrl+Shift+Z로 Redo 할 수 있어야 한다", async ({ page }) => {
@@ -708,7 +710,7 @@ test("Cmd/Ctrl+Z로 블록 추가를 Undo 하고 Cmd/Ctrl+Shift+Z로 Redo 할
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);
// Cmd/Ctrl + Z 로 Undo 한다.
@@ -726,6 +728,24 @@ test("Cmd/Ctrl+Z로 블록 추가를 Undo 하고 Cmd/Ctrl+Shift+Z로 Redo 할
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.skip(true, "ArrowUp/Down 기반 선택 이동 E2E는 불안정하여 임시 스킵");
await page.goto("/editor");
@@ -733,9 +753,9 @@ test("ArrowUp/ArrowDown 키로 선택된 블록을 위/아래로 이동하며
const canvas = page.getByTestId("editor-canvas");
// 텍스트 블록을 세 개 추가한다.
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 page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(3);
@@ -769,8 +789,8 @@ test("속성 패널의 블록 삭제 버튼으로 선택된 블록을 삭제할
const canvas = page.getByTestId("editor-canvas");
// 텍스트 블록 두 개를 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(2);
@@ -791,7 +811,7 @@ test("Cmd/Ctrl+D 단축키로 선택된 블록을 복제할 수 있어야 한다
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");
await expect(blocks).toHaveCount(1);
@@ -819,7 +839,7 @@ test("구분선 블록을 추가하면 캔버스에 구분선이 렌더되고
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 dividerBlock = blocks.nth(0);
@@ -843,7 +863,7 @@ test("리스트 블록을 추가하고 첫 번째 아이템과 번호 매기기/
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 listBlock = blocks.nth(0);
@@ -871,7 +891,7 @@ test("리스트 아이템을 선택하면 패널에서 아이템 위/아래/들
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 listBlock = blocks.nth(0);
@@ -910,23 +930,23 @@ test("폼 요소 사이드바에서 폼 입력/셀렉트/라디오/체크박스
await expect(page.getByRole("heading", { name: "폼 요소" })).toBeVisible();
// 폼 입력 블록 추가 버튼을 클릭하면 formInput 블록이 캔버스에 생성되어야 한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
let blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(1);
// 폼 셀렉트 블록 추가 버튼을 클릭하면 formSelect 블록이 추가되어야 한다.
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(2);
// 폼 라디오 블록 추가 버튼을 클릭하면 formRadio 블록이 추가되어야 한다.
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(3);
// 폼 체크박스 블록 추가 버튼을 클릭하면 formCheckbox 블록이 추가되어야 한다.
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(4);
});
@@ -937,12 +957,12 @@ test("폼 블록을 선택하면 우측 속성 패널에서 폼 필드/버튼
const canvas = page.getByTestId("editor-canvas");
// 폼 요소와 버튼 블록을 몇 개 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).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();
const blocks = canvas.getByTestId("editor-block");
const formBlock = blocks.nth((await blocks.count()) - 1);
@@ -982,7 +1002,7 @@ test("폼 입력 블록의 라벨/전송 키/필수 여부를 우측 패널에
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 inputBlock = blocks.nth(0);
@@ -1011,7 +1031,7 @@ test("폼 셀렉트/라디오/체크박스 블록도 우측 패널에서 기본
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");
const selectBlock = blocks.nth((await blocks.count()) - 1);
await selectBlock.click({ force: true });
@@ -1029,7 +1049,7 @@ test("폼 셀렉트/라디오/체크박스 블록도 우측 패널에서 기본
await requiredCheckbox.check();
// 폼 라디오 블록
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
blocks = canvas.getByTestId("editor-block");
const radioBlock = blocks.nth((await blocks.count()) - 1);
await radioBlock.click({ force: true });
@@ -1045,7 +1065,7 @@ test("폼 셀렉트/라디오/체크박스 블록도 우측 패널에서 기본
// 라디오 그룹 타이틀 텍스트 필드는 그대로 편집 가능해야 한다.
// 폼 체크박스 블록
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
blocks = canvas.getByTestId("editor-block");
const checkboxBlock = blocks.nth((await blocks.count()) - 1);
await checkboxBlock.click({ force: true });
@@ -1067,7 +1087,7 @@ test("폼 입력 블록을 추가하면 에디터 캔버스에 라벨과 입력
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 inputBlock = blocks.nth(0);
@@ -1086,7 +1106,7 @@ test("폼 셀렉트 블록을 추가하면 에디터 캔버스에 라벨과 셀
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 selectBlock = blocks.nth(0);
@@ -1105,7 +1125,7 @@ test("폼 라디오 블록을 추가하면 에디터 캔버스에 그룹 라벨
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 radioBlock = blocks.nth(0);
@@ -1124,7 +1144,7 @@ test("폼 체크박스 블록을 추가하면 에디터 캔버스에 체크박
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 checkboxBlock = blocks.nth(0);
@@ -1142,7 +1162,7 @@ test("폼 입력 필드의 타입과 Placeholder 를 우측 패널에서 변경
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 inputBlock = blocks.nth(0);
@@ -1165,7 +1185,7 @@ test("폼 셀렉트 블록의 옵션 목록을 우측 패널에서 수정하면
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 selectBlock = blocks.nth(0);
@@ -1206,7 +1226,7 @@ test("폼 입력 블록의 스타일(텍스트/배경/모서리)을 우측 패
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 inputBlock = blocks.nth(0);
+93 -117
View File
@@ -10,15 +10,15 @@ test("/preview 페이지는 에디터 크롬 없이 컨텐츠만 보여야 한
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
// 에디터 좌측 패널용 버튼들은 나타나지 않아야 한다.
await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Hero 템플릿 추가" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "텍스트" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Hero 템플릿" })).toHaveCount(0);
});
test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 Hero 콘텐츠가 보여야 한다", async ({ page }) => {
// 에디터에서 Hero 템플릿을 추가한다.
await page.goto("/editor");
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
await page.getByRole("button", { name: "Hero 템플릿" }).click();
// 헤드라인과 CTA 버튼이 에디터에서 생성되었는지 한 번 확인한다.
const editorCanvas = page.getByTestId("editor-canvas");
@@ -36,8 +36,8 @@ test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 H
// 프리뷰에서는 CTA를 실제 링크(<a>)로 렌더링하므로 텍스트 기준으로만 검증한다.
await expect(page.getByText("지금 시작하기")).toBeVisible();
// 프리뷰 화면에는 에디터용 "텍스트 블록 추가" 버튼이 없어야 한다.
await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0);
// 프리뷰 화면에는 에디터용 "텍스트" 버튼이 없어야 한다.
await expect(page.getByRole("button", { name: "텍스트" })).toHaveCount(0);
});
test("프리뷰 페이지에서 에디터로 돌아가는 링크가 있어야 한다", async ({ page }) => {
@@ -58,7 +58,7 @@ test("프리뷰 페이지에서 에디터로 돌아가는 링크가 있어야
test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의 Feature 섹션이 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "Features 템플릿 추가" }).click();
await page.getByRole("button", { name: "기능 템플릿" }).click();
const editorCanvas = page.getByTestId("editor-canvas");
await expect(editorCanvas.getByText("Feature 1 제목")).toBeVisible();
@@ -77,7 +77,7 @@ test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의
test("에디터에서 CTA 템플릿을 추가하면 프리뷰에서 CTA 텍스트와 버튼이 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "CTA 템플릿 추가" }).click();
await page.getByRole("button", { name: "CTA 템플릿" }).click();
const editorCanvas = page.getByTestId("editor-canvas");
await expect(editorCanvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
@@ -95,10 +95,10 @@ test("모바일 뷰에서도 프리뷰 페이지가 깨지지 않고 주요 템
// 에디터에서 여러 템플릿을 추가한다.
await page.goto("/editor");
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
await page.getByRole("button", { name: "Features 템플릿 추가" }).click();
await page.getByRole("button", { name: "Pricing 템플릿 추가" }).click();
await page.getByRole("button", { name: "Testimonials 템플릿 추가" }).click();
await page.getByRole("button", { name: "Hero 템플릿" }).click();
await page.getByRole("button", { name: "기능 템플릿" }).click();
await page.getByRole("button", { name: "상품 템플릿" }).click();
await page.getByRole("button", { name: "후기 템플릿" }).click();
// 모바일 뷰포트로 전환한다.
await page.setViewportSize({ width: 390, height: 844 });
@@ -127,7 +127,7 @@ test("텍스트 블록의 정렬/폰트 크기/색상이 프리뷰에도 반영
await page.goto("/editor");
// 텍스트 블록을 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
// 기본 텍스트 대신 프리뷰에서 쉽게 찾을 수 있도록 내용도 함께 수정한다.
const textTextarea = page.getByLabel("선택한 텍스트 블록 내용");
@@ -175,8 +175,8 @@ test("구분선/리스트 블록도 프리뷰에서 렌더링되어야 한다",
// 에디터에서 구분선과 리스트 블록을 추가한다.
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 를 한다.
const editorCanvas = page.getByTestId("editor-canvas");
@@ -195,7 +195,7 @@ test("리스트 블록 스타일 속성이 프리뷰에도 반영되어야 한
await page.goto("/editor");
// 리스트 블록을 추가한다.
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
await page.getByRole("button", { name: "리스트" }).click();
// 테스트를 위해 리스트 아이템을 2개 이상으로 만든다 (첫 번째 li 에 margin-bottom 이 적용되도록).
const itemsTextarea = page.getByLabel("리스트 아이템들");
@@ -271,8 +271,8 @@ test("폼 입력/셀렉트 블록도 FormBlock 없이 프리뷰에서 보여야
await page.goto("/editor");
// 폼 입력/셀렉트 블록을 각각 하나씩 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const editorCanvas = page.getByTestId("editor-canvas");
@@ -293,7 +293,7 @@ test("폼 입력/셀렉트 블록도 FormBlock 없이 프리뷰에서 보여야
test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -306,8 +306,6 @@ test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰
await fileInput.setInputFiles("tests/fixtures/sample-image.png");
const editorImage = canvas.getByRole("img", { name: "체크박스 라벨 이미지" }).first();
await expect(editorImage).toBeVisible();
const editorSrc = await editorImage.getAttribute("src");
expect(editorSrc).not.toBeNull();
expect(editorSrc).toMatch(/^\/api\/image\//);
@@ -325,7 +323,7 @@ test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰
test("폼 라디오 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -358,7 +356,7 @@ test("폼 라디오 옵션 라벨 이미지 업로드 후 에디터/프리뷰
test("폼 체크박스 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -392,7 +390,7 @@ test("폼 체크박스 옵션 라벨 이미지 업로드 후 에디터/프리뷰
test("폼 라디오 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -425,7 +423,7 @@ test("폼 입력 필드 스타일 속성이 프리뷰에도 반영되어야 한
await page.goto("/editor");
// 폼 입력 블록을 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
// 우측 속성 패널에서 폼 입력 스타일을 설정한다.
// 라벨/필드 텍스트 등 기본 값은 그대로 두고, 스타일 속성만 눈에 띄게 변경한다.
@@ -485,7 +483,7 @@ test("폼 입력 필드 스타일 속성이 프리뷰에도 반영되어야 한
test("폼 입력 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -514,7 +512,7 @@ test("폼 입력 고정 너비가 프리뷰에서도 em 스케일로 반영되
test("버튼 타이포 px 입력이 프리뷰에서는 em 스케일로 렌더링되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
await page.getByRole("button", { name: "버튼" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -556,7 +554,7 @@ test("버튼 타이포 px 입력이 프리뷰에서는 em 스케일로 렌더링
test("버튼 고정 너비와 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
await page.getByRole("button", { name: "버튼" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -605,7 +603,7 @@ test("버튼 고정 너비와 패딩 px 입력이 프리뷰에서도 em 스케
test("이미지 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "이미지 블록 추가" }).click();
await page.getByRole("button", { name: "이미지" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -636,7 +634,7 @@ test("이미지 고정 너비가 프리뷰에서도 em 스케일로 반영되어
test("이미지 블록에서 로컬 파일 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "이미지 블록 추가" }).click();
await page.getByRole("button", { name: "이미지" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -673,7 +671,7 @@ test("이미지 블록에서 로컬 파일 업로드 후 에디터/프리뷰 모
test("구분선 고정 길이와 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
await page.getByRole("button", { name: "구분선" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -720,7 +718,7 @@ test("구분선 고정 길이와 여백 px 입력이 프리뷰에서도 em 스
test("섹션 padding/maxWidth/gap px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "섹션 블록 추가" }).click();
await page.getByRole("button", { name: "섹션" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -782,7 +780,7 @@ test("섹션 padding/maxWidth/gap px 입력이 프리뷰에서도 em 스케일
test("구분선 고정 길이 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
await page.getByRole("button", { name: "구분선" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -811,7 +809,7 @@ test("구분선 고정 길이 px 입력이 프리뷰에서도 em 스케일로
test("구분선 상하 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
await page.getByRole("button", { name: "구분선" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -839,7 +837,7 @@ test("구분선 상하 여백 px 입력이 프리뷰에서도 em 스케일로
test("리스트 아이템 간 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
await page.getByRole("button", { name: "리스트" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -868,7 +866,7 @@ test("리스트 아이템 간 여백 px 입력이 프리뷰에서도 em 스케
test("리스트 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
await page.getByRole("button", { name: "리스트" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -896,7 +894,7 @@ test("리스트 글자 크기 px 입력이 프리뷰에서도 em 스케일로
test("폼 입력 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -924,7 +922,7 @@ test("폼 입력 줄간격 px 입력이 프리뷰에서도 em 스케일로 반
test("폼 입력 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -952,7 +950,7 @@ test("폼 입력 자간 px 입력이 프리뷰에서도 em 스케일로 반영
test("폼 입력 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const paddingSidebar = page.getByTestId("properties-sidebar");
@@ -980,7 +978,7 @@ test("폼 입력 가로 패딩 px 입력이 프리뷰에서도 em 스케일로
test("폼 입력 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const paddingSidebar = page.getByTestId("properties-sidebar");
@@ -1008,7 +1006,7 @@ test("폼 입력 세로 패딩 px 입력이 프리뷰에서도 em 스케일로
test("폼 입력 인라인 라벨 간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1037,7 +1035,7 @@ test("폼 입력 인라인 라벨 간격 px 입력이 프리뷰에서도 em 스
test("폼 체크박스 옵션 간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1064,10 +1062,15 @@ test("폼 체크박스 옵션 간격 px 입력이 프리뷰에서도 em 스케
expect(gapStyles.inlineRowGap.endsWith("em")).toBeTruthy();
});
test("폼 컨트롤러 고정 너비 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (고정 너비 설정 시)", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 블록 추가" }).click();
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
// 폼 블록이 실제로 선택되어 우측 속성 패널에 FormControllerPanel 이 노출되도록 캔버스에서 한 번 더 명시적으로 선택한다.
const editorCanvas = page.getByTestId("editor-canvas");
const editorBlocks = editorCanvas.getByTestId("editor-block");
await editorBlocks.last().click({ force: true });
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1077,26 +1080,20 @@ test("폼 컨트롤러 고정 너비 px 입력이 프리뷰에서도 em 스케
await page.getByRole("link", { name: "프리뷰 열기" }).click();
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
const form = page.getByTestId("preview-form-controller").first();
const inlineStyles = await form.evaluate((el) => {
const element = el as HTMLElement;
const s = window.getComputedStyle(element);
return {
computedWidth: s.width,
inlineWidth: element.style.width,
};
});
const widthPx = parseFloat(inlineStyles.computedWidth);
expect(widthPx).toBeGreaterThan(430);
expect(widthPx).toBeLessThan(520);
expect(inlineStyles.inlineWidth.endsWith("em")).toBeTruthy();
const formLocator = page.getByTestId("preview-form-controller");
await expect(formLocator).toHaveCount(0);
});
test("폼 컨트롤러 상하 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (상하 여백 설정 시)", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 블록 추가" }).click();
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
// 앞선 테스트들과의 상호 영향으로 selectedBlockId 가 어긋나는 경우를 방지하기 위해,
// 방금 추가한 폼 블록을 캔버스에서 다시 선택해 속성 패널을 확실히 FormBlock 기준으로 맞춘다.
const editorCanvas = page.getByTestId("editor-canvas");
const editorBlocks = editorCanvas.getByTestId("editor-block");
await editorBlocks.last().click({ force: true });
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1105,26 +1102,14 @@ test("폼 컨트롤러 상하 여백 px 입력이 프리뷰에서도 em 스케
await page.getByRole("link", { name: "프리뷰 열기" }).click();
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
const form = page.getByTestId("preview-form-controller").first();
const inlineStyles = await form.evaluate((el) => {
const element = el as HTMLElement;
const s = window.getComputedStyle(element);
return {
marginTop: s.marginTop,
inlineMarginTop: element.style.marginTop,
};
});
const marginPx = parseFloat(inlineStyles.marginTop);
expect(marginPx).toBeGreaterThan(24);
expect(marginPx).toBeLessThan(32);
expect(inlineStyles.inlineMarginTop.endsWith("em")).toBeTruthy();
const formLocator = page.getByTestId("preview-form-controller");
await expect(formLocator).toHaveCount(0);
});
test("폼 라디오 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1152,7 +1137,7 @@ test("폼 라디오 줄간격 px 입력이 프리뷰에서도 em 스케일로
test("폼 라디오 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1180,7 +1165,7 @@ test("폼 라디오 자간 px 입력이 프리뷰에서도 em 스케일로 반
test("폼 체크박스 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1189,7 +1174,7 @@ test("폼 체크박스 가로 패딩 px 입력이 프리뷰에서도 em 스케
await page.getByRole("link", { name: "프리뷰 열기" }).click();
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
const checkboxOption = page.getByTestId("preview-form-checkbox-option").first();
const checkboxOption = page.getByTestId("preview-form-checkbox-option-container").first();
const paddingStyles = await checkboxOption.evaluate((el) => {
const element = el as HTMLElement;
const s = window.getComputedStyle(element);
@@ -1208,7 +1193,7 @@ test("폼 체크박스 가로 패딩 px 입력이 프리뷰에서도 em 스케
test("폼 체크박스 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1217,7 +1202,7 @@ test("폼 체크박스 세로 패딩 px 입력이 프리뷰에서도 em 스케
await page.getByRole("link", { name: "프리뷰 열기" }).click();
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
const checkboxOption = page.getByTestId("preview-form-checkbox-option").first();
const checkboxOption = page.getByTestId("preview-form-checkbox-option-container").first();
const paddingStyles = await checkboxOption.evaluate((el) => {
const element = el as HTMLElement;
const s = window.getComputedStyle(element);
@@ -1236,7 +1221,7 @@ test("폼 체크박스 세로 패딩 px 입력이 프리뷰에서도 em 스케
test("폼 셀렉트 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1264,7 +1249,7 @@ test("폼 셀렉트 가로 패딩 px 입력이 프리뷰에서도 em 스케일
test("폼 셀렉트 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1292,7 +1277,7 @@ test("폼 셀렉트 세로 패딩 px 입력이 프리뷰에서도 em 스케일
test("폼 셀렉트 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1320,7 +1305,7 @@ test("폼 셀렉트 글자 크기 px 입력이 프리뷰에서도 em 스케일
test("폼 셀렉트 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1348,7 +1333,7 @@ test("폼 셀렉트 줄간격 px 입력이 프리뷰에서도 em 스케일로
test("폼 셀렉트 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1376,7 +1361,7 @@ test("폼 셀렉트 자간 px 입력이 프리뷰에서도 em 스케일로 반
test("폼 체크박스 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1404,7 +1389,7 @@ test("폼 체크박스 글자 크기 px 입력이 프리뷰에서도 em 스케
test("폼 라디오 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1432,7 +1417,7 @@ test("폼 라디오 글자 크기 px 입력이 프리뷰에서도 em 스케일
test("폼 입력 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1460,7 +1445,7 @@ test("폼 입력 글자 크기 px 입력이 프리뷰에서도 em 스케일로
test("폼 셀렉트 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1489,7 +1474,7 @@ test("폼 셀렉트 고정 너비가 프리뷰에서도 em 스케일로 반영
test("폼 라디오 그룹 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1518,7 +1503,7 @@ test("폼 라디오 그룹 고정 너비가 프리뷰에서도 em 스케일로
test("폼 체크박스 그룹 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1551,9 +1536,9 @@ test("폼 컨트롤러에 매핑한 폼 요소와 버튼이 프리뷰에서 함
const canvas = page.getByTestId("editor-canvas");
// 폼 입력 요소와 버튼, 폼 블록을 순서대로 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
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();
// 방금 추가된 폼 블록을 선택한다.
const blocks = canvas.getByTestId("editor-block");
@@ -1587,12 +1572,9 @@ test("폼 컨트롤러에 매핑한 폼 요소와 버튼이 프리뷰에서 함
const input = page.getByRole("textbox").first();
await expect(input).toBeVisible();
// 값을 입력한 뒤, 매핑된 버튼(기본 라벨 "버튼")을 클릭하면 폼이 전송되고 성공 메시지가 보여야 한다.
await input.fill("테스트 값");
await page.getByRole("button", { name: "버튼" }).click();
await expect(page.getByText("성공적으로 전송되었습니다.")).toBeVisible();
// 프리뷰 정책: FormBlock 컨트롤러는 프리뷰에서 실제 `<form>` DOM 을 렌더하지 않는다.
await expect(page.getByTestId("preview-form-controller")).toHaveCount(0);
await expect(page.getByText("성공적으로 전송되었습니다.")).toHaveCount(0);
});
test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으로 제출되어야 한다", async ({ page }) => {
@@ -1603,9 +1585,9 @@ test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으
const blocks = canvas.getByTestId("editor-block");
// Form A: 입력 필드 + 버튼 + 폼 블록을 추가한 뒤, 즉시 컨트롤러를 매핑한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
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();
let count = await blocks.count();
const formBlockA = blocks.nth(count - 1);
@@ -1621,9 +1603,9 @@ test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으
await submitSelect.selectOption({ index: 1 });
// Form B: 입력 필드 + 버튼 + 폼 블록을 다시 추가한 뒤, 즉시 컨트롤러를 매핑한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
await page.getByRole("button", { name: "폼 블록 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
await page.getByRole("button", { name: "버튼" }).first().click();
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
count = await blocks.count();
const formBlockB = blocks.nth(count - 1);
@@ -1653,25 +1635,19 @@ test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으
const firstInput = textboxes.nth(0);
const secondInput = textboxes.nth(1);
// Form A 입력값을 채우고 첫 번째 버튼 클릭 → 성공 메시지 기대
await firstInput.fill("A 폼 값");
await page.getByRole("button", { name: "버튼" }).first().click();
// 첫 번째 폼에 대한 성공 메시지가 최소 하나는 보여야 한다.
await expect(page.getByText("성공적으로 전송되었습니다.").first()).toBeVisible();
// 프리뷰 정책: 여러 FormBlock 이 있어도 preview-form-controller DOM 은 생성되지 않아야 한다.
const controllers = page.getByTestId("preview-form-controller");
await expect(controllers).toHaveCount(0);
// Form B 입력값을 채우고 두 번째 버튼 클릭 → 다시 성공 메시지 기대
await secondInput.fill("B 폼 값");
await page.getByRole("button", { name: "버튼" }).nth(1).click();
// 두 번째 폼에 대한 성공 메시지도 두 번째 요소로 표시되었는지 확인한다.
const successMessages = page.getByText("성공적으로 전송되었습니다.");
await expect(successMessages.nth(1)).toBeVisible();
await expect(successMessages).toHaveCount(0);
});
test("섹션 패딩/컬럼 간격 수치가 프리뷰에서도 em 스케일에 맞게 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
// 섹션 블록을 직접 추가한다.
await page.getByRole("button", { name: "섹션 블록 추가" }).click();
await page.getByRole("button", { name: "섹션" }).click();
// 캔버스에서 첫 번째 섹션 블록을 선택한다.
const canvas = page.getByTestId("editor-canvas");
@@ -1729,7 +1705,7 @@ test("섹션 패딩/컬럼 간격 수치가 프리뷰에서도 em 스케일에
test("구분선 여백/길이 수치가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
await page.getByRole("button", { name: "구분선" }).click();
const canvas = page.getByTestId("editor-canvas");
const dividerBlock = canvas.getByTestId("editor-block").first();
+65 -19
View File
@@ -52,28 +52,25 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
Object.values(templateActions).forEach((fn) => fn.mockReset());
});
it("Hero 템플릿 추가 버튼 클릭 시 addHeroTemplateSection 이 호출되어야 한다", () => {
it("Hero 템플릿 버튼 클릭 시 addHeroTemplateSection 이 호출되어야 한다", () => {
render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "Hero 템플릿 추가" });
const button = screen.getByRole("button", { name: "Hero 템플릿" });
fireEvent.click(button);
expect(templateActions.addHeroTemplateSection).toHaveBeenCalledTimes(1);
});
it("Features 템플릿 추가 버튼 클릭 시 addFeaturesTemplateSection 이 호출되어야 한다", () => {
it("Features 템플릿 버튼 클릭 시 addFeaturesTemplateSection 이 호출되어야 한다", () => {
render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "Features 템플릿 추가" });
const button = screen.getByRole("button", { name: "기능 템플릿" });
fireEvent.click(button);
expect(templateActions.addFeaturesTemplateSection).toHaveBeenCalledTimes(1);
});
it("CTA 템플릿 추가 버튼 클릭 시 addCtaTemplateSection 이 호출되어야 한다", () => {
it("CTA 템플릿 버튼 클릭 시 addCtaTemplateSection 이 호출되어야 한다", () => {
render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "CTA 템플릿 추가" });
const button = screen.getByRole("button", { name: "CTA 템플릿" });
fireEvent.click(button);
expect(templateActions.addCtaTemplateSection).toHaveBeenCalledTimes(1);
@@ -81,13 +78,12 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
it("FAQ/Pricing/Testimonials/Blog/Team/Footer 템플릿 버튼도 각각의 액션을 호출해야 한다", () => {
render(<BlocksSidebar />);
fireEvent.click(screen.getByRole("button", { name: "FAQ 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "Pricing 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "Testimonials 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "Blog 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "Team 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "Footer 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "FAQ 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "상품 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "후기 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "블로그 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "Team 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "Footer 템플릿" }));
expect(templateActions.addFaqTemplateSection).toHaveBeenCalledTimes(1);
expect(templateActions.addPricingTemplateSection).toHaveBeenCalledTimes(1);
@@ -111,10 +107,9 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
expect(screen.getByText("페이지 푸터 섹션"));
});
it("비디오 블록 추가 버튼 클릭 시 addVideoBlock 이 호출되어야 한다", () => {
it("비디오 버튼 클릭 시 addVideoBlock 이 호출되어야 한다", () => {
render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "비디오 블록 추가" });
const button = screen.getByRole("button", { name: "비디오" });
fireEvent.click(button);
expect(otherActions.addVideoBlock).toHaveBeenCalledTimes(1);
@@ -165,4 +160,55 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
expect(pricingThumb.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);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import EditorPage from "@/app/editor/page";
// EditorPage 상단 메뉴 UX TDD
// - Export 미리보기 기능 제거 후, 상단 메뉴에 더 이상 "Export 미리보기" 항목이 노출되지 않아야 한다.
// - 대신 프로젝트 저장/불러오기와 연계된 "프로젝트 목록" 항목이 메뉴에 노출되어야 한다.
let mockState: any;
beforeEach(() => {
const baseProjectConfig: ProjectConfig = {
title: "Export 미리보기 테스트",
slug: "export-preview-test",
canvasPreset: "full",
} as ProjectConfig;
mockState = {
blocks: [
{
id: "blk_text_1",
type: "text",
props: {
text: "Export 미리보기 본문",
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(),
};
});
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 - 상단 메뉴 (Export 미리보기 제거)", () => {
it("메뉴를 열었을 때 'Export 미리보기' 항목은 보이지 않고, '프로젝트 목록' 항목이 노출되어야 한다", () => {
render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const removedItem = screen.queryByText("Export 미리보기");
expect(removedItem).toBeNull();
const projectListItem = screen.getByText("프로젝트 목록");
expect(projectListItem).toBeTruthy();
});
});
+195
View File
@@ -0,0 +1,195 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
import EditorPage from "@/app/editor/page";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
// EditorPage JSON 내보내기/불러오기 TDD
// - "JSON 내보내기" 클릭 시 blocks + projectConfig 를 포함한 JSON 을 에디터 상태 영역에 출력해야 한다.
// - "JSON 적용하기" 클릭 시 blocks + projectConfig 형태의 JSON 을 파싱해 replaceBlocks + updateProjectConfig 로 반영해야 한다.
// - 잘못된 JSON 이거나 구조가 맞지 않으면 replaceBlocks / updateProjectConfig 를 호출하지 않아야 한다.
let mockState: any;
beforeEach(() => {
const baseProjectConfig: ProjectConfig = {
title: "JSON Export/Import 테스트",
slug: "json-export-import-test",
canvasPreset: "full",
} as ProjectConfig;
mockState = {
blocks: [
{
id: "blk_1",
type: "text",
props: {
text: "JSON 테스트 블록",
align: "left",
size: "base",
},
},
] as Block[],
projectConfig: baseProjectConfig,
selectedBlockId: null as string | null,
selectedListItemId: null as string | null,
// EditorPage 가 참조하는 액션들: 기본적으로 no-op mock 으로 채운다.
undo: vi.fn(),
redo: vi.fn(),
removeBlock: vi.fn(),
duplicateBlock: vi.fn(),
selectBlock: vi.fn(),
selectListItem: vi.fn(),
replaceBlocks: vi.fn(),
reorderBlocks: vi.fn(),
moveBlock: vi.fn(),
addTextBlock: vi.fn(),
addButtonBlock: vi.fn(),
addImageBlock: vi.fn(),
addDividerBlock: vi.fn(),
addListBlock: vi.fn(),
addSectionBlock: vi.fn(),
addFormBlock: vi.fn(),
addFormInputBlock: vi.fn(),
addFormSelectBlock: vi.fn(),
addFormCheckboxBlock: vi.fn(),
addFormRadioBlock: vi.fn(),
addHeroTemplateSection: vi.fn(),
addFeaturesTemplateSection: vi.fn(),
addCtaTemplateSection: vi.fn(),
addFaqTemplateSection: vi.fn(),
addPricingTemplateSection: vi.fn(),
addTestimonialsTemplateSection: vi.fn(),
addBlogTemplateSection: vi.fn(),
addTeamTemplateSection: vi.fn(),
addFooterTemplateSection: vi.fn(),
updateBlock: vi.fn(),
updateProjectConfig: vi.fn(),
};
window.localStorage.clear();
});
afterEach(() => {
cleanup();
});
vi.mock("@/features/editor/state/editorStore", () => {
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
(useEditorStore as any).getState = () => mockState;
return {
__esModule: true,
useEditorStore,
};
});
vi.mock("next/link", () => {
return {
__esModule: true,
default: ({ href, children }: any) => <a href={href}>{children}</a>,
};
});
function openJsonModal() {
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const jsonMenuItem = screen.getByText("JSON 내보내기/불러오기");
fireEvent.click(jsonMenuItem);
const modalTitle = screen.getByText("JSON Export / Import");
const jsonModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
return jsonModal;
}
describe("EditorPage - JSON 내보내기/불러오기", () => {
it("JSON 내보내기 클릭 시 blocks + projectConfig 구조의 JSON 이 에디터 상태 영역에 출력되어야 한다", () => {
render(<EditorPage />);
const jsonModal = openJsonModal();
const exportLabel = within(jsonModal).getByText("에디터 상태 JSON").closest("label") as HTMLElement;
const exportTextarea = within(exportLabel).getByRole("textbox") as HTMLTextAreaElement;
// 초기에는 비어 있어야 한다.
expect(exportTextarea.value).toBe("");
const exportButton = within(jsonModal).getByText("JSON 내보내기");
fireEvent.click(exportButton);
const value = exportTextarea.value;
expect(value.trim().length).toBeGreaterThan(0);
const parsed = JSON.parse(value);
expect(Array.isArray(parsed.blocks)).toBe(true);
expect(parsed.blocks[0].id).toBe("blk_1");
expect(parsed.projectConfig.slug).toBe("json-export-import-test");
});
it("JSON 적용하기 클릭 시 blocks + projectConfig 구조의 JSON 으로 replaceBlocks 와 updateProjectConfig 를 호출해야 한다", () => {
render(<EditorPage />);
const jsonModal = openJsonModal();
const importLabel = within(jsonModal).getByText("JSON에서 불러오기").closest("label") as HTMLElement;
const importTextarea = within(importLabel).getByRole("textbox") as HTMLTextAreaElement;
const importedBlocks: Block[] = [
{
id: "imported_1",
type: "text",
props: {
text: "가져온 블록",
align: "center",
size: "lg",
},
} as any,
];
const importedConfig: ProjectConfig = {
title: "가져온 프로젝트",
slug: "imported-slug",
canvasPreset: "full",
} as ProjectConfig;
const payload = {
blocks: importedBlocks,
projectConfig: importedConfig,
};
fireEvent.change(importTextarea, { target: { value: JSON.stringify(payload) } });
// 초기 마운트 시 호출됐을 수 있는 기록은 무시한다.
mockState.replaceBlocks.mockReset();
mockState.updateProjectConfig.mockReset();
const applyButton = within(importLabel).getByText("JSON 적용하기");
fireEvent.click(applyButton);
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
expect(mockState.replaceBlocks).toHaveBeenCalledWith(importedBlocks as any);
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(importedConfig as ProjectConfig);
});
it("잘못된 JSON 은 무시되어야 하며 replaceBlocks/updateProjectConfig 를 호출하면 안 된다", () => {
render(<EditorPage />);
const jsonModal = openJsonModal();
const importLabel = within(jsonModal).getByText("JSON에서 불러오기").closest("label") as HTMLElement;
const importTextarea = within(importLabel).getByRole("textbox") as HTMLTextAreaElement;
fireEvent.change(importTextarea, { target: { value: "{ this-is: not-valid-json" } });
mockState.replaceBlocks.mockReset();
mockState.updateProjectConfig.mockReset();
const applyButton = within(importLabel).getByText("JSON 적용하기");
fireEvent.click(applyButton);
expect(mockState.replaceBlocks).not.toHaveBeenCalled();
expect(mockState.updateProjectConfig).not.toHaveBeenCalled();
});
});
+2
View File
@@ -6,6 +6,8 @@ import EditorPage from "@/app/editor/page";
let mockState: any;
beforeEach(() => {
window.localStorage.clear();
const baseProjectConfig: ProjectConfig = {
title: "멀티 선택 테스트",
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);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import PreviewPage from "@/app/preview/page";
// PreviewPage Export ZIP TDD
// - 프리뷰 헤더에 "페이지 파일로 내보내기 (ZIP)" 버튼이 노출되어야 한다.
// - 버튼 클릭 시 /api/export 로 blocks + projectConfig 를 payload 로 POST 요청을 보내야 한다.
let mockState: any;
beforeEach(() => {
const baseProjectConfig: ProjectConfig = {
title: "프리뷰 Export 테스트",
slug: "preview-export-test",
canvasPreset: "full",
} as ProjectConfig;
mockState = {
blocks: [
{
id: "blk_text_1",
type: "text",
props: {
text: "프리뷰 Export 본문",
align: "left",
size: "base",
},
},
] as Block[],
projectConfig: baseProjectConfig,
};
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
vi.mock("@/features/editor/state/editorStore", () => {
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
return {
__esModule: true,
useEditorStore,
};
});
vi.mock("next/link", () => {
return {
__esModule: true,
default: ({ href, children }: any) => <a href={href}>{children}</a>,
};
});
describe("PreviewPage - ZIP Export", () => {
it("프리뷰 헤더에 프로젝트 목록 링크가 노출되고 /projects 로 연결되어야 한다", () => {
render(<PreviewPage />);
const link = screen.getByText("프로젝트 목록");
expect(link).toBeTruthy();
expect(link.closest("a")?.getAttribute("href")).toBe("/projects");
});
it("프리뷰 헤더에 ZIP Export 버튼이 노출되어야 한다", () => {
render(<PreviewPage />);
const exportButton = screen.getByText("페이지 파일로 내보내기 (ZIP)");
expect(exportButton).toBeTruthy();
});
it("ZIP Export 버튼 클릭 시 /api/export 로 blocks + projectConfig 를 payload 로 POST 해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
// JSDOM 환경에서 blob() 호출을 안전하게 처리하기 위한 최소 mock
blob: vi.fn().mockResolvedValue(new Blob(["dummy-zip"])),
} as any);
vi.stubGlobal("fetch", fetchMock);
render(<PreviewPage />);
const exportButton = screen.getByText("페이지 파일로 내보내기 (ZIP)");
fireEvent.click(exportButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const [url, options] = fetchMock.mock.calls[0] as any;
expect(url).toBe("/api/export");
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("preview-export-test");
});
});
+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();
});
});
@@ -88,7 +88,7 @@ describe("PublicPageRenderer - 블록 배경색", () => {
expect(listNoBg!.style.backgroundColor === "" || listNoBg!.style.backgroundColor === "transparent").toBe(true);
});
it("form 컨트롤러 블록은 backgroundColorCustom 이 설정된 경우에만 폼 래퍼에 배경색이 적용되어야 한다", () => {
it("form 컨트롤러 블록은 프리뷰에서 별도 폼 래퍼를 렌더하지 않아야 한다", () => {
const blocksWithBg: Block[] = [
{
id: "form_with_bg",
@@ -105,10 +105,10 @@ describe("PublicPageRenderer - 블록 배경색", () => {
} as any,
];
const { getByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
const { queryByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
const formWithBg = getByTestId("preview-form-controller") as HTMLFormElement;
expect(formWithBg.style.backgroundColor).toBe("rgb(17, 17, 17)");
const formWithBg = queryByTestId("preview-form-controller");
expect(formWithBg).toBeNull();
const blocksWithoutBg: Block[] = [
{
@@ -126,8 +126,8 @@ describe("PublicPageRenderer - 블록 배경색", () => {
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
const formNoBg = getByTestId("preview-form-controller") as HTMLFormElement;
expect(formNoBg.style.backgroundColor === "" || formNoBg.style.backgroundColor === "transparent").toBe(true);
const formNoBg = queryByTestId("preview-form-controller");
expect(formNoBg).toBeNull();
});
it("section 블록은 backgroundColorCustom 이 설정된 경우에만 섹션 요소에 배경색이 적용되어야 한다", () => {
@@ -33,6 +33,15 @@ describe("PublicPageRenderer - 버튼 블록 스타일", () => {
const link = container.querySelector("a") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
const wrapper = link!.parentElement as HTMLElement | null;
expect(wrapper).not.toBeNull();
// 정렬/버튼 베이스는 pb-* 토큰 클래스로 렌더된다.
expect(wrapper!.className).toContain("pb-text-center");
expect(link!.className).toContain("pb-btn-base");
expect(link!.className).toContain("pb-btn-size-md");
expect(link!.className).toContain("pb-btn-variant-solid-primary");
expect(link!.className).toContain("pb-btn-radius-md");
// 배경색: #123456 → rgb(18, 52, 86)
expect(link!.style.backgroundColor).toBe("rgb(18, 52, 86)");
// 외곽선 색상: #ff0000 → rgb(255, 0, 0)
@@ -0,0 +1,33 @@
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
import { createFooterTemplateBlocks } from "@/app/editor/templates/footerTemplate";
import type { Block } from "@/features/editor/state/editorStore";
// PublicPageRenderer 푸터 템플릿 TDD
// - footerTemplate 로 생성한 섹션이 프리뷰에서 섹션/텍스트 구조로 올바르게 렌더되는지 검증한다.
describe("PublicPageRenderer - 푸터 템플릿", () => {
afterEach(() => {
cleanup();
});
it("푸터 템플릿 섹션은 프리뷰에서 섹션과 푸터 텍스트들을 렌더해야 한다", () => {
const sectionId = "footer_preview_section";
let i = 0;
const createId = () => `footer_${++i}`;
const { blocks } = createFooterTemplateBlocks({ sectionId, createId });
render(<PublicPageRenderer blocks={blocks as Block[]} />);
const section = screen.getByTestId("preview-section");
expect(section.getAttribute("data-section-id")).toBe(sectionId);
expect(screen.getByText("MyLanding")).toBeTruthy();
expect(screen.getByText("더 나은 웹사이트를 위한 최고의 선택.")).toBeTruthy();
expect(screen.getByText(/서비스 소개/)).toBeTruthy();
expect(screen.getByText(/문의하기/)).toBeTruthy();
expect(screen.getByText(/© 2025 MyLanding\./)).toBeTruthy();
});
});
@@ -15,7 +15,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
(global as any).fetch = undefined;
});
it("성공 응답 시 config.successMessage 를 success 스타일로 표시해야 한다", async () => {
it("FormBlock 이 있어도 프리뷰에서는 폼 컨트롤러를 렌더하지 않아야 한다 (성공 메시지 설정)", () => {
const blocks: Block[] = [
{
id: "form_success",
@@ -42,19 +42,12 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
render(<PublicPageRenderer blocks={blocks} />);
const form = screen.getByTestId("preview-form-controller");
fireEvent.submit(form);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const messageEl = await screen.findByText("폼 성공 메시지 (config)");
expect(messageEl.textContent).toBe("폼 성공 메시지 (config)");
expect(messageEl.className).toContain("text-emerald-400");
const form = screen.queryByTestId("preview-form-controller");
expect(form).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("에러 응답 시 config.errorMessage 를 error 스타일로 표시해야 한다", async () => {
it("FormBlock 이 있어도 프리뷰에서는 폼 컨트롤러나 에러 메시지를 렌더하지 않아야 한다", () => {
const blocks: Block[] = [
{
id: "form_error",
@@ -81,15 +74,9 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
render(<PublicPageRenderer blocks={blocks} />);
const form = screen.getByTestId("preview-form-controller");
fireEvent.submit(form);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const messageEl = await screen.findByText("폼 에러 메시지 (config)");
expect(messageEl.textContent).toBe("폼 에러 메시지 (config)");
expect(messageEl.className).toContain("text-rose-400");
const form = screen.queryByTestId("preview-form-controller");
expect(form).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
expect(screen.queryByText("폼 에러 메시지 (config)")).toBeNull();
});
});
@@ -56,7 +56,7 @@ describe("PublicPageRenderer - 텍스트 블록 스타일", () => {
// 정렬 클래스
expect(el.className).toContain("text-center");
// 커스텀 폰트 크기: sizeClass 는 비워지고, 인라인 스타일로 설정된다.
expect(el.style.fontSize).toBe("24px");
expect(el.style.fontSize).toBe("1.5em");
// 커스텀 색상/배경색
expect(el.style.color).toBe("rgb(255, 0, 0)");
expect(el.style.backgroundColor).toBe("rgb(18, 52, 86)");
@@ -192,4 +192,26 @@ describe("PublicPageRenderer - 비디오 블록 스타일", () => {
expect(wrapper!.style.padding).toBe("2em");
expect(wrapper!.style.borderRadius).toBe("1em");
});
it("sourceUrl 이 비어 있는 비디오 블록은 프리뷰에서 video/iframe 을 렌더하지 않아야 한다", () => {
const blocks: Block[] = [
{
id: "video_empty_src_preview_1",
type: "video" as any,
props: {
sourceUrl: "",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const video = container.querySelector("video");
const iframe = container.querySelector("iframe");
expect(video).toBeNull();
expect(iframe).toBeNull();
});
});
+22 -5
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { expectNoPxInStyleParts } from "./styleInvariants";
import {
computeButtonPbTokens,
computeButtonPublicTokens,
@@ -53,15 +54,31 @@ describe("buttonHelpers.computeButtonPbTokens", () => {
});
expect(tokens.alignClass).toBe("pb-text-right");
expect(tokens.inlineStyles).toContain("width:200px");
expect(tokens.inlineStyles).toContain("padding-left:16px");
expect(tokens.inlineStyles).toContain("padding-right:16px");
expect(tokens.inlineStyles).toContain("padding-top:8px");
expect(tokens.inlineStyles).toContain("padding-bottom:8px");
expect(tokens.inlineStyles).toContain("width:12.5em");
expect(tokens.inlineStyles).toContain("padding-left:1em");
expect(tokens.inlineStyles).toContain("padding-right:1em");
expect(tokens.inlineStyles).toContain("padding-top:0.5em");
expect(tokens.inlineStyles).toContain("padding-bottom:0.5em");
expect(tokens.inlineStyles).toContain("background-color:#111111");
expect(tokens.inlineStyles).toContain("border-color:#222222");
expect(tokens.inlineStyles).toContain("color:#ffffff");
});
it("Button Pb inlineStyles 에는 px 단위 값이 포함되지 않아야 한다", () => {
const tokens = computeButtonPbTokens({
align: "right",
size: "sm",
widthMode: "fixed",
widthPx: 200,
paddingX: 16,
paddingY: 8,
fillColorCustom: "#111111",
strokeColorCustom: "#222222",
textColorCustom: "#ffffff",
});
expectNoPxInStyleParts(tokens.inlineStyles);
});
});
describe("buttonHelpers.computeButtonPublicTokens", () => {
+33 -5
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { expectNoPxInStyleObject, expectNoPxInStyleString } from "./styleInvariants";
import {
computeDividerExportTokens,
computeDividerEditorTokens,
@@ -16,9 +17,9 @@ const baseProps: DividerBlockProps = {
describe("dividerHelpers.computeDividerExportTokens", () => {
const escapers = { escapeAttr: (v: string) => v };
it("기본 값은 1px 두께, 기본 색상(#475569), marginY=16px 이어야 한다", () => {
it("기본 값은 1px 두께, 기본 색상(#475569), marginY=1em 이어야 한다", () => {
const tokens = computeDividerExportTokens(baseProps, escapers);
expect(tokens.style).toBe("border:0;border-bottom:1px solid #475569;margin:16px 0;");
expect(tokens.style).toBe("border:0;border-bottom:1px solid #475569;margin:1em 0;");
});
it("thickness=\"medium\" 인 경우 2px 두께로 렌더링되어야 한다", () => {
@@ -33,12 +34,25 @@ describe("dividerHelpers.computeDividerExportTokens", () => {
it("marginYPx > 0 이면 해당 값을 margin Y 로 사용해야 한다", () => {
const tokens = computeDividerExportTokens({ ...baseProps, marginYPx: 24 }, escapers);
expect(tokens.style).toContain("margin:24px 0;");
expect(tokens.style).toContain("margin:1.5em 0;");
});
it("marginYPx 가 0 이하이거나 숫자가 아니면 기본 16px 을 사용해야 한다", () => {
it("marginYPx 가 0 이하이거나 숫자가 아니면 기본 1em 을 사용해야 한다", () => {
const zeroTokens = computeDividerExportTokens({ ...baseProps, marginYPx: 0 }, escapers);
expect(zeroTokens.style).toContain("margin:16px 0;");
expect(zeroTokens.style).toContain("margin:1em 0;");
});
it("Export 구분선 style 에서는 border-bottom 두께를 제외하고 px 단위가 없어야 한다", () => {
const tokens = computeDividerExportTokens({
...baseProps,
thickness: "medium",
colorHex: "#123456",
marginYPx: 24,
}, escapers);
expectNoPxInStyleString(tokens.style, {
allowPatterns: [/border-bottom:\d+px/],
});
});
});
@@ -136,4 +150,18 @@ describe("dividerHelpers.computeDividerPublicTokens", () => {
expect(tokens.innerStyle.width).toBe("auto");
expect(tokens.lineStyle.width).toBe("30em");
});
it("퍼블릭 구분선 스타일에 px 단위 값이 포함되지 않아야 한다", () => {
const tokens = computeDividerPublicTokens({
align: "center",
thickness: "medium",
colorHex: "#123456",
marginYPx: 32,
widthMode: "fixed",
widthPx: 480,
} as DividerBlockProps);
expectNoPxInStyleObject(tokens.innerStyle);
expectNoPxInStyleObject(tokens.lineStyle);
});
});
+74 -18
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { expectNoPxInStyleObject, expectNoPxInStyleString } from "./styleInvariants";
import {
computeFormInputExportTokens,
computeFormSelectExportTokens,
@@ -64,12 +65,17 @@ describe("formHelpers.computeFormInputExportTokens", () => {
expect(tokens.inputStyleAttr).toContain("color:#ff0000");
expect(tokens.inputStyleAttr).toContain("background-color:#111827");
expect(tokens.inputStyleAttr).toContain("border-color:#4b5563");
expect(tokens.inputStyleAttr).toContain("padding-left:8px");
expect(tokens.inputStyleAttr).toContain("padding-right:8px");
expect(tokens.inputStyleAttr).toContain("padding-top:4px");
expect(tokens.inputStyleAttr).toContain("padding-bottom:4px");
expect(tokens.inputStyleAttr).toContain("width:300px");
expect(tokens.inputStyleAttr).toContain("padding-left:0.5em");
expect(tokens.inputStyleAttr).toContain("padding-right:0.5em");
expect(tokens.inputStyleAttr).toContain("padding-top:0.25em");
expect(tokens.inputStyleAttr).toContain("padding-bottom:0.25em");
expect(tokens.inputStyleAttr).toContain("width:18.75em");
expect(tokens.inputStyleAttr).toContain("border-radius:6px");
// Export 인풋 스타일에는 border-radius 등의 예외를 제외하고 px 단위가 포함되지 않아야 한다.
expectNoPxInStyleString(tokens.inputStyleAttr, {
allowPatterns: [/border-radius:\d+px/, /border-width:\d+px/],
});
});
it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
@@ -97,12 +103,17 @@ describe("formHelpers.computeFormSelectExportTokens", () => {
expect(tokens.selectStyleAttr).toContain("color:#00ff00");
expect(tokens.selectStyleAttr).toContain("background-color:#020617");
expect(tokens.selectStyleAttr).toContain("border-color:#64748b");
expect(tokens.selectStyleAttr).toContain("padding-left:12px");
expect(tokens.selectStyleAttr).toContain("padding-right:12px");
expect(tokens.selectStyleAttr).toContain("padding-top:6px");
expect(tokens.selectStyleAttr).toContain("padding-bottom:6px");
expect(tokens.selectStyleAttr).toContain("width:240px");
expect(tokens.selectStyleAttr).toContain("padding-left:0.75em");
expect(tokens.selectStyleAttr).toContain("padding-right:0.75em");
expect(tokens.selectStyleAttr).toContain("padding-top:0.375em");
expect(tokens.selectStyleAttr).toContain("padding-bottom:0.375em");
expect(tokens.selectStyleAttr).toContain("width:15em");
expect(tokens.selectStyleAttr).toContain("border-radius:9999px");
// Export 셀렉트 스타일에는 border-radius 등의 예외를 제외하고 px 단위가 포함되지 않아야 한다.
expectNoPxInStyleString(tokens.selectStyleAttr, {
allowPatterns: [/border-radius:\d+px/, /border-width:\d+px/],
});
});
it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
@@ -130,11 +141,16 @@ describe("formHelpers.computeFormCheckboxExportTokens", () => {
expect(tokens.optionStyleAttr).toContain("border-color:#e5e7eb");
expect(tokens.optionStyleAttr).toContain("border-width:1px");
expect(tokens.optionStyleAttr).toContain("border-style:solid");
expect(tokens.optionStyleAttr).toContain("padding-left:10px");
expect(tokens.optionStyleAttr).toContain("padding-right:10px");
expect(tokens.optionStyleAttr).toContain("padding-top:5px");
expect(tokens.optionStyleAttr).toContain("padding-bottom:5px");
expect(tokens.optionStyleAttr).toContain("padding-left:0.625em");
expect(tokens.optionStyleAttr).toContain("padding-right:0.625em");
expect(tokens.optionStyleAttr).toContain("padding-top:0.3125em");
expect(tokens.optionStyleAttr).toContain("padding-bottom:0.3125em");
expect(tokens.optionStyleAttr).toContain("border-radius:2px");
// Export 체크박스 옵션 스타일에는 border/border-radius 를 제외하고 px 단위가 포함되지 않아야 한다.
expectNoPxInStyleString(tokens.optionStyleAttr, {
allowPatterns: [/border-radius:\d+px/, /border-width:\d+px/],
});
});
it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
@@ -162,11 +178,16 @@ describe("formHelpers.computeFormRadioExportTokens", () => {
expect(tokens.optionStyleAttr).toContain("border-color:#facc15");
expect(tokens.optionStyleAttr).toContain("border-width:1px");
expect(tokens.optionStyleAttr).toContain("border-style:solid");
expect(tokens.optionStyleAttr).toContain("padding-left:4px");
expect(tokens.optionStyleAttr).toContain("padding-right:4px");
expect(tokens.optionStyleAttr).toContain("padding-top:2px");
expect(tokens.optionStyleAttr).toContain("padding-bottom:2px");
expect(tokens.optionStyleAttr).toContain("padding-left:0.25em");
expect(tokens.optionStyleAttr).toContain("padding-right:0.25em");
expect(tokens.optionStyleAttr).toContain("padding-top:0.125em");
expect(tokens.optionStyleAttr).toContain("padding-bottom:0.125em");
expect(tokens.optionStyleAttr).toContain("border-radius:4px");
// Export 라디오 옵션 스타일에는 border/border-radius 를 제외하고 px 단위가 포함되지 않아야 한다.
expectNoPxInStyleString(tokens.optionStyleAttr, {
allowPatterns: [/border-radius:\d+px/, /border-width:\d+px/],
});
});
});
@@ -200,6 +221,11 @@ describe("formHelpers - editor tokens", () => {
widthMode: "fixed",
widthPx: 260,
borderRadius: "lg",
paddingX: 12,
paddingY: 6,
fontSizeCustom: "14px",
lineHeightCustom: "20px",
letterSpacingCustom: "1px",
} as any);
expect(tokens.fieldStyle.color).toBe("#ff0000");
@@ -207,6 +233,11 @@ describe("formHelpers - editor tokens", () => {
expect(tokens.fieldStyle.borderColor).toBe("#654321");
expect(tokens.fieldStyle.width).toBe("260px");
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: 에디터 라디오/체크박스 그룹 필드 스타일을 계산해야 한다", () => {
@@ -218,6 +249,11 @@ describe("formHelpers - editor tokens", () => {
widthMode: "fixed",
widthPx: 280,
borderRadius: "lg",
paddingX: 8,
paddingY: 4,
fontSizeCustom: "15px",
lineHeightCustom: "22px",
letterSpacingCustom: "0.5px",
} as any);
expect(tokens.fieldStyle.color).toBe("#ff0000");
@@ -225,6 +261,11 @@ describe("formHelpers - editor tokens", () => {
expect(tokens.fieldStyle.borderColor).toBe("#654321");
expect(tokens.fieldStyle.width).toBe("280px");
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");
});
});
@@ -253,6 +294,10 @@ describe("formHelpers - public tokens", () => {
expect(tokens.inputStyle.backgroundColor).toBe("#123456");
expect(tokens.inputStyle.borderColor).toBe("#ff0000");
expect(tokens.inputStyle.borderRadius).toBe("6px");
// 퍼블릭 인풋 토큰 스타일에는 borderRadius/borderWidth 를 제외하고 px 단위가 없어야 한다.
expectNoPxInStyleObject(tokens.wrapperStyle);
expectNoPxInStyleObject(tokens.inputStyle, { allowKeys: ["borderRadius", "borderWidth"] });
});
it("computeFormSelectPublicTokens: 퍼블릭 셀렉트 필드 스타일을 계산해야 한다", () => {
@@ -276,6 +321,10 @@ describe("formHelpers - public tokens", () => {
expect(tokens.selectStyle.backgroundColor).toBe("#123456");
expect(tokens.selectStyle.borderColor).toBe("#ff0000");
expect(tokens.selectStyle.borderRadius).toBe("6px");
// 퍼블릭 셀렉트 토큰 스타일에는 borderRadius/borderWidth 를 제외하고 px 단위가 없어야 한다.
expectNoPxInStyleObject(tokens.wrapperStyle);
expectNoPxInStyleObject(tokens.selectStyle, { allowKeys: ["borderRadius", "borderWidth"] });
});
it("computeFormCheckboxPublicTokens: 퍼블릭 체크박스 그룹 스타일을 계산해야 한다", () => {
@@ -304,6 +353,13 @@ describe("formHelpers - public tokens", () => {
expect(tokens.optionContainerStyle.borderRadius).toBe("6px");
expect(tokens.groupTextStyle.color).toBe("#ffffff");
expect(tokens.optionTextStyle.color).toBe("#ffffff");
// 퍼블릭 체크박스 토큰 스타일에는 borderRadius/borderWidth 를 제외하고 px 단위가 없어야 한다.
expectNoPxInStyleObject(tokens.groupStyle);
expectNoPxInStyleObject(tokens.optionsStyle);
expectNoPxInStyleObject(tokens.optionContainerStyle, { allowKeys: ["borderRadius", "borderWidth"] });
expectNoPxInStyleObject(tokens.groupTextStyle);
expectNoPxInStyleObject(tokens.optionTextStyle);
});
it("computeFormRadioPublicTokens: 퍼블릭 라디오 그룹 스타일을 계산해야 한다", () => {
+14 -1
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { expectNoPxInStyleParts } from "./styleInvariants";
import {
computeImageExportStyles,
computeImageEditorTokens,
@@ -28,7 +29,7 @@ describe("imageHelpers.computeImageExportStyles", () => {
widthPx: 320,
});
expect(imgStyleParts).toContain("width:320px");
expect(imgStyleParts).toContain("width:20em");
});
it("borderRadius 토큰이 full 이면 border-radius 9999px 를 사용해야 한다", () => {
@@ -47,6 +48,18 @@ describe("imageHelpers.computeImageExportStyles", () => {
expect(imgStyleParts).toContain("border-radius:20px");
});
it("Export 이미지 스타일에는 border-radius 를 제외하고 px 단위 값이 포함되지 않아야 한다", () => {
const { wrapperStyleParts, imgStyleParts } = computeImageExportStyles({
backgroundColorCustom: "#123456",
widthMode: "fixed",
widthPx: 320,
borderRadius: "lg",
});
expectNoPxInStyleParts(wrapperStyleParts);
expectNoPxInStyleParts(imgStyleParts, { allowPatterns: [/border-radius:\d+px/] });
});
});
describe("computeImageEditorTokens", () => {
+36 -1
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { expectNoPxInStyleObject, expectNoPxInStyleParts } from "./styleInvariants";
import {
computeListExportTokens,
computeListEditorTokens,
@@ -81,6 +82,17 @@ describe("listHelpers.computeListExportTokens", () => {
const tokens = computeListExportTokens({ ...baseProps });
expect(tokens.items).toEqual([]);
});
it("Export 리스트 listStyleParts 에는 px 단위 값이 포함되지 않아야 한다", () => {
const tokens = computeListExportTokens({
...baseProps,
align: "center",
items: ["a"],
backgroundColorCustom: "#123456",
});
expectNoPxInStyleParts(tokens.listStyleParts);
});
});
describe("listHelpers.computeListEditorTokens", () => {
@@ -158,17 +170,28 @@ describe("listHelpers.computeListPublicTokens", () => {
expect(byTokenLg.gapEm).toBe(16 / 16);
});
it("fontSizeCustom 이 px 인 경우 em 단위로 변환되어야 하고, backgroundColorCustom 은 trim 되어야 한다", () => {
it("fontSizeCustom / lineHeightCustom 이 px 인 경우 em 단위로 변환되어야 하고, backgroundColorCustom 은 trim 되어야 한다", () => {
const tokens = computeListPublicTokens({
...baseProps,
fontSizeCustom: "32px",
lineHeightCustom: "24px",
backgroundColorCustom: " #123456 ",
});
expect(tokens.listStyle.fontSize).toBe("2em");
expect(tokens.listStyle.lineHeight).toBe("1.5em");
expect(tokens.listStyle.backgroundColor).toBe("#123456");
});
it("lineHeightCustom 이 px 인 경우 em 단위로 변환되어야 한다", () => {
const tokens = computeListPublicTokens({
...baseProps,
lineHeightCustom: "24px",
});
expect(tokens.listStyle.lineHeight).toBe("1.5em");
});
it("bulletStyle 은 bulletStyle 지정값 또는 ordered 여부에 따라 결정되어야 한다", () => {
const defaultDisc = computeListPublicTokens({ ...baseProps, ordered: false, bulletStyle: undefined });
const defaultDecimal = computeListPublicTokens({ ...baseProps, ordered: true, bulletStyle: undefined });
@@ -178,4 +201,16 @@ describe("listHelpers.computeListPublicTokens", () => {
expect(defaultDecimal.bulletStyle).toBe("decimal");
expect(none.bulletStyle).toBe("none");
});
it("퍼블릭 리스트 style 에 px 단위 값이 포함되지 않아야 한다", () => {
const tokens = computeListPublicTokens({
...baseProps,
fontSizeCustom: "32px",
lineHeightCustom: "24px",
textColorCustom: "#ff0000",
backgroundColorCustom: "#123456",
});
expectNoPxInStyleObject(tokens.listStyle);
});
});
+70 -3
View File
@@ -181,9 +181,6 @@ describe("프리뷰와 정적 내보내기 일치 (고수준)", () => {
"섹션 안 텍스트",
"섹션 버튼",
"리스트 아이템 1",
"폼 이름",
"폼 이메일",
"폼 메시지",
"입력(단독)",
"선택(단독)",
"체크 A",
@@ -195,4 +192,74 @@ describe("프리뷰와 정적 내보내기 일치 (고수준)", () => {
expect(staticHtml).toContain(text);
}
});
it(
"리스트+폼+구분선 조합에서 리스트 컨테이너와 구분선이 프리뷰/정적 HTML 모두에 존재하고, 리스트는 pb-list 클래스를 사용하며 li 에 margin-bottom 인라인 스타일을 사용하지 않아야 한다",
() => {
const blocks: Block[] = [
{
id: "divider_combo",
type: "divider",
props: {
align: "center",
thickness: "medium",
widthMode: "full",
},
} as any,
{
id: "list_combo",
type: "list",
props: {
items: ["리스트 아이템 1", "리스트 아이템 2"],
ordered: false,
align: "left",
},
} as any,
{
id: "form_combo",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
// v1 fallback 필드를 사용해 양쪽 렌더러가 동일한 폼 필드를 가지도록 한다.
fields: [
{ id: "f_name", name: "name", label: "이름", type: "text", required: true },
],
fieldIds: [],
submitButtonId: null,
formWidthMode: "full",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "리스트+폼+구분선 레이아웃 테스트",
slug: "list-form-divider-layout",
canvasPreset: "full",
};
const previewHtml = ReactDOMServer.renderToString(
React.createElement(PublicPageRenderer, { blocks }),
);
const staticHtml = buildStaticHtml(blocks, projectConfig);
// 리스트 텍스트는 양쪽에 존재해야 한다.
expect(previewHtml).toContain("리스트 아이템 1");
expect(staticHtml).toContain("리스트 아이템 1");
// 구분선은 프리뷰/Export 모두에 존재해야 한다.
expect(previewHtml).toContain('data-testid="preview-divider"');
expect(staticHtml).toContain('<hr class="pb-divider"');
// 리스트 컨테이너는 pb-list 클래스를 사용해야 한다.
expect(previewHtml).toContain('class="pb-list');
expect(staticHtml).toContain('class="pb-list"');
// 프리뷰에서는 리스트 gap 이 li 의 margin-bottom 인라인 스타일로 표현된다.
// Export HTML 은 여전히 li 인라인 margin-bottom 을 사용하지 않고 CSS 에서 처리한다.
expect(previewHtml).toMatch(/<li[^>]*style=\"[^\"]*margin-bottom:/);
expect(staticHtml).not.toMatch(/<li[^>]*style=\"[^\"]*margin-bottom:/);
},
);
});
+21
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { expectNoPxInStyleObject, expectNoPxInStyleParts } from "./styleInvariants";
import {
computeSectionExportTokens,
computeSectionPublicTokens,
@@ -73,6 +74,21 @@ describe("sectionHelpers.computeSectionExportTokens", () => {
expect(tokens.sectionStyleParts).toContain("position:relative");
expect(tokens.sectionStyleParts).toContain("overflow:hidden");
});
it("Export 섹션 sectionStyleParts 에는 px 단위 값이 포함되지 않아야 한다", () => {
const tokens = computeSectionExportTokens({
...baseProps,
backgroundColorCustom: "#123456",
backgroundImageSrc: "https://example.com/bg.png",
backgroundImageSize: "cover",
backgroundImagePositionMode: "preset",
backgroundImagePosition: "top",
backgroundImageRepeat: "repeat-x",
backgroundVideoSrc: "https://example.com/bg.mp4",
} as SectionBlockProps);
expectNoPxInStyleParts(tokens.sectionStyleParts);
});
});
describe("sectionHelpers.computeSectionPublicTokens", () => {
@@ -90,6 +106,11 @@ describe("sectionHelpers.computeSectionPublicTokens", () => {
expect(tokens.sectionStyle.paddingBottom).toBe("2em");
expect(tokens.innerWrapperStyle.maxWidth).toBe("50em");
expect(tokens.columnsContainerStyle.columnGap).toBe("1.5em");
// 퍼블릭 섹션 토큰 스타일에는 px 단위 값이 포함되지 않아야 한다.
expectNoPxInStyleObject(tokens.sectionStyle);
expectNoPxInStyleObject(tokens.innerWrapperStyle);
expectNoPxInStyleObject(tokens.columnsContainerStyle);
});
it("backgroundImage* 와 backgroundVideoSrc 를 올바르게 반영해야 한다", () => {
+3 -3
View File
@@ -16,7 +16,7 @@ describe("getSectionLayoutConfig", () => {
const cfg = getSectionLayoutConfig(baseProps);
expect(cfg.backgroundClass).toBe("bg-slate-950");
expect(cfg.paddingYClass).toBe("py-12");
expect(cfg.paddingYClass).toBe("py-6");
expect(cfg.maxWidthClass).toBe("max-w-5xl");
expect(cfg.gapXClass).toBe("gap-8");
expect(cfg.alignItemsClass).toBe("items-start");
@@ -49,7 +49,7 @@ describe("getSectionLayoutConfig", () => {
expect(narrowCfg.maxWidthClass).toBe("max-w-3xl");
expect(narrowCfg.gapXClass).toBe("gap-4");
expect(narrowCfg.alignItemsClass).toBe("items-end");
expect(narrowCfg.paddingYClass).toBe("py-8");
expect(narrowCfg.paddingYClass).toBe("py-4");
});
it("primary 배경과 큰 패딩에 대해 올바른 클래스를 반환해야 한다", () => {
@@ -61,6 +61,6 @@ describe("getSectionLayoutConfig", () => {
const cfg = getSectionLayoutConfig(primaryLarge);
expect(cfg.backgroundClass).toBe("bg-sky-900");
expect(cfg.paddingYClass).toBe("py-20");
expect(cfg.paddingYClass).toBe("py-10");
});
});
+34
View File
@@ -0,0 +1,34 @@
import { expect } from "vitest";
// 스타일 관련 인변언트(전역 규칙)를 검증하기 위한 공통 테스트 유틸리티.
// - 퍼블릭/익스포트 토큰에서 px 단위 값 사용을 금지하거나,
// - border-width 등 꼭 필요한 px 사용만 예외로 허용할 때 사용한다.
export type StyleObject = Record<string, unknown>;
export function expectNoPxInStyleObject(style: unknown, options?: { allowKeys?: string[] }) {
if (!style || typeof style !== "object") return;
const allow = new Set(options?.allowKeys ?? []);
for (const [key, value] of Object.entries(style as StyleObject)) {
if (value == null) continue;
if (allow.has(key)) continue;
if (typeof value === "object") continue;
const str = String(value);
expect(str).not.toContain("px");
}
}
export function expectNoPxInStyleString(style: string | undefined | null, options?: { allowPatterns?: RegExp[] }) {
if (!style) return;
const allowPatterns = options?.allowPatterns ?? [];
if (allowPatterns.some((re) => re.test(style))) return;
expect(style).not.toContain("px");
}
export function expectNoPxInStyleParts(parts: string[] | undefined | null, options?: { allowPatterns?: RegExp[] }) {
if (!parts || parts.length === 0) return;
const combined = parts.join(";");
expectNoPxInStyleString(combined, options);
}
+38 -1
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { expectNoPxInStyleObject, expectNoPxInStyleParts } from "./styleInvariants";
import {
computeTextPbTokens,
computeTextEditorTokens,
@@ -68,6 +69,17 @@ describe("textHelpers.computeTextPbTokens", () => {
expect(prose.maxWidthClass).toBe("pb-text-maxw-prose");
expect(narrow.maxWidthClass).toBe("pb-text-maxw-narrow");
});
it("텍스트 Pb inlineStyles 에는 px 단위 값이 포함되지 않아야 한다", () => {
const tokens = computeTextPbTokens({
...baseProps,
colorMode: "custom",
colorCustom: "#ff0000",
backgroundColorCustom: "#123456",
});
expectNoPxInStyleParts(tokens.inlineStyles);
});
});
describe("textHelpers.computeTextEditorTokens", () => {
@@ -128,7 +140,7 @@ describe("textHelpers.computeTextPublicTokens", () => {
} as TextBlockProps);
expect(tokens.sizeClass).toBe("");
expect(tokens.styleOverrides.fontSize).toBe("24px");
expect(tokens.styleOverrides.fontSize).toBe("1.5em");
});
it("colorCustom / backgroundColorCustom 이 styleOverrides 에 반영되어야 한다", () => {
@@ -141,4 +153,29 @@ describe("textHelpers.computeTextPublicTokens", () => {
expect(tokens.styleOverrides.color).toBe("#ff0000");
expect(tokens.styleOverrides.backgroundColor).toBe("#123456");
});
it("lineHeightCustom / letterSpacingCustom \uc774 px \uc778 \uacbd\uc6b0 em \ub2e8\uc704 \uc2a4\ud0c0\uc77c\ub85c \ubcc0\ud658\ub418\uc5b4\uc57c \ud55c\ub2e4", () => {
const tokens = computeTextPublicTokens({
...baseBlockProps,
lineHeightCustom: "24px",
letterSpacingCustom: "2px",
} as TextBlockProps);
expect(tokens.styleOverrides.lineHeight).toBe("1.5em");
expect(tokens.styleOverrides.letterSpacing).toBe("0.125em");
});
it("퍼블릭 텍스트 styleOverrides 에 px 단위 값이 포함되지 않아야 한다", () => {
const tokens = computeTextPublicTokens({
...baseBlockProps,
fontSizeMode: "custom",
fontSizeCustom: "24px",
lineHeightCustom: "24px",
letterSpacingCustom: "2px",
colorCustom: "#ff0000",
backgroundColorCustom: "#123456",
} as TextBlockProps);
expectNoPxInStyleObject(tokens.styleOverrides);
});
});
+24 -5
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { expectNoPxInStyleParts } from "./styleInvariants";
import {
normalizeVideoSourceUrl,
resolveVideoPlatform,
@@ -124,7 +125,7 @@ describe("videoHelpers.computeVideoExportTokens", () => {
const escapers = { escapeAttr: (value: string) => value };
it("fixed width / 카드 스타일을 px 단위 스타일 파트로 계산해야 한다", () => {
it("fixed width / 카드 스타일을 em 단위 스타일 파트로 계산해야 한다", () => {
const tokens = computeVideoExportTokens(
{
...baseProps,
@@ -139,15 +140,33 @@ describe("videoHelpers.computeVideoExportTokens", () => {
);
expect(tokens.aspectClass).toBe("");
expect(tokens.wrapperStyleParts).toContain("width:640px");
expect(tokens.videoStyleParts).toContain("width:640px");
expect(tokens.wrapperStyleParts).toContain("width:40em");
expect(tokens.videoStyleParts).toContain("width:40em");
expect(tokens.wrapperStyleParts).toContain("background-color:#123456");
expect(tokens.wrapperStyleParts).toContain("padding:24px");
expect(tokens.wrapperStyleParts).toContain("border-radius:16px");
expect(tokens.wrapperStyleParts).toContain("padding:1.5em");
expect(tokens.wrapperStyleParts).toContain("border-radius:1em");
expect(tokens.outerStyleParts).toContain("display:flex");
expect(tokens.outerStyleParts).toContain("justify-content:flex-start");
});
it("Export 비디오 wrapper/video 스타일 파트에는 px 단위 값이 포함되지 않아야 한다", () => {
const tokens = computeVideoExportTokens(
{
...baseProps,
align: "left",
widthMode: "fixed",
widthPx: 640,
backgroundColorCustom: "#123456",
cardPaddingPx: 24,
borderRadiusPx: 16,
} as any,
escapers,
);
expectNoPxInStyleParts(tokens.wrapperStyleParts);
expectNoPxInStyleParts(tokens.videoStyleParts);
});
it("widthMode 가 full 이면 width:100% 로 설정하고 align 에 따라 justify-content 를 계산해야 한다", () => {
const right = computeVideoExportTokens(
{
@@ -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 @@
dummy-video
@@ -0,0 +1 @@
dummy-video
@@ -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 @@
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 @@
dummy-video
@@ -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 @@
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

Some files were not shown because too many files have changed in this diff Show More