1차 싱크 완료
This commit is contained in:
+309
-20
@@ -119,7 +119,9 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
? weightMap[fontWeightScale]
|
? weightMap[fontWeightScale]
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
let colorClass = "";
|
let colorClass = "pb-text-color-strong";
|
||||||
|
const inlineStyles: string[] = [];
|
||||||
|
|
||||||
if (props.colorMode === "palette") {
|
if (props.colorMode === "palette") {
|
||||||
const palette = props.colorPalette ?? "default";
|
const palette = props.colorPalette ?? "default";
|
||||||
const paletteMap: Record<NonNullable<TextBlockProps["colorPalette"]>, string> = {
|
const paletteMap: Record<NonNullable<TextBlockProps["colorPalette"]>, string> = {
|
||||||
@@ -133,7 +135,18 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
info: "pb-text-color-info",
|
info: "pb-text-color-info",
|
||||||
neutral: "pb-text-color-neutral",
|
neutral: "pb-text-color-neutral",
|
||||||
};
|
};
|
||||||
colorClass = paletteMap[palette] ?? "";
|
colorClass = paletteMap[palette] ?? colorClass;
|
||||||
|
} else if (
|
||||||
|
props.colorMode === "custom" &&
|
||||||
|
typeof props.colorCustom === "string" &&
|
||||||
|
props.colorCustom.trim() !== ""
|
||||||
|
) {
|
||||||
|
inlineStyles.push(`color:${props.colorCustom.trim()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 블록 배경색: backgroundColorCustom 이 설정된 경우에만 적용한다.
|
||||||
|
if (typeof (props as any).backgroundColorCustom === "string" && (props as any).backgroundColorCustom.trim() !== "") {
|
||||||
|
inlineStyles.push(`background-color:${(props as any).backgroundColorCustom.trim()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let maxWidthClass = "";
|
let maxWidthClass = "";
|
||||||
@@ -159,12 +172,15 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
weightClass,
|
weightClass,
|
||||||
colorClass,
|
colorClass,
|
||||||
maxWidthClass,
|
maxWidthClass,
|
||||||
|
"pb-whitespace-pre-wrap",
|
||||||
...decoClasses,
|
...decoClasses,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
return `<p class="${classes}">${escapeHtml(text)}</p>`;
|
const styleAttr = inlineStyles.length > 0 ? ` style="${inlineStyles.join(";")}"` : "";
|
||||||
|
|
||||||
|
return `<p class="${classes}"${styleAttr}>${escapeHtml(text)}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === "button") {
|
if (block.type === "button") {
|
||||||
@@ -238,7 +254,42 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
const props: any = block.props ?? {};
|
const props: any = block.props ?? {};
|
||||||
const src = typeof props.src === "string" ? props.src : "";
|
const src = typeof props.src === "string" ? props.src : "";
|
||||||
const alt = typeof props.alt === "string" ? props.alt : "";
|
const alt = typeof props.alt === "string" ? props.alt : "";
|
||||||
return `<div><img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}" /></div>`;
|
|
||||||
|
const wrapperStyleParts: string[] = [];
|
||||||
|
const imgStyleParts: string[] = [];
|
||||||
|
|
||||||
|
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||||
|
wrapperStyleParts.push(`background-color:${escapeAttr(props.backgroundColorCustom.trim())}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthMode = props.widthMode ?? "auto";
|
||||||
|
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||||
|
imgStyleParts.push(`width:${props.widthPx}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const radiusToken = props.borderRadius ?? "md";
|
||||||
|
const fallbackRadiusPx =
|
||||||
|
radiusToken === "none"
|
||||||
|
? 0
|
||||||
|
: radiusToken === "sm"
|
||||||
|
? 4
|
||||||
|
: radiusToken === "lg"
|
||||||
|
? 16
|
||||||
|
: radiusToken === "full"
|
||||||
|
? 9999
|
||||||
|
: 8;
|
||||||
|
const radiusPx =
|
||||||
|
typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0
|
||||||
|
? props.borderRadiusPx
|
||||||
|
: fallbackRadiusPx;
|
||||||
|
if (typeof radiusPx === "number" && radiusPx >= 0) {
|
||||||
|
imgStyleParts.push(`border-radius:${radiusPx === 9999 ? "9999px" : `${radiusPx}px`}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapperStyleAttr = wrapperStyleParts.length > 0 ? ` style="${wrapperStyleParts.join(";")}"` : "";
|
||||||
|
const imgStyleAttr = imgStyleParts.length > 0 ? ` style="${imgStyleParts.join(";")}"` : "";
|
||||||
|
|
||||||
|
return `<div${wrapperStyleAttr}><img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}"${imgStyleAttr} /></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === "form") {
|
if (block.type === "form") {
|
||||||
@@ -261,7 +312,13 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
const fields = controllerFields.length > 0 ? controllerFields : null;
|
const fields = controllerFields.length > 0 ? controllerFields : null;
|
||||||
|
|
||||||
const formParts: string[] = [];
|
const formParts: string[] = [];
|
||||||
formParts.push(`<form class="pb-form" method="post">`);
|
const formStyleParts: string[] = [];
|
||||||
|
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||||
|
formStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
|
||||||
|
}
|
||||||
|
const formStyleAttr = formStyleParts.length > 0 ? ` style="${formStyleParts.join(";")}"` : "";
|
||||||
|
|
||||||
|
formParts.push(`<form class="pb-form" method="post"${formStyleAttr}>`);
|
||||||
|
|
||||||
if (fields) {
|
if (fields) {
|
||||||
for (const fieldBlock of fields) {
|
for (const fieldBlock of fields) {
|
||||||
@@ -272,8 +329,15 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
? (anyProps.label ?? anyProps.groupLabel)
|
? (anyProps.label ?? anyProps.groupLabel)
|
||||||
: name;
|
: 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(`<div class="pb-form-field">`);
|
||||||
formParts.push(`<label class="pb-form-label">${escapeHtml(label ?? "")}</label>`);
|
formParts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label ?? "")}</label>`);
|
||||||
|
|
||||||
if (fieldBlock.type === "formInput") {
|
if (fieldBlock.type === "formInput") {
|
||||||
const type = anyProps.inputType === "email" ? "email" : "text";
|
const type = anyProps.inputType === "email" ? "email" : "text";
|
||||||
@@ -281,12 +345,12 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
formParts.push(
|
formParts.push(
|
||||||
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
|
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
|
||||||
label ?? "",
|
label ?? "",
|
||||||
)}"${required} />`,
|
)}"${required}${fieldTextStyleAttr} />`,
|
||||||
);
|
);
|
||||||
} else if (fieldBlock.type === "formSelect") {
|
} else if (fieldBlock.type === "formSelect") {
|
||||||
const required = anyProps.required ? " required" : "";
|
const required = anyProps.required ? " required" : "";
|
||||||
const options: FormSelectOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
|
const options: FormSelectOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||||
formParts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}>`);
|
formParts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}${fieldTextStyleAttr}>`);
|
||||||
for (const opt of options) {
|
for (const opt of options) {
|
||||||
formParts.push(
|
formParts.push(
|
||||||
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
|
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
|
||||||
@@ -297,7 +361,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
const options = Array.isArray(anyProps.options) ? anyProps.options : [];
|
const options = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||||
for (const opt of options) {
|
for (const opt of options) {
|
||||||
formParts.push(
|
formParts.push(
|
||||||
`<label class="pb-form-option"><input type="checkbox" name="${escapeAttr(
|
`<label class="pb-form-option"${fieldTextStyleAttr}><input type="checkbox" name="${escapeAttr(
|
||||||
name,
|
name,
|
||||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||||
);
|
);
|
||||||
@@ -306,7 +370,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
const options = Array.isArray(anyProps.options) ? anyProps.options : [];
|
const options = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||||
for (const opt of options) {
|
for (const opt of options) {
|
||||||
formParts.push(
|
formParts.push(
|
||||||
`<label class="pb-form-option"><input type="radio" name="${escapeAttr(
|
`<label class="pb-form-option"${fieldTextStyleAttr}><input type="radio" name="${escapeAttr(
|
||||||
name,
|
name,
|
||||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||||
);
|
);
|
||||||
@@ -386,7 +450,14 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
|
|
||||||
const align = props.align === "center" ? "center" : props.align === "right" ? "right" : "left";
|
const align = props.align === "center" ? "center" : props.align === "right" ? "right" : "left";
|
||||||
const lis = items.map((text) => `<li>${escapeHtml(text)}</li>`).join("");
|
const lis = items.map((text) => `<li>${escapeHtml(text)}</li>`).join("");
|
||||||
return `<${Tag} class="pb-list" style="text-align:${align};">${lis}</${Tag}>`;
|
|
||||||
|
const listStyleParts: string[] = [`text-align:${align}`];
|
||||||
|
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||||
|
listStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
|
||||||
|
}
|
||||||
|
const listStyleAttr = listStyleParts.join(";");
|
||||||
|
|
||||||
|
return `<${Tag} class="pb-list" style="${listStyleAttr}">${lis}</${Tag}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === "formInput") {
|
if (block.type === "formInput") {
|
||||||
@@ -397,12 +468,69 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
const type = props.inputType === "email" ? "email" : "text";
|
const type = props.inputType === "email" ? "email" : "text";
|
||||||
const required = props.required ? " required" : "";
|
const required = props.required ? " required" : "";
|
||||||
|
|
||||||
|
const textColorRaw =
|
||||||
|
typeof props.textColorCustom === "string" && props.textColorCustom.trim() !== ""
|
||||||
|
? props.textColorCustom.trim()
|
||||||
|
: "";
|
||||||
|
const labelStyleAttr = textColorRaw ? ` style=\"color:${escapeAttr(textColorRaw)}\"` : "";
|
||||||
|
|
||||||
|
const inputStyleParts: string[] = [];
|
||||||
|
if (textColorRaw) {
|
||||||
|
inputStyleParts.push(`color:${escapeAttr(textColorRaw)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
|
||||||
|
inputStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") {
|
||||||
|
inputStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||||
|
const px = props.paddingX;
|
||||||
|
inputStyleParts.push(`padding-left:${px}px`);
|
||||||
|
inputStyleParts.push(`padding-right:${px}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 widthMode =
|
||||||
|
typeof props.widthMode === "string"
|
||||||
|
? props.widthMode
|
||||||
|
: props.fullWidth
|
||||||
|
? "full"
|
||||||
|
: "auto";
|
||||||
|
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||||
|
inputStyleParts.push(`width:${props.widthPx}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const radiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md";
|
||||||
|
const radiusPx =
|
||||||
|
radiusToken === "none"
|
||||||
|
? 0
|
||||||
|
: radiusToken === "sm"
|
||||||
|
? 2
|
||||||
|
: radiusToken === "lg"
|
||||||
|
? 6
|
||||||
|
: radiusToken === "full"
|
||||||
|
? 9999
|
||||||
|
: 4;
|
||||||
|
inputStyleParts.push(`border-radius:${radiusPx}px`);
|
||||||
|
|
||||||
|
const inputStyleAttr =
|
||||||
|
inputStyleParts.length > 0 ? ` style=\"${inputStyleParts.join(";")}\"` : "";
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'<div class="pb-form-field">',
|
'<div class="pb-form-field">',
|
||||||
`<label class="pb-form-label">${escapeHtml(label)}</label>`,
|
`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label)}</label>`,
|
||||||
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
|
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
|
||||||
label,
|
label,
|
||||||
)}"${required} />`,
|
)}"${required}${inputStyleAttr} />`,
|
||||||
"</div>",
|
"</div>",
|
||||||
].join("");
|
].join("");
|
||||||
}
|
}
|
||||||
@@ -415,10 +543,67 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
const required = props.required ? " required" : "";
|
const required = props.required ? " required" : "";
|
||||||
const options: FormSelectOption[] = Array.isArray(props.options) ? props.options : [];
|
const options: FormSelectOption[] = Array.isArray(props.options) ? props.options : [];
|
||||||
|
|
||||||
|
const textColorRaw =
|
||||||
|
typeof props.textColorCustom === "string" && props.textColorCustom.trim() !== ""
|
||||||
|
? props.textColorCustom.trim()
|
||||||
|
: "";
|
||||||
|
const labelStyleAttr = textColorRaw ? ` style=\"color:${escapeAttr(textColorRaw)}\"` : "";
|
||||||
|
|
||||||
|
const selectStyleParts: string[] = [];
|
||||||
|
if (textColorRaw) {
|
||||||
|
selectStyleParts.push(`color:${escapeAttr(textColorRaw)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthMode =
|
||||||
|
typeof props.widthMode === "string"
|
||||||
|
? props.widthMode
|
||||||
|
: props.fullWidth
|
||||||
|
? "full"
|
||||||
|
: "auto";
|
||||||
|
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||||
|
selectStyleParts.push(`width:${props.widthPx}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||||
|
const px = props.paddingX;
|
||||||
|
selectStyleParts.push(`padding-left:${px}px`);
|
||||||
|
selectStyleParts.push(`padding-right:${px}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||||
|
const py = props.paddingY;
|
||||||
|
selectStyleParts.push(`padding-top:${py}px`);
|
||||||
|
selectStyleParts.push(`padding-bottom:${py}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
|
||||||
|
selectStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") {
|
||||||
|
selectStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const radiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md";
|
||||||
|
const radiusPx =
|
||||||
|
radiusToken === "none"
|
||||||
|
? 0
|
||||||
|
: radiusToken === "sm"
|
||||||
|
? 2
|
||||||
|
: radiusToken === "lg"
|
||||||
|
? 6
|
||||||
|
: radiusToken === "full"
|
||||||
|
? 9999
|
||||||
|
: 4;
|
||||||
|
selectStyleParts.push(`border-radius:${radiusPx}px`);
|
||||||
|
|
||||||
|
const selectStyleAttr =
|
||||||
|
selectStyleParts.length > 0 ? ` style=\"${selectStyleParts.join(";")}\"` : "";
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
parts.push('<div class="pb-form-field">');
|
parts.push('<div class="pb-form-field">');
|
||||||
parts.push(`<label class="pb-form-label">${escapeHtml(label)}</label>`);
|
parts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||||
parts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}>`);
|
parts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}${selectStyleAttr}>`);
|
||||||
for (const opt of options) {
|
for (const opt of options) {
|
||||||
parts.push(
|
parts.push(
|
||||||
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
|
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
|
||||||
@@ -439,12 +624,61 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
: name;
|
: name;
|
||||||
const options = Array.isArray(props.options) ? props.options : [];
|
const options = Array.isArray(props.options) ? props.options : [];
|
||||||
|
|
||||||
|
const textColorRaw =
|
||||||
|
typeof props.textColorCustom === "string" && props.textColorCustom.trim() !== ""
|
||||||
|
? props.textColorCustom.trim()
|
||||||
|
: "";
|
||||||
|
const labelStyleAttr = textColorRaw ? ` style=\"color:${escapeAttr(textColorRaw)}\"` : "";
|
||||||
|
|
||||||
|
const optionStyleParts: string[] = [];
|
||||||
|
if (textColorRaw) {
|
||||||
|
optionStyleParts.push(`color:${escapeAttr(textColorRaw)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||||
|
const px = props.paddingX;
|
||||||
|
optionStyleParts.push(`padding-left:${px}px`);
|
||||||
|
optionStyleParts.push(`padding-right:${px}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||||
|
const py = props.paddingY;
|
||||||
|
optionStyleParts.push(`padding-top:${py}px`);
|
||||||
|
optionStyleParts.push(`padding-bottom:${py}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
|
||||||
|
optionStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") {
|
||||||
|
optionStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`);
|
||||||
|
optionStyleParts.push("border-width:1px");
|
||||||
|
optionStyleParts.push("border-style:solid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkboxRadiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md";
|
||||||
|
const checkboxRadiusPx =
|
||||||
|
checkboxRadiusToken === "none"
|
||||||
|
? 0
|
||||||
|
: checkboxRadiusToken === "sm"
|
||||||
|
? 2
|
||||||
|
: checkboxRadiusToken === "lg"
|
||||||
|
? 6
|
||||||
|
: checkboxRadiusToken === "full"
|
||||||
|
? 9999
|
||||||
|
: 4;
|
||||||
|
optionStyleParts.push(`border-radius:${checkboxRadiusPx}px`);
|
||||||
|
|
||||||
|
const optionStyleAttr =
|
||||||
|
optionStyleParts.length > 0 ? ` style=\"${optionStyleParts.join(";")}\"` : "";
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
parts.push('<div class="pb-form-field">');
|
parts.push('<div class="pb-form-field">');
|
||||||
parts.push(`<label class="pb-form-label">${escapeHtml(label)}</label>`);
|
parts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||||
for (const opt of options) {
|
for (const opt of options) {
|
||||||
parts.push(
|
parts.push(
|
||||||
`<label class="pb-form-option"><input type="checkbox" name="${escapeAttr(
|
`<label class="pb-form-option"${optionStyleAttr}><input type="checkbox" name="${escapeAttr(
|
||||||
name,
|
name,
|
||||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||||
);
|
);
|
||||||
@@ -463,12 +697,61 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
: name;
|
: name;
|
||||||
const options = Array.isArray(props.options) ? props.options : [];
|
const options = Array.isArray(props.options) ? props.options : [];
|
||||||
|
|
||||||
|
const textColorRaw =
|
||||||
|
typeof props.textColorCustom === "string" && props.textColorCustom.trim() !== ""
|
||||||
|
? props.textColorCustom.trim()
|
||||||
|
: "";
|
||||||
|
const labelStyleAttr = textColorRaw ? ` style=\"color:${escapeAttr(textColorRaw)}\"` : "";
|
||||||
|
|
||||||
|
const optionStyleParts: string[] = [];
|
||||||
|
if (textColorRaw) {
|
||||||
|
optionStyleParts.push(`color:${escapeAttr(textColorRaw)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||||
|
const px = props.paddingX;
|
||||||
|
optionStyleParts.push(`padding-left:${px}px`);
|
||||||
|
optionStyleParts.push(`padding-right:${px}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||||
|
const py = props.paddingY;
|
||||||
|
optionStyleParts.push(`padding-top:${py}px`);
|
||||||
|
optionStyleParts.push(`padding-bottom:${py}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
|
||||||
|
optionStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") {
|
||||||
|
optionStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`);
|
||||||
|
optionStyleParts.push("border-width:1px");
|
||||||
|
optionStyleParts.push("border-style:solid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const radioRadiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md";
|
||||||
|
const radioRadiusPx =
|
||||||
|
radioRadiusToken === "none"
|
||||||
|
? 0
|
||||||
|
: radioRadiusToken === "sm"
|
||||||
|
? 2
|
||||||
|
: radioRadiusToken === "lg"
|
||||||
|
? 6
|
||||||
|
: radioRadiusToken === "full"
|
||||||
|
? 9999
|
||||||
|
: 4;
|
||||||
|
optionStyleParts.push(`border-radius:${radioRadiusPx}px`);
|
||||||
|
|
||||||
|
const optionStyleAttr =
|
||||||
|
optionStyleParts.length > 0 ? ` style=\"${optionStyleParts.join(";")}\"` : "";
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
parts.push('<div class="pb-form-field">');
|
parts.push('<div class="pb-form-field">');
|
||||||
parts.push(`<label class="pb-form-label">${escapeHtml(label)}</label>`);
|
parts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||||
for (const opt of options) {
|
for (const opt of options) {
|
||||||
parts.push(
|
parts.push(
|
||||||
`<label class="pb-form-option"><input type="radio" name="${escapeAttr(
|
`<label class="pb-form-option"${optionStyleAttr}><input type="radio" name="${escapeAttr(
|
||||||
name,
|
name,
|
||||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||||
);
|
);
|
||||||
@@ -515,8 +798,14 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
const pyClass = pyClassMap[pyToken] ?? "pb-section-py-md";
|
const pyClass = pyClassMap[pyToken] ?? "pb-section-py-md";
|
||||||
|
|
||||||
const sectionClasses = ["pb-section", bgClass, pyClass].filter(Boolean).join(" ");
|
const sectionClasses = ["pb-section", bgClass, pyClass].filter(Boolean).join(" ");
|
||||||
|
const sectionStyleParts: string[] = [];
|
||||||
|
// 섹션 커스텀 배경색: backgroundColorCustom 이 설정된 경우에만 인라인 스타일로 적용하여 토큰 기반 배경 클래스를 오버라이드한다.
|
||||||
|
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||||
|
sectionStyleParts.push(`background-color:${escapeAttr(props.backgroundColorCustom.trim())}`);
|
||||||
|
}
|
||||||
|
const sectionStyleAttr = sectionStyleParts.length > 0 ? ` style="${sectionStyleParts.join(";")}"` : "";
|
||||||
|
|
||||||
bodyParts.push(`<section class="${sectionClasses}"><div class="pb-section-inner"><div class="pb-section-columns">`);
|
bodyParts.push(`<section class="${sectionClasses}"${sectionStyleAttr}><div class="pb-section-inner"><div class="pb-section-columns">`);
|
||||||
for (const col of columns) {
|
for (const col of columns) {
|
||||||
const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id);
|
const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id);
|
||||||
bodyParts.push('<div class="pb-section-column">');
|
bodyParts.push('<div class="pb-section-column">');
|
||||||
|
|||||||
@@ -21,19 +21,24 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const submitTarget = config?.submitTarget ?? "internal";
|
const submitTarget = config?.submitTarget ?? "internal";
|
||||||
|
const successMessage = config?.successMessage;
|
||||||
|
const errorMessage = config?.errorMessage;
|
||||||
|
|
||||||
// 1) internal: 우리 서버에서 직접 처리하는 기본 모드
|
// 1) internal: 우리 서버에서 직접 처리하는 기본 모드
|
||||||
if (submitTarget === "internal") {
|
if (submitTarget === "internal") {
|
||||||
// TODO: 이곳에서 DB 저장이나 이메일 전송 등 실제 처리를 수행한다.
|
// TODO: 이곳에서 DB 저장이나 이메일 전송 등 실제 처리를 수행한다.
|
||||||
console.log("[forms/submit][internal]", { name, email, message });
|
console.log("[forms/submit][internal]", { name, email, message });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true, message: successMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) webhook: destinationUrl 로 서버에서 POST (Google Sheets 등 포함)
|
// 2) webhook: destinationUrl 로 서버에서 POST (Google Sheets 등 포함)
|
||||||
if (submitTarget === "webhook") {
|
if (submitTarget === "webhook") {
|
||||||
const destinationUrl = config?.destinationUrl;
|
const destinationUrl = config?.destinationUrl;
|
||||||
if (!destinationUrl) {
|
if (!destinationUrl) {
|
||||||
return NextResponse.json({ ok: false, error: "destinationUrl_missing" }, { status: 400 });
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "destinationUrl_missing", message: errorMessage },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadFormat = config?.payloadFormat ?? "form";
|
const payloadFormat = config?.payloadFormat ?? "form";
|
||||||
@@ -87,19 +92,25 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error("[forms/submit][webhook] remote error", res.status, await res.text().catch(() => ""));
|
console.error("[forms/submit][webhook] remote error", res.status, await res.text().catch(() => ""));
|
||||||
return NextResponse.json({ ok: false, error: "webhook_failed" }, { status: 502 });
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "webhook_failed", message: errorMessage },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[forms/submit][webhook] fetch error", error);
|
console.error("[forms/submit][webhook] fetch error", error);
|
||||||
return NextResponse.json({ ok: false, error: "webhook_exception" }, { status: 500 });
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "webhook_exception", message: errorMessage },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[forms/submit][webhook] forwarded", { name, email, message, destinationUrl });
|
console.log("[forms/submit][webhook] forwarded", { name, email, message, destinationUrl });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true, message: successMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그 외 알 수 없는 모드는 internal 과 동일하게 처리
|
// 그 외 알 수 없는 모드는 internal 과 동일하게 처리
|
||||||
console.warn("[forms/submit] unknown submitTarget, fallback to internal", submitTarget);
|
console.warn("[forms/submit] unknown submitTarget, fallback to internal", submitTarget);
|
||||||
console.log("[forms/submit][internal]", { name, email, message });
|
console.log("[forms/submit][internal]", { name, email, message });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true, message: successMessage });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function EditorCanvas(props: EditorCanvasProps) {
|
|||||||
projectConfig,
|
projectConfig,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const canvasOuterStyle: CSSProperties = {};
|
||||||
const canvasInnerStyle: CSSProperties = {};
|
const canvasInnerStyle: CSSProperties = {};
|
||||||
const preset = projectConfig.canvasPreset ?? "full";
|
const preset = projectConfig.canvasPreset ?? "full";
|
||||||
const widthPx =
|
const widthPx =
|
||||||
@@ -59,14 +60,19 @@ export function EditorCanvas(props: EditorCanvasProps) {
|
|||||||
canvasInnerStyle.maxWidth = "1200px";
|
canvasInnerStyle.maxWidth = "1200px";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (projectConfig.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) {
|
||||||
|
canvasOuterStyle.backgroundColor = projectConfig.bodyBgColorHex;
|
||||||
|
}
|
||||||
|
|
||||||
if (projectConfig.canvasBgColorHex && projectConfig.canvasBgColorHex.trim()) {
|
if (projectConfig.canvasBgColorHex && projectConfig.canvasBgColorHex.trim()) {
|
||||||
canvasInnerStyle.backgroundColor = projectConfig.canvasBgColorHex;
|
canvasInnerStyle.backgroundColor = projectConfig.canvasBgColorHex;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex-1 p-4 flex flex-col gap-2 text-sm text-slate-200 border-r border-slate-800 overflow-auto"
|
className="flex-1 p-4 flex flex-col gap-2 text-sm text-slate-200 border-r border-slate-800 overflow-auto pb-scroll"
|
||||||
data-testid="editor-canvas"
|
data-testid="editor-canvas"
|
||||||
|
style={canvasOuterStyle}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.target === event.currentTarget) {
|
if (event.target === event.currentTarget) {
|
||||||
onCanvasEmptyClick();
|
onCanvasEmptyClick();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { Block, ButtonBlockProps, FormBlockProps } from "@/features/editor/state/editorStore";
|
import type { Block, ButtonBlockProps, FormBlockProps } from "@/features/editor/state/editorStore";
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||||||
|
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
||||||
|
|
||||||
interface FormControllerPanelProps {
|
interface FormControllerPanelProps {
|
||||||
block: Block; // type === "form"
|
block: Block; // type === "form"
|
||||||
@@ -188,6 +189,61 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
<ColorPickerField
|
||||||
|
label="폼 배경색"
|
||||||
|
ariaLabelColorInput="폼 배경색 피커"
|
||||||
|
ariaLabelHexInput="폼 배경색 HEX"
|
||||||
|
value={
|
||||||
|
formProps.backgroundColorCustom && formProps.backgroundColorCustom.trim() !== ""
|
||||||
|
? formProps.backgroundColorCustom
|
||||||
|
: TEXT_COLOR_PALETTE[0]?.color ?? "#e5e7eb"
|
||||||
|
}
|
||||||
|
onChange={(hex) => {
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
backgroundColorCustom: hex,
|
||||||
|
} as any);
|
||||||
|
}}
|
||||||
|
palette={TEXT_COLOR_PALETTE}
|
||||||
|
onPaletteSelect={(item) => {
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
backgroundColorCustom: item.color,
|
||||||
|
} 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">
|
||||||
|
<span>성공 메시지</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"
|
||||||
|
placeholder="예: 성공적으로 전송되었습니다."
|
||||||
|
value={formProps.successMessage ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
successMessage: e.target.value,
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||||
|
<span>에러 메시지</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"
|
||||||
|
placeholder="예: 전송 중 오류가 발생했습니다."
|
||||||
|
value={formProps.errorMessage ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
errorMessage: e.target.value,
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 border-t border-slate-800 pt-3 mt-4">
|
<div className="space-y-3 border-t border-slate-800 pt-3 mt-4">
|
||||||
<h3 className="text-[11px] font-semibold text-slate-200">폼 컨트롤러</h3>
|
<h3 className="text-[11px] font-semibold text-slate-200">폼 컨트롤러</h3>
|
||||||
|
|
||||||
|
|||||||
+89
-17
@@ -113,17 +113,14 @@ export default function EditorPage() {
|
|||||||
const [projectSlug, setProjectSlug] = useState("");
|
const [projectSlug, setProjectSlug] = useState("");
|
||||||
const [loadSlug, setLoadSlug] = useState("");
|
const [loadSlug, setLoadSlug] = useState("");
|
||||||
const [projectMessage, setProjectMessage] = useState("");
|
const [projectMessage, setProjectMessage] = useState("");
|
||||||
|
// 멀티 선택(MVP)을 위한 로컬 선택 상태: 단일 선택(selectedBlockId)과 병행 사용한다.
|
||||||
|
const [selectedBlockIds, setSelectedBlockIds] = useState<string[]>([]);
|
||||||
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null);
|
const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null);
|
||||||
|
|
||||||
const sensors = useSensors(useSensor(PointerSensor));
|
const sensors = useSensors(useSensor(PointerSensor));
|
||||||
|
|
||||||
const editorMainStyle: CSSProperties = {};
|
|
||||||
if (projectConfig.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) {
|
|
||||||
editorMainStyle.backgroundColor = projectConfig.bodyBgColorHex;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startEditing = (id: string, initialText: string) => {
|
const startEditing = (id: string, initialText: string) => {
|
||||||
selectBlock(id);
|
selectBlock(id);
|
||||||
setEditingBlockId(id);
|
setEditingBlockId(id);
|
||||||
@@ -474,6 +471,20 @@ export default function EditorPage() {
|
|||||||
// ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다.
|
// ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (target) {
|
||||||
|
const tagName = target.tagName;
|
||||||
|
const isInputLike =
|
||||||
|
tagName === "INPUT" ||
|
||||||
|
tagName === "TEXTAREA" ||
|
||||||
|
(target as HTMLElement).isContentEditable;
|
||||||
|
if (isInputLike) {
|
||||||
|
// 입력 포커스 상태에서는 에디터 전역 단축키를 비활성화하고,
|
||||||
|
// 기본 브라우저 편집 동작(삭제/커서 이동 등)을 그대로 둔다.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 방향키로 선택된 블록을 위/아래로 이동 (순서 변경이 아니라 선택만 이동)
|
// 방향키로 선택된 블록을 위/아래로 이동 (순서 변경이 아니라 선택만 이동)
|
||||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||||
const { blocks: currentBlocks, selectedBlockId: currentSelectedId, selectBlock: storeSelectBlock } =
|
const { blocks: currentBlocks, selectedBlockId: currentSelectedId, selectBlock: storeSelectBlock } =
|
||||||
@@ -518,9 +529,20 @@ export default function EditorPage() {
|
|||||||
const isMac = navigator.platform.toLowerCase().includes("mac");
|
const isMac = navigator.platform.toLowerCase().includes("mac");
|
||||||
const metaKey = isMac ? event.metaKey : event.ctrlKey;
|
const metaKey = isMac ? event.metaKey : event.ctrlKey;
|
||||||
|
|
||||||
// Delete / Backspace 로 선택된 블록 삭제
|
// Delete / Backspace 로 선택된 블록 삭제 (멀티 선택 우선)
|
||||||
if (event.key === "Delete" || event.key === "Backspace") {
|
if (event.key === "Delete" || event.key === "Backspace") {
|
||||||
const { selectedBlockId: currentSelectedId } = (useEditorStore as any).getState();
|
const { selectedBlockId: currentSelectedId, blocks: currentBlocks } = (useEditorStore as any).getState();
|
||||||
|
|
||||||
|
// 멀티 선택이 존재하면 멀티 삭제를 우선 처리한다.
|
||||||
|
if (selectedBlockIds.length > 1) {
|
||||||
|
event.preventDefault();
|
||||||
|
const selectedSet = new Set(selectedBlockIds);
|
||||||
|
const nextBlocks = (currentBlocks as Block[]).filter((b) => !selectedSet.has(b.id));
|
||||||
|
replaceBlocks(nextBlocks);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 단일 선택만 있는 경우 기존 removeBlock 로직 사용
|
||||||
if (currentSelectedId) {
|
if (currentSelectedId) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
removeBlock(currentSelectedId);
|
removeBlock(currentSelectedId);
|
||||||
@@ -559,11 +581,12 @@ export default function EditorPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [undo, redo]);
|
}, [undo, redo, selectedBlockIds, replaceBlocks, removeBlock]);
|
||||||
|
|
||||||
const renderBlocks = (targetBlocks: Block[]) =>
|
const renderBlocks = (targetBlocks: Block[]) =>
|
||||||
targetBlocks.map((block) => {
|
targetBlocks.map((block) => {
|
||||||
const isSelected = block.id === selectedBlockId;
|
const isSelected =
|
||||||
|
(selectedBlockId != null && block.id === selectedBlockId) || selectedBlockIds.includes(block.id);
|
||||||
const isEditing = block.id === editingBlockId;
|
const isEditing = block.id === editingBlockId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -572,6 +595,9 @@ export default function EditorPage() {
|
|||||||
<SortableEditorBlock
|
<SortableEditorBlock
|
||||||
block={block}
|
block={block}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
|
selectedBlockId={selectedBlockId ?? null}
|
||||||
|
selectedBlockIds={selectedBlockIds}
|
||||||
|
setSelectedBlockIds={setSelectedBlockIds}
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
editingText={editingText}
|
editingText={editingText}
|
||||||
startEditing={startEditing}
|
startEditing={startEditing}
|
||||||
@@ -590,7 +616,7 @@ export default function EditorPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex flex-col" style={editorMainStyle}>
|
<main className="h-screen flex flex-col overflow-hidden">
|
||||||
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between relative z-20">
|
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between relative z-20">
|
||||||
<div className="flex items-baseline gap-3">
|
<div className="flex items-baseline gap-3">
|
||||||
<h1 className="text-xl font-semibold">Page Editor</h1>
|
<h1 className="text-xl font-semibold">Page Editor</h1>
|
||||||
@@ -867,6 +893,9 @@ interface TextInlineToolbarProps {
|
|||||||
interface SortableEditorBlockProps {
|
interface SortableEditorBlockProps {
|
||||||
block: Block;
|
block: Block;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
selectedBlockId: string | null;
|
||||||
|
selectedBlockIds: string[];
|
||||||
|
setSelectedBlockIds: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
editingText: string;
|
editingText: string;
|
||||||
startEditing: (id: string, initialText: string) => void;
|
startEditing: (id: string, initialText: string) => void;
|
||||||
@@ -884,6 +913,9 @@ interface SortableEditorBlockProps {
|
|||||||
function SortableEditorBlock({
|
function SortableEditorBlock({
|
||||||
block,
|
block,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
selectedBlockId,
|
||||||
|
selectedBlockIds,
|
||||||
|
setSelectedBlockIds,
|
||||||
isEditing,
|
isEditing,
|
||||||
editingText,
|
editingText,
|
||||||
startEditing,
|
startEditing,
|
||||||
@@ -966,6 +998,11 @@ function SortableEditorBlock({
|
|||||||
colorClass = `pb-text-color-${colorPalette}`;
|
colorClass = `pb-text-color-${colorPalette}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 블록 배경색: backgroundColorCustom 이 설정된 경우 편집기 텍스트 컨테이너 배경에도 반영한다.
|
||||||
|
if (textProps.backgroundColorCustom && textProps.backgroundColorCustom.trim() !== "") {
|
||||||
|
textStyleOverrides.backgroundColor = textProps.backgroundColorCustom.trim();
|
||||||
|
}
|
||||||
|
|
||||||
// 최대 너비: 커스텀 값이 있으면 우선 사용, 없으면 scale 프리셋
|
// 최대 너비: 커스텀 값이 있으면 우선 사용, 없으면 scale 프리셋
|
||||||
const maxWidthScale = textProps.maxWidthScale ?? "none";
|
const maxWidthScale = textProps.maxWidthScale ?? "none";
|
||||||
if (textProps.maxWidthCustom && textProps.maxWidthCustom.trim() !== "") {
|
if (textProps.maxWidthCustom && textProps.maxWidthCustom.trim() !== "") {
|
||||||
@@ -1027,15 +1064,29 @@ function SortableEditorBlock({
|
|||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={{ ...baseStyle, ...textStyleOverrides }}
|
style={{ ...baseStyle, ...textStyleOverrides }}
|
||||||
data-testid="editor-block"
|
data-testid="editor-block"
|
||||||
|
data-block-id={block.id}
|
||||||
data-selected={isSelected ? "true" : "false"}
|
data-selected={isSelected ? "true" : "false"}
|
||||||
aria-selected={isSelected ? "true" : "false"}
|
aria-selected={isSelected ? "true" : "false"}
|
||||||
className={`rounded border px-3 py-2 cursor-text transition-colors ${alignClass} ${sizeClass} ${leadingClass} ${weightClass} ${colorClass} ${maxWidthClass} ${decorationClass} ${
|
className={`rounded border px-3 py-2 cursor-text transition-colors ${alignClass} ${sizeClass} ${leadingClass} ${weightClass} ${colorClass} ${maxWidthClass} ${decorationClass} ${
|
||||||
isSelected ? "border-sky-500 bg-slate-900/80" : "border-slate-700 bg-slate-900"
|
isSelected ? "border-sky-500 bg-slate-900/80" : "border-slate-700 bg-slate-900"
|
||||||
}`}
|
}`}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
// 섹션 안 자식 블록을 클릭했을 때 섹션까지 선택되는 것을 막기 위해
|
// 멀티 선택(MVP): Cmd/Ctrl+클릭 시 선택 토글, 그 외에는 단일 선택으로 전환한다.
|
||||||
// 클릭 이벤트를 여기서 중단하고 현재 블록만 선택한다.
|
const isMeta = event.metaKey || event.ctrlKey;
|
||||||
|
if (isMeta) {
|
||||||
|
event.stopPropagation();
|
||||||
|
setSelectedBlockIds((prev) => {
|
||||||
|
if (prev.includes(block.id)) {
|
||||||
|
return prev.filter((id) => id !== block.id);
|
||||||
|
}
|
||||||
|
return [...prev, block.id];
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일반 클릭: 현재 블록만 단일 선택으로 유지한다.
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
setSelectedBlockIds([block.id]);
|
||||||
selectBlock(block.id);
|
selectBlock(block.id);
|
||||||
}}
|
}}
|
||||||
onDoubleClick={(event) => {
|
onDoubleClick={(event) => {
|
||||||
@@ -1358,7 +1409,7 @@ function SortableEditorBlock({
|
|||||||
else if (checkboxRadius === "lg" || checkboxRadius === "full") fieldStyle.borderRadius = 9999;
|
else if (checkboxRadius === "lg" || checkboxRadius === "full") fieldStyle.borderRadius = 9999;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 text-xs text-slate-200">
|
<div className="flex flex-col gap-1 text-xs text-slate-200" style={fieldStyle}>
|
||||||
{/* 체크박스 그룹 타이틀도 텍스트/이미지 모드를 지원한다. */}
|
{/* 체크박스 그룹 타이틀도 텍스트/이미지 모드를 지원한다. */}
|
||||||
{groupLabelMode === "image" && checkboxProps.groupLabelImageUrl ? (
|
{groupLabelMode === "image" && checkboxProps.groupLabelImageUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
@@ -1402,7 +1453,9 @@ function SortableEditorBlock({
|
|||||||
const variant = buttonProps.variant ?? "solid";
|
const variant = buttonProps.variant ?? "solid";
|
||||||
const colorPalette = buttonProps.colorPalette ?? "primary";
|
const colorPalette = buttonProps.colorPalette ?? "primary";
|
||||||
const radius = buttonProps.borderRadius ?? "md";
|
const radius = buttonProps.borderRadius ?? "md";
|
||||||
const fullWidth = buttonProps.fullWidth ?? false;
|
const baseFullWidth = buttonProps.fullWidth ?? false;
|
||||||
|
const widthMode = buttonProps.widthMode ?? (baseFullWidth ? "full" : "auto");
|
||||||
|
const isFullWidth = widthMode === "full" || baseFullWidth;
|
||||||
|
|
||||||
const sizeClassBtn =
|
const sizeClassBtn =
|
||||||
size === "xs"
|
size === "xs"
|
||||||
@@ -1445,6 +1498,9 @@ function SortableEditorBlock({
|
|||||||
if (buttonProps.textColorCustom && buttonProps.textColorCustom.trim() !== "") {
|
if (buttonProps.textColorCustom && buttonProps.textColorCustom.trim() !== "") {
|
||||||
buttonStyle.color = buttonProps.textColorCustom;
|
buttonStyle.color = buttonProps.textColorCustom;
|
||||||
}
|
}
|
||||||
|
if (widthMode === "fixed" && typeof buttonProps.widthPx === "number" && buttonProps.widthPx > 0) {
|
||||||
|
buttonStyle.width = `${buttonProps.widthPx}px`;
|
||||||
|
}
|
||||||
if (typeof buttonProps.paddingX === "number" && buttonProps.paddingX >= 0) {
|
if (typeof buttonProps.paddingX === "number" && buttonProps.paddingX >= 0) {
|
||||||
buttonStyle.paddingInline = `${buttonProps.paddingX}px`;
|
buttonStyle.paddingInline = `${buttonProps.paddingX}px`;
|
||||||
}
|
}
|
||||||
@@ -1453,7 +1509,7 @@ function SortableEditorBlock({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={fullWidth ? "w-full" : "inline-block"}>
|
<div className={isFullWidth ? "w-full" : "inline-block"}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`pb-btn-base ${sizeClassBtn} ${radiusClassBtn} ${variantClassBtn} w-full`}
|
className={`pb-btn-base ${sizeClassBtn} ${radiusClassBtn} ${variantClassBtn} w-full`}
|
||||||
@@ -1708,6 +1764,12 @@ function SortableEditorBlock({
|
|||||||
})()}
|
})()}
|
||||||
{block.type === "section" && (() => {
|
{block.type === "section" && (() => {
|
||||||
const sectionProps = block.props as SectionBlockProps;
|
const sectionProps = block.props as SectionBlockProps;
|
||||||
|
const isSectionSelected =
|
||||||
|
isSelected ||
|
||||||
|
(selectedBlockId != null &&
|
||||||
|
allBlocks.some(
|
||||||
|
(candidate) => candidate.id === selectedBlockId && candidate.sectionId === block.id,
|
||||||
|
));
|
||||||
|
|
||||||
const bgClass =
|
const bgClass =
|
||||||
sectionProps.background === "muted"
|
sectionProps.background === "muted"
|
||||||
@@ -1723,6 +1785,13 @@ function SortableEditorBlock({
|
|||||||
? "py-10"
|
? "py-10"
|
||||||
: "py-6";
|
: "py-6";
|
||||||
|
|
||||||
|
const alignItemsClass =
|
||||||
|
sectionProps.alignItems === "center"
|
||||||
|
? "items-center"
|
||||||
|
: sectionProps.alignItems === "bottom"
|
||||||
|
? "items-end"
|
||||||
|
: "items-start";
|
||||||
|
|
||||||
const columns = sectionProps.columns && sectionProps.columns.length > 0
|
const columns = sectionProps.columns && sectionProps.columns.length > 0
|
||||||
? sectionProps.columns
|
? sectionProps.columns
|
||||||
: [{ id: `${block.id}_col_fallback`, span: 12 }];
|
: [{ id: `${block.id}_col_fallback`, span: 12 }];
|
||||||
@@ -1738,7 +1807,10 @@ function SortableEditorBlock({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`w-full ${bgClass} ${pyClass} rounded border border-dashed border-slate-700`}
|
data-testid="editor-section"
|
||||||
|
className={`w-full ${bgClass} ${pyClass} rounded border ${
|
||||||
|
isSectionSelected ? "border-sky-500" : "border-dashed border-slate-700"
|
||||||
|
}`}
|
||||||
style={sectionStyle}
|
style={sectionStyle}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -1751,7 +1823,7 @@ function SortableEditorBlock({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex"
|
className={`flex ${alignItemsClass}`}
|
||||||
style={{
|
style={{
|
||||||
columnGap:
|
columnGap:
|
||||||
typeof sectionProps.gapXPx === "number" && sectionProps.gapXPx > 0
|
typeof sectionProps.gapXPx === "number" && sectionProps.gapXPx > 0
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function BlocksSidebar() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3">
|
<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>
|
<h2 className="font-medium">블록</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -152,71 +152,213 @@ export function BlocksSidebar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-3 border-t border-slate-800 mt-3 space-y-2">
|
<div className="pt-3 border-t border-slate-800 mt-3 space-y-3">
|
||||||
<h3 className="text-[11px] font-medium text-slate-300">템플릿</h3>
|
<h3 className="text-[11px] font-medium text-slate-300">템플릿</h3>
|
||||||
<button
|
|
||||||
type="button"
|
<div className="space-y-2">
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
<p className="text-[10px] font-semibold text-slate-400">히어로 · CTA</p>
|
||||||
onClick={handleAddHeroTemplate}
|
<div className="space-y-2">
|
||||||
>
|
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||||
Hero 템플릿 추가
|
<div className="flex items-center justify-between gap-2">
|
||||||
</button>
|
<button
|
||||||
<button
|
type="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"
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
onClick={handleAddHeroTemplate}
|
||||||
onClick={handleAddFeaturesTemplate}
|
>
|
||||||
>
|
Hero 템플릿 추가
|
||||||
Features 템플릿 추가
|
</button>
|
||||||
</button>
|
<div
|
||||||
<button
|
data-testid="template-preview-hero"
|
||||||
type="button"
|
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
>
|
||||||
onClick={handleAddCtaTemplate}
|
<div className="flex-1 bg-slate-700/70" />
|
||||||
>
|
</div>
|
||||||
CTA 템플릿 추가
|
</div>
|
||||||
</button>
|
<p className="text-[10px] text-slate-400 leading-snug">
|
||||||
<button
|
페이지 상단 Hero 섹션 (큰 제목 + 서브텍스트 + 버튼)
|
||||||
type="button"
|
</p>
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
</div>
|
||||||
onClick={handleAddFaqTemplate}
|
|
||||||
>
|
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||||
FAQ 템플릿 추가
|
<div className="flex items-center justify-between gap-2">
|
||||||
</button>
|
<button
|
||||||
<button
|
type="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"
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
onClick={handleAddCtaTemplate}
|
||||||
onClick={handleAddPricingTemplate}
|
>
|
||||||
>
|
CTA 템플릿 추가
|
||||||
Pricing 템플릿 추가
|
</button>
|
||||||
</button>
|
<div
|
||||||
<button
|
data-testid="template-preview-cta"
|
||||||
type="button"
|
className="shrink-0 flex h-6 w-10 items-center justify-center gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
>
|
||||||
onClick={handleAddTestimonialsTemplate}
|
<div className="h-2 w-6 rounded bg-slate-700/70" />
|
||||||
>
|
</div>
|
||||||
Testimonials 템플릿 추가
|
</div>
|
||||||
</button>
|
<p className="text-[10px] text-slate-400 leading-snug">콜투액션(CTA) 섹션</p>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
</div>
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
</div>
|
||||||
onClick={handleAddBlogTemplate}
|
|
||||||
>
|
<div className="space-y-2">
|
||||||
Blog 템플릿 추가
|
<p className="text-[10px] font-semibold text-slate-400">콘텐츠 섹션</p>
|
||||||
</button>
|
<div className="space-y-2">
|
||||||
<button
|
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||||
type="button"
|
<div className="flex items-center justify-between gap-2">
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
<button
|
||||||
onClick={handleAddTeamTemplate}
|
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"
|
||||||
Team 템플릿 추가
|
onClick={handleAddFeaturesTemplate}
|
||||||
</button>
|
>
|
||||||
<button
|
Features 템플릿 추가
|
||||||
type="button"
|
</button>
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
<div
|
||||||
onClick={handleAddFooterTemplate}
|
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"
|
||||||
Footer 템플릿 추가
|
>
|
||||||
</button>
|
<div className="flex-1 bg-slate-700/70" />
|
||||||
|
<div className="flex-1 bg-slate-700/50" />
|
||||||
|
<div className="flex-1 bg-slate-700/70" />
|
||||||
|
</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 flex-col justify-center gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||||
|
>
|
||||||
|
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||||
|
<div className="h-[2px] w-full bg-slate-700/40" />
|
||||||
|
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||||
|
</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-end gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||||
|
>
|
||||||
|
<div className="h-2 flex-1 bg-slate-700/40" />
|
||||||
|
<div className="h-4 flex-1 bg-slate-700/70" />
|
||||||
|
<div className="h-3 flex-1 bg-slate-700/50" />
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<div className="flex-1 bg-slate-700/70" />
|
||||||
|
<div className="flex-1 bg-slate-700/50" />
|
||||||
|
<div className="flex-1 bg-slate-700/70" />
|
||||||
|
</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 flex-col justify-center gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||||
|
>
|
||||||
|
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||||
|
<div className="h-[2px] w-3/4 bg-slate-700/50" />
|
||||||
|
</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-end gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||||
|
>
|
||||||
|
<div className="h-3 flex-1 bg-slate-700/60" />
|
||||||
|
<div className="h-4 flex-1 bg-slate-700/80" />
|
||||||
|
<div className="h-2 flex-1 bg-slate-700/50" />
|
||||||
|
</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 flex-col justify-end gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||||
|
>
|
||||||
|
<div className="h-[2px] w-full bg-slate-700/40" />
|
||||||
|
<div className="h-[2px] w-3/4 bg-slate-700/70" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-400 leading-snug">페이지 푸터 섹션</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -341,18 +341,6 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex items-center justify-between text-xs text-slate-400">
|
|
||||||
<span>가로 전체 사용</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={buttonProps.fullWidth ?? false}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateBlock(selectedBlockId, { fullWidth: e.target.checked } as any);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
|
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
|
||||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||||||
|
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
||||||
|
|
||||||
export type ImagePropertiesPanelProps = {
|
export type ImagePropertiesPanelProps = {
|
||||||
imageProps: ImageBlockProps;
|
imageProps: ImageBlockProps;
|
||||||
@@ -127,6 +128,21 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
|
|||||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
|
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
|
||||||
<h4 className="text-[11px] font-semibold text-slate-200">이미지 스타일</h4>
|
<h4 className="text-[11px] font-semibold text-slate-200">이미지 스타일</h4>
|
||||||
|
|
||||||
|
{/* 카드 배경색 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<ColorPickerField
|
||||||
|
label="카드 배경색"
|
||||||
|
ariaLabelColorInput="이미지 카드 배경색 피커"
|
||||||
|
ariaLabelHexInput="이미지 카드 배경색 HEX"
|
||||||
|
value={imageProps.backgroundColorCustom ?? ""}
|
||||||
|
onChange={(hex) => {
|
||||||
|
const next = hex && hex.trim().length > 0 ? hex : undefined;
|
||||||
|
updateBlock(selectedBlockId, { backgroundColorCustom: next } as any);
|
||||||
|
}}
|
||||||
|
palette={TEXT_COLOR_PALETTE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 정렬 */}
|
{/* 정렬 */}
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<span>정렬</span>
|
<span>정렬</span>
|
||||||
|
|||||||
@@ -167,6 +167,17 @@ export function ListPropertiesPanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ColorPickerField
|
||||||
|
label="블록 배경색"
|
||||||
|
ariaLabelColorInput="리스트 배경색 피커"
|
||||||
|
ariaLabelHexInput="리스트 배경색 HEX"
|
||||||
|
value={listProps.backgroundColorCustom ?? ""}
|
||||||
|
onChange={(hex) => {
|
||||||
|
updateBlock(selectedBlockId, { backgroundColorCustom: hex } as any);
|
||||||
|
}}
|
||||||
|
palette={TEXT_COLOR_PALETTE}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 불릿 스타일 (● / ○ / ■ / 숫자형 / 없음) */}
|
{/* 불릿 스타일 (● / ○ / ■ / 숫자형 / 없음) */}
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<span>불릿 스타일</span>
|
<span>불릿 스타일</span>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
data-testid="properties-sidebar"
|
data-testid="properties-sidebar"
|
||||||
className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto"
|
className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto pb-scroll"
|
||||||
onKeyDownCapture={(e) => {
|
onKeyDownCapture={(e) => {
|
||||||
// 속성 패널 안에서 발생한 키 입력은 에디터 단축키/텍스트 블록 편집으로 전달되지 않도록 막는다.
|
// 속성 패널 안에서 발생한 키 입력은 에디터 단축키/텍스트 블록 편집으로 전달되지 않도록 막는다.
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
|||||||
|
|
||||||
// 1컬럼인 경우: 12 고정 표시
|
// 1컬럼인 경우: 12 고정 표시
|
||||||
if (colCount === 1) {
|
if (colCount === 1) {
|
||||||
const only = columns[0];
|
const only = columns[0] ?? { id: `${selectedBlockId}_col_1`, span: 12 };
|
||||||
return (
|
return (
|
||||||
<label key={only.id} className="flex flex-col gap-1">
|
<label key={only.id} className="flex flex-col gap-1">
|
||||||
<span>{`1열 폭 (고정 12/12)`}</span>
|
<span>{`1열 폭 (고정 12/12)`}</span>
|
||||||
|
|||||||
@@ -437,6 +437,21 @@ export function TextPropertiesPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<ColorPickerField
|
||||||
|
label="블록 배경색"
|
||||||
|
ariaLabelColorInput="텍스트 블록 배경색 피커"
|
||||||
|
ariaLabelHexInput="텍스트 블록 배경색 HEX"
|
||||||
|
value={textProps.backgroundColorCustom ?? ""}
|
||||||
|
onChange={(hex) => {
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
backgroundColorCustom: hex,
|
||||||
|
} as any);
|
||||||
|
}}
|
||||||
|
palette={TEXT_COLOR_PALETTE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||||
<span>최대 너비</span>
|
<span>최대 너비</span>
|
||||||
|
|||||||
@@ -123,6 +123,11 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
textStyle.color = props.colorCustom;
|
textStyle.color = props.colorCustom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 블록 배경색: backgroundColorCustom 이 설정된 경우에만 적용한다 (기본은 투명/미지정).
|
||||||
|
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||||
|
textStyle.backgroundColor = props.backgroundColorCustom.trim();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
key={block.id}
|
key={block.id}
|
||||||
@@ -314,6 +319,41 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 텍스트 색상: textColorCustom 이 설정되어 있으면 해당 색상을 사용하고, 없으면 기본 밝은 텍스트 색상을 사용한다.
|
||||||
|
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||||
|
const colorValue = props.textColorCustom.trim();
|
||||||
|
wrapperStyle.color = colorValue;
|
||||||
|
selectStyle.color = colorValue;
|
||||||
|
} else {
|
||||||
|
wrapperStyle.color = "#f9fafb";
|
||||||
|
selectStyle.color = "#f9fafb";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배경/테두리 색상: fillColorCustom/strokeColorCustom 이 있으면 select 에 적용한다.
|
||||||
|
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||||
|
const bgValue = props.fillColorCustom.trim();
|
||||||
|
selectStyle.backgroundColor = bgValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||||
|
const strokeValue = props.strokeColorCustom.trim();
|
||||||
|
selectStyle.borderColor = strokeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모서리 둥글기: borderRadius 토큰을 px 로 변환한다.
|
||||||
|
const selectRadiusToken = props.borderRadius ?? "md";
|
||||||
|
const selectRadiusPx =
|
||||||
|
selectRadiusToken === "none"
|
||||||
|
? 0
|
||||||
|
: selectRadiusToken === "sm"
|
||||||
|
? 2
|
||||||
|
: selectRadiusToken === "lg"
|
||||||
|
? 6
|
||||||
|
: selectRadiusToken === "full"
|
||||||
|
? 9999
|
||||||
|
: 4;
|
||||||
|
selectStyle.borderRadius = `${selectRadiusPx}px`;
|
||||||
|
|
||||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||||
const paddingEm = pxToEm(props.paddingX);
|
const paddingEm = pxToEm(props.paddingX);
|
||||||
selectStyle.paddingInline = paddingEm;
|
selectStyle.paddingInline = paddingEm;
|
||||||
@@ -353,6 +393,7 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
||||||
const widthClass = widthMode === "full" ? "w-full" : "";
|
const widthClass = widthMode === "full" ? "w-full" : "";
|
||||||
const groupStyle: CSSProperties = {};
|
const groupStyle: CSSProperties = {};
|
||||||
|
const optionContainerStyle: CSSProperties = {};
|
||||||
const optionTextStyle: CSSProperties = {};
|
const optionTextStyle: CSSProperties = {};
|
||||||
const groupTextStyle: CSSProperties = {};
|
const groupTextStyle: CSSProperties = {};
|
||||||
const textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
|
const textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
|
||||||
@@ -404,14 +445,51 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 텍스트 색상: textColorCustom 이 설정되어 있으면 그룹/옵션 텍스트 모두에 동일하게 적용한다.
|
||||||
|
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||||
|
const colorValue = props.textColorCustom.trim();
|
||||||
|
groupTextStyle.color = colorValue;
|
||||||
|
optionTextStyle.color = colorValue;
|
||||||
|
} else {
|
||||||
|
groupTextStyle.color = "#e5e7eb";
|
||||||
|
optionTextStyle.color = "#e5e7eb";
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||||
optionTextStyle.paddingInline = pxToEm(props.paddingX);
|
optionContainerStyle.paddingInline = pxToEm(props.paddingX);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||||
optionTextStyle.paddingBlock = pxToEm(props.paddingY);
|
optionContainerStyle.paddingBlock = pxToEm(props.paddingY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 옵션 컨테이너 배경/테두리 색상
|
||||||
|
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||||
|
const bgValue = props.fillColorCustom.trim();
|
||||||
|
optionContainerStyle.backgroundColor = bgValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||||
|
const strokeValue = props.strokeColorCustom.trim();
|
||||||
|
optionContainerStyle.borderColor = strokeValue;
|
||||||
|
optionContainerStyle.borderWidth = "1px";
|
||||||
|
optionContainerStyle.borderStyle = "solid";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 옵션 컨테이너 모서리 둥글기
|
||||||
|
const checkboxRadiusToken = props.borderRadius ?? "md";
|
||||||
|
const checkboxRadiusPx =
|
||||||
|
checkboxRadiusToken === "none"
|
||||||
|
? 0
|
||||||
|
: checkboxRadiusToken === "sm"
|
||||||
|
? 2
|
||||||
|
: checkboxRadiusToken === "lg"
|
||||||
|
? 6
|
||||||
|
: checkboxRadiusToken === "full"
|
||||||
|
? 9999
|
||||||
|
: 4;
|
||||||
|
optionContainerStyle.borderRadius = `${checkboxRadiusPx}px`;
|
||||||
|
|
||||||
const optionsStyle: CSSProperties = {};
|
const optionsStyle: CSSProperties = {};
|
||||||
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
|
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
|
||||||
optionsStyle.rowGap = pxToEm(props.optionGapPx);
|
optionsStyle.rowGap = pxToEm(props.optionGapPx);
|
||||||
@@ -439,7 +517,11 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-col" data-testid="preview-form-checkbox-options" style={optionsStyle}>
|
<div className="flex flex-col" data-testid="preview-form-checkbox-options" style={optionsStyle}>
|
||||||
{props.options.map((opt) => (
|
{props.options.map((opt) => (
|
||||||
<label key={opt.value} className="inline-flex items-center gap-1">
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className="inline-flex items-center gap-1"
|
||||||
|
style={optionContainerStyle}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
|
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
|
||||||
@@ -469,6 +551,7 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
||||||
const widthClass = widthMode === "full" ? "w-full" : "";
|
const widthClass = widthMode === "full" ? "w-full" : "";
|
||||||
const groupStyle: CSSProperties = {};
|
const groupStyle: CSSProperties = {};
|
||||||
|
const optionContainerStyle: CSSProperties = {};
|
||||||
const optionTextStyle: CSSProperties = {};
|
const optionTextStyle: CSSProperties = {};
|
||||||
const groupTextStyle: CSSProperties = {};
|
const groupTextStyle: CSSProperties = {};
|
||||||
const textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
|
const textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
|
||||||
@@ -520,14 +603,51 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 텍스트 색상: textColorCustom 이 설정되어 있으면 그룹/옵션 텍스트 모두에 동일하게 적용한다.
|
||||||
|
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||||
|
const colorValue = props.textColorCustom.trim();
|
||||||
|
groupTextStyle.color = colorValue;
|
||||||
|
optionTextStyle.color = colorValue;
|
||||||
|
} else {
|
||||||
|
groupTextStyle.color = "#e5e7eb";
|
||||||
|
optionTextStyle.color = "#e5e7eb";
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||||
optionTextStyle.paddingInline = pxToEm(props.paddingX);
|
optionContainerStyle.paddingInline = pxToEm(props.paddingX);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||||
optionTextStyle.paddingBlock = pxToEm(props.paddingY);
|
optionContainerStyle.paddingBlock = pxToEm(props.paddingY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 옵션 컨테이너 배경/테두리 색상
|
||||||
|
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||||
|
const bgValue = props.fillColorCustom.trim();
|
||||||
|
optionContainerStyle.backgroundColor = bgValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||||
|
const strokeValue = props.strokeColorCustom.trim();
|
||||||
|
optionContainerStyle.borderColor = strokeValue;
|
||||||
|
optionContainerStyle.borderWidth = "1px";
|
||||||
|
optionContainerStyle.borderStyle = "solid";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 옵션 컨테이너 모서리 둥글기
|
||||||
|
const radioRadiusToken = props.borderRadius ?? "md";
|
||||||
|
const radioRadiusPx =
|
||||||
|
radioRadiusToken === "none"
|
||||||
|
? 0
|
||||||
|
: radioRadiusToken === "sm"
|
||||||
|
? 2
|
||||||
|
: radioRadiusToken === "lg"
|
||||||
|
? 6
|
||||||
|
: radioRadiusToken === "full"
|
||||||
|
? 9999
|
||||||
|
: 4;
|
||||||
|
optionContainerStyle.borderRadius = `${radioRadiusPx}px`;
|
||||||
|
|
||||||
const optionsStyle: CSSProperties = {};
|
const optionsStyle: CSSProperties = {};
|
||||||
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
|
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
|
||||||
optionsStyle.rowGap = pxToEm(props.optionGapPx);
|
optionsStyle.rowGap = pxToEm(props.optionGapPx);
|
||||||
@@ -555,7 +675,11 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-col" data-testid="preview-form-radio-options" style={optionsStyle}>
|
<div className="flex flex-col" data-testid="preview-form-radio-options" style={optionsStyle}>
|
||||||
{props.options.map((opt) => (
|
{props.options.map((opt) => (
|
||||||
<label key={opt.value} className="inline-flex items-center gap-1">
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className="inline-flex items-center gap-1"
|
||||||
|
style={optionContainerStyle}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
|
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
|
||||||
@@ -753,6 +877,11 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
listStyle.color = props.textColorCustom;
|
listStyle.color = props.textColorCustom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 리스트 배경색: backgroundColorCustom 이 설정된 경우에만 적용한다.
|
||||||
|
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||||
|
listStyle.backgroundColor = props.backgroundColorCustom.trim();
|
||||||
|
}
|
||||||
|
|
||||||
const bulletStyle = bulletStyleRaw;
|
const bulletStyle = bulletStyleRaw;
|
||||||
|
|
||||||
const itemsTree: ListItemNode[] =
|
const itemsTree: ListItemNode[] =
|
||||||
@@ -922,6 +1051,11 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
formStyle.marginBottom = marginEm;
|
formStyle.marginBottom = marginEm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 폼 배경색: backgroundColorCustom 이 설정된 경우에만 적용한다.
|
||||||
|
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||||
|
formStyle.backgroundColor = props.backgroundColorCustom.trim();
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -1097,6 +1231,7 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
props.align === "left" ? "justify-start" : props.align === "right" ? "justify-end" : "justify-center";
|
props.align === "left" ? "justify-start" : props.align === "right" ? "justify-end" : "justify-center";
|
||||||
|
|
||||||
const widthMode = props.widthMode ?? "auto";
|
const widthMode = props.widthMode ?? "auto";
|
||||||
|
const wrapperStyle: CSSProperties = {};
|
||||||
const imageStyle: CSSProperties = {
|
const imageStyle: CSSProperties = {
|
||||||
display: "block",
|
display: "block",
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
@@ -1124,8 +1259,12 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
: fallbackRadiusPx;
|
: fallbackRadiusPx;
|
||||||
imageStyle.borderRadius = radiusPx === 9999 ? "9999px" : pxToEm(radiusPx);
|
imageStyle.borderRadius = radiusPx === 9999 ? "9999px" : pxToEm(radiusPx);
|
||||||
|
|
||||||
|
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||||
|
wrapperStyle.backgroundColor = props.backgroundColorCustom;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={block.id} className={`w-full flex ${alignClass}`}>
|
<div key={block.id} className={`w-full flex ${alignClass}`} style={wrapperStyle}>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={props.src || ""} alt={props.alt} className="object-contain" style={imageStyle} />
|
<img src={props.src || ""} alt={props.alt} className="object-contain" style={imageStyle} />
|
||||||
</div>
|
</div>
|
||||||
@@ -1200,10 +1339,10 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col bg-slate-950 text-slate-50">
|
<div className="flex-1 flex flex-col text-slate-50">
|
||||||
{/* 루트 텍스트/버튼/이미지 블록들이 있다면 페이지 상단에 노출 */}
|
{/* 루트 텍스트/버튼/이미지 블록들이 있다면 페이지 상단에 노출 */}
|
||||||
{rootBlocks.length > 0 && (
|
{rootBlocks.length > 0 && (
|
||||||
<section className="bg-slate-950 py-12">
|
<section className="py-12">
|
||||||
<div className="mx-auto max-w-3xl px-4 flex flex-col gap-4">
|
<div className="mx-auto max-w-3xl px-4 flex flex-col gap-4">
|
||||||
{rootBlocks.map((b) => (
|
{rootBlocks.map((b) => (
|
||||||
<div key={b.id}>{renderBlock(b)}</div>
|
<div key={b.id}>{renderBlock(b)}</div>
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export interface TextBlockProps {
|
|||||||
// 커스텀 색상 값 (예: "#ff0000", "rgb(…)" 등)
|
// 커스텀 색상 값 (예: "#ff0000", "rgb(…)" 등)
|
||||||
colorCustom?: string;
|
colorCustom?: string;
|
||||||
|
|
||||||
|
// 블록 배경색 커스텀 값 (예: "#123456")
|
||||||
|
backgroundColorCustom?: string;
|
||||||
|
|
||||||
// 최대 너비 모드: 프리셋 또는 커스텀 값
|
// 최대 너비 모드: 프리셋 또는 커스텀 값
|
||||||
maxWidthMode?: "scale" | "custom";
|
maxWidthMode?: "scale" | "custom";
|
||||||
// 최대 너비 스케일
|
// 최대 너비 스케일
|
||||||
@@ -127,6 +130,8 @@ export interface ImageBlockProps {
|
|||||||
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
|
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
|
||||||
// 모서리 둥글기 px 값 (이미지에 한해서는 연속적인 px 단위 조정을 지원한다)
|
// 모서리 둥글기 px 값 (이미지에 한해서는 연속적인 px 단위 조정을 지원한다)
|
||||||
borderRadiusPx?: number;
|
borderRadiusPx?: number;
|
||||||
|
// 이미지 카드를 감싸는 배경색 커스텀 값 (예: "#123456")
|
||||||
|
backgroundColorCustom?: string;
|
||||||
// 이미지 소스 타입: 업로드된 에셋 또는 외부 URL
|
// 이미지 소스 타입: 업로드된 에셋 또는 외부 URL
|
||||||
sourceType?: "asset" | "externalUrl";
|
sourceType?: "asset" | "externalUrl";
|
||||||
// 업로드된 에셋인 경우 Asset.id 를 저장해둔다.
|
// 업로드된 에셋인 경우 Asset.id 를 저장해둔다.
|
||||||
@@ -450,6 +455,8 @@ export interface ListBlockProps {
|
|||||||
fontSizeCustom?: string;
|
fontSizeCustom?: string;
|
||||||
lineHeightCustom?: string;
|
lineHeightCustom?: string;
|
||||||
textColorCustom?: string;
|
textColorCustom?: string;
|
||||||
|
// 리스트 전체를 감싸는 배경색 커스텀 값
|
||||||
|
backgroundColorCustom?: string;
|
||||||
// 불릿/간격
|
// 불릿/간격
|
||||||
bulletStyle?:
|
bulletStyle?:
|
||||||
| "disc"
|
| "disc"
|
||||||
@@ -619,6 +626,8 @@ export interface FormBlockProps {
|
|||||||
formWidthMode?: "auto" | "full" | "fixed";
|
formWidthMode?: "auto" | "full" | "fixed";
|
||||||
formWidthPx?: number;
|
formWidthPx?: number;
|
||||||
marginYPx?: number;
|
marginYPx?: number;
|
||||||
|
// 폼 전체를 감싸는 배경색 커스텀 값
|
||||||
|
backgroundColorCustom?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CanvasPreset = "mobile" | "tablet" | "desktop" | "full" | "custom";
|
export type CanvasPreset = "mobile" | "tablet" | "desktop" | "full" | "custom";
|
||||||
@@ -804,14 +813,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
|
|
||||||
// Hero 템플릿 섹션 추가: 섹션 1개와 기본 텍스트/버튼 블록들을 첫 컬럼에 배치한다
|
// Hero 템플릿 섹션 추가: 섹션 1개와 기본 텍스트/버튼 블록들을 첫 컬럼에 배치한다
|
||||||
addHeroTemplateSection: () => {
|
addHeroTemplateSection: () => {
|
||||||
const sectionId = createId();
|
const { selectedBlockId, blocks } = get();
|
||||||
|
let sectionId = createId();
|
||||||
|
let replacingSectionId: string | null = null;
|
||||||
|
|
||||||
|
if (selectedBlockId) {
|
||||||
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||||
|
if (target) {
|
||||||
|
sectionId = target.id;
|
||||||
|
replacingSectionId = target.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createHeroTemplateBlocks({
|
const { blocks: templateBlocks, lastSelectedId } = createHeroTemplateBlocks({
|
||||||
sectionId,
|
sectionId,
|
||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
let baseBlocks = state.blocks as Block[];
|
||||||
|
if (replacingSectionId) {
|
||||||
|
baseBlocks = baseBlocks.filter(
|
||||||
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blocks: newBlocks,
|
blocks: newBlocks,
|
||||||
@@ -827,14 +854,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
|
|
||||||
// Features 템플릿 섹션 추가: 3컬럼(4/4/4) 섹션과 각 컬럼의 제목/설명 텍스트를 생성한다
|
// Features 템플릿 섹션 추가: 3컬럼(4/4/4) 섹션과 각 컬럼의 제목/설명 텍스트를 생성한다
|
||||||
addFeaturesTemplateSection: () => {
|
addFeaturesTemplateSection: () => {
|
||||||
const sectionId = createId();
|
const { selectedBlockId, blocks } = get();
|
||||||
|
let sectionId = createId();
|
||||||
|
let replacingSectionId: string | null = null;
|
||||||
|
|
||||||
|
if (selectedBlockId) {
|
||||||
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||||
|
if (target) {
|
||||||
|
sectionId = target.id;
|
||||||
|
replacingSectionId = target.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createFeaturesTemplateBlocks({
|
const { blocks: templateBlocks, lastSelectedId } = createFeaturesTemplateBlocks({
|
||||||
sectionId,
|
sectionId,
|
||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
let baseBlocks = state.blocks as Block[];
|
||||||
|
if (replacingSectionId) {
|
||||||
|
baseBlocks = baseBlocks.filter(
|
||||||
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blocks: newBlocks,
|
blocks: newBlocks,
|
||||||
@@ -845,14 +890,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
|
|
||||||
// Blog 템플릿 섹션 추가: 3컬럼 포스트 카드(제목/요약)를 생성한다
|
// Blog 템플릿 섹션 추가: 3컬럼 포스트 카드(제목/요약)를 생성한다
|
||||||
addBlogTemplateSection: () => {
|
addBlogTemplateSection: () => {
|
||||||
const sectionId = createId();
|
const { selectedBlockId, blocks } = get();
|
||||||
|
let sectionId = createId();
|
||||||
|
let replacingSectionId: string | null = null;
|
||||||
|
|
||||||
|
if (selectedBlockId) {
|
||||||
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||||
|
if (target) {
|
||||||
|
sectionId = target.id;
|
||||||
|
replacingSectionId = target.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createBlogTemplateBlocks({
|
const { blocks: templateBlocks, lastSelectedId } = createBlogTemplateBlocks({
|
||||||
sectionId,
|
sectionId,
|
||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
let baseBlocks = state.blocks as Block[];
|
||||||
|
if (replacingSectionId) {
|
||||||
|
baseBlocks = baseBlocks.filter(
|
||||||
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blocks: newBlocks,
|
blocks: newBlocks,
|
||||||
@@ -863,14 +926,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
|
|
||||||
// Team 템플릿 섹션 추가: 3컬럼 팀 카드(이름/역할/소개)를 생성한다
|
// Team 템플릿 섹션 추가: 3컬럼 팀 카드(이름/역할/소개)를 생성한다
|
||||||
addTeamTemplateSection: () => {
|
addTeamTemplateSection: () => {
|
||||||
const sectionId = createId();
|
const { selectedBlockId, blocks } = get();
|
||||||
|
let sectionId = createId();
|
||||||
|
let replacingSectionId: string | null = null;
|
||||||
|
|
||||||
|
if (selectedBlockId) {
|
||||||
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||||
|
if (target) {
|
||||||
|
sectionId = target.id;
|
||||||
|
replacingSectionId = target.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createTeamTemplateBlocks({
|
const { blocks: templateBlocks, lastSelectedId } = createTeamTemplateBlocks({
|
||||||
sectionId,
|
sectionId,
|
||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
let baseBlocks = state.blocks as Block[];
|
||||||
|
if (replacingSectionId) {
|
||||||
|
baseBlocks = baseBlocks.filter(
|
||||||
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blocks: newBlocks,
|
blocks: newBlocks,
|
||||||
@@ -881,14 +962,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
|
|
||||||
// Footer 템플릿 섹션 추가: 링크/카피라이트 텍스트가 포함된 1컬럼 섹션을 생성한다
|
// Footer 템플릿 섹션 추가: 링크/카피라이트 텍스트가 포함된 1컬럼 섹션을 생성한다
|
||||||
addFooterTemplateSection: () => {
|
addFooterTemplateSection: () => {
|
||||||
const sectionId = createId();
|
const { selectedBlockId, blocks } = get();
|
||||||
|
let sectionId = createId();
|
||||||
|
let replacingSectionId: string | null = null;
|
||||||
|
|
||||||
|
if (selectedBlockId) {
|
||||||
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||||
|
if (target) {
|
||||||
|
sectionId = target.id;
|
||||||
|
replacingSectionId = target.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createFooterTemplateBlocks({
|
const { blocks: templateBlocks, lastSelectedId } = createFooterTemplateBlocks({
|
||||||
sectionId,
|
sectionId,
|
||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
let baseBlocks = state.blocks as Block[];
|
||||||
|
if (replacingSectionId) {
|
||||||
|
baseBlocks = baseBlocks.filter(
|
||||||
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blocks: newBlocks,
|
blocks: newBlocks,
|
||||||
@@ -899,14 +998,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
|
|
||||||
// CTA 템플릿 섹션 추가: 텍스트와 버튼이 포함된 1컬럼 섹션을 생성한다
|
// CTA 템플릿 섹션 추가: 텍스트와 버튼이 포함된 1컬럼 섹션을 생성한다
|
||||||
addCtaTemplateSection: () => {
|
addCtaTemplateSection: () => {
|
||||||
const sectionId = createId();
|
const { selectedBlockId, blocks } = get();
|
||||||
|
let sectionId = createId();
|
||||||
|
let replacingSectionId: string | null = null;
|
||||||
|
|
||||||
|
if (selectedBlockId) {
|
||||||
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||||
|
if (target) {
|
||||||
|
sectionId = target.id;
|
||||||
|
replacingSectionId = target.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createCtaTemplateBlocks({
|
const { blocks: templateBlocks, lastSelectedId } = createCtaTemplateBlocks({
|
||||||
sectionId,
|
sectionId,
|
||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
let baseBlocks = state.blocks as Block[];
|
||||||
|
if (replacingSectionId) {
|
||||||
|
baseBlocks = baseBlocks.filter(
|
||||||
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blocks: newBlocks,
|
blocks: newBlocks,
|
||||||
@@ -917,7 +1034,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
|
|
||||||
// FAQ 템플릿 섹션 추가: 질문/답변 쌍이 수직으로 나열된 섹션을 생성한다
|
// FAQ 템플릿 섹션 추가: 질문/답변 쌍이 수직으로 나열된 섹션을 생성한다
|
||||||
addFaqTemplateSection: () => {
|
addFaqTemplateSection: () => {
|
||||||
const sectionId = createId();
|
const { selectedBlockId, blocks } = get();
|
||||||
|
let sectionId = createId();
|
||||||
|
let replacingSectionId: string | null = null;
|
||||||
|
|
||||||
|
if (selectedBlockId) {
|
||||||
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||||
|
if (target) {
|
||||||
|
sectionId = target.id;
|
||||||
|
replacingSectionId = target.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createFaqTemplateBlocks({
|
const { blocks: templateBlocks, lastSelectedId } = createFaqTemplateBlocks({
|
||||||
sectionId,
|
sectionId,
|
||||||
@@ -925,7 +1052,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
let baseBlocks = state.blocks as Block[];
|
||||||
|
if (replacingSectionId) {
|
||||||
|
baseBlocks = baseBlocks.filter(
|
||||||
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blocks: newBlocks,
|
blocks: newBlocks,
|
||||||
@@ -936,7 +1070,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
|
|
||||||
// Pricing 템플릿 섹션 추가: 3컬럼 요금제 카드(플랜 이름/가격/설명)를 생성한다
|
// Pricing 템플릿 섹션 추가: 3컬럼 요금제 카드(플랜 이름/가격/설명)를 생성한다
|
||||||
addPricingTemplateSection: () => {
|
addPricingTemplateSection: () => {
|
||||||
const sectionId = createId();
|
const { selectedBlockId, blocks } = get();
|
||||||
|
let sectionId = createId();
|
||||||
|
let replacingSectionId: string | null = null;
|
||||||
|
|
||||||
|
if (selectedBlockId) {
|
||||||
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||||
|
if (target) {
|
||||||
|
sectionId = target.id;
|
||||||
|
replacingSectionId = target.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createPricingTemplateBlocks({
|
const { blocks: templateBlocks, lastSelectedId } = createPricingTemplateBlocks({
|
||||||
sectionId,
|
sectionId,
|
||||||
@@ -944,7 +1088,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
let baseBlocks = state.blocks as Block[];
|
||||||
|
if (replacingSectionId) {
|
||||||
|
baseBlocks = baseBlocks.filter(
|
||||||
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blocks: newBlocks,
|
blocks: newBlocks,
|
||||||
@@ -955,7 +1106,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
|
|
||||||
// Testimonials 템플릿 섹션 추가: 3컬럼 후기 카드(본문/작성자)를 생성한다
|
// Testimonials 템플릿 섹션 추가: 3컬럼 후기 카드(본문/작성자)를 생성한다
|
||||||
addTestimonialsTemplateSection: () => {
|
addTestimonialsTemplateSection: () => {
|
||||||
const sectionId = createId();
|
const { selectedBlockId, blocks } = get();
|
||||||
|
let sectionId = createId();
|
||||||
|
let replacingSectionId: string | null = null;
|
||||||
|
|
||||||
|
if (selectedBlockId) {
|
||||||
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||||
|
if (target) {
|
||||||
|
sectionId = target.id;
|
||||||
|
replacingSectionId = target.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { blocks: templateBlocks, lastSelectedId } = createTestimonialsTemplateBlocks({
|
const { blocks: templateBlocks, lastSelectedId } = createTestimonialsTemplateBlocks({
|
||||||
sectionId,
|
sectionId,
|
||||||
@@ -963,7 +1124,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
let baseBlocks = state.blocks as Block[];
|
||||||
|
if (replacingSectionId) {
|
||||||
|
baseBlocks = baseBlocks.filter(
|
||||||
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blocks: newBlocks,
|
blocks: newBlocks,
|
||||||
|
|||||||
@@ -186,6 +186,33 @@ body {
|
|||||||
max-width: var(--pb-text-maxw-narrow);
|
max-width: var(--pb-text-maxw-narrow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pb-whitespace-pre-wrap {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pb-scroll {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #1f2937 #020617;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pb-scroll::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pb-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pb-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #1f2937;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 2px solid #020617;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pb-scroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
.pb-btn-base {
|
.pb-btn-base {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+1146
-171
File diff suppressed because it is too large
Load Diff
@@ -128,6 +128,7 @@ describe("/api/forms/submit", () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const json = (await res.json()) as any;
|
const json = (await res.json()) as any;
|
||||||
expect(json.ok).toBe(true);
|
expect(json.ok).toBe(true);
|
||||||
|
expect(json.message).toBe(config.successMessage);
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
const [url, options] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
const [url, options] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||||
@@ -144,4 +145,156 @@ describe("/api/forms/submit", () => {
|
|||||||
expect(parsed.source).toBe("builder");
|
expect(parsed.source).toBe("builder");
|
||||||
expect(parsed.formId).toBe("contact-json");
|
expect(parsed.formId).toBe("contact-json");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("webhook 모드 성공 시에도 successMessage 가 응답 message 필드에 포함되어야 한다", async () => {
|
||||||
|
const destinationUrl = `${BASE_URL}/webhook-success-message`;
|
||||||
|
|
||||||
|
const config: FormBlockProps = {
|
||||||
|
kind: "contact",
|
||||||
|
submitTarget: "webhook",
|
||||||
|
destinationUrl,
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: "Bearer success-token" },
|
||||||
|
extraParams: { source: "builder" },
|
||||||
|
successMessage: "웹훅으로 전송되었습니다.",
|
||||||
|
errorMessage: "웹훅 전송 실패",
|
||||||
|
fieldIds: [],
|
||||||
|
submitButtonId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async () => new Response("ok", { status: 200 }));
|
||||||
|
// @ts-expect-error - 글로벌 fetch 재정의
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("email_address", "success-webhook@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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("webhook 모드에서 destinationUrl 이 없으면 400 과 destinationUrl_missing 및 errorMessage 가 포함되어야 한다", async () => {
|
||||||
|
const config: FormBlockProps = {
|
||||||
|
kind: "contact",
|
||||||
|
submitTarget: "webhook",
|
||||||
|
// destinationUrl 누락
|
||||||
|
successMessage: "성공 메시지",
|
||||||
|
errorMessage: "Webhook URL 이 설정되지 않았습니다.",
|
||||||
|
fieldIds: [],
|
||||||
|
submitButtonId: null,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("email_address", "missing-destination@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(400);
|
||||||
|
const json = (await res.json()) as any;
|
||||||
|
expect(json.ok).toBe(false);
|
||||||
|
expect(json.error).toBe("destinationUrl_missing");
|
||||||
|
expect(json.message).toBe(config.errorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("webhook 모드에서 원격 응답이 실패하면 502 와 webhook_failed, errorMessage 가 포함되어야 한다", async () => {
|
||||||
|
const destinationUrl = `${BASE_URL}/webhook-fail`;
|
||||||
|
|
||||||
|
const config: FormBlockProps = {
|
||||||
|
kind: "contact",
|
||||||
|
submitTarget: "webhook",
|
||||||
|
destinationUrl,
|
||||||
|
method: "POST",
|
||||||
|
headers: {},
|
||||||
|
extraParams: {},
|
||||||
|
successMessage: "성공 메시지",
|
||||||
|
errorMessage: "웹훅 호출이 실패했습니다.",
|
||||||
|
fieldIds: [],
|
||||||
|
submitButtonId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async () => new Response("remote error", { status: 503 }));
|
||||||
|
// @ts-expect-error - 글로벌 fetch 재정의
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("email_address", "fail-remote@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(502);
|
||||||
|
const json = (await res.json()) as any;
|
||||||
|
expect(json.ok).toBe(false);
|
||||||
|
expect(json.error).toBe("webhook_failed");
|
||||||
|
expect(json.message).toBe(config.errorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("webhook 모드에서 fetch 예외가 발생하면 500 과 webhook_exception, errorMessage 가 포함되어야 한다", async () => {
|
||||||
|
const destinationUrl = `${BASE_URL}/webhook-exception`;
|
||||||
|
|
||||||
|
const config: FormBlockProps = {
|
||||||
|
kind: "contact",
|
||||||
|
submitTarget: "webhook",
|
||||||
|
destinationUrl,
|
||||||
|
method: "POST",
|
||||||
|
headers: {},
|
||||||
|
extraParams: {},
|
||||||
|
successMessage: "성공 메시지",
|
||||||
|
errorMessage: "웹훅 호출 중 예외가 발생했습니다.",
|
||||||
|
fieldIds: [],
|
||||||
|
submitButtonId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async () => {
|
||||||
|
throw new Error("network error");
|
||||||
|
});
|
||||||
|
// @ts-expect-error - 글로벌 fetch 재정의
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("email_address", "exception@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(500);
|
||||||
|
const json = (await res.json()) as any;
|
||||||
|
expect(json.ok).toBe(false);
|
||||||
|
expect(json.error).toBe("webhook_exception");
|
||||||
|
expect(json.message).toBe(config.errorMessage);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
|
import { BlocksSidebar } from "@/app/editor/panels/BlocksSidebar";
|
||||||
|
|
||||||
|
// BlocksSidebar 템플릿 버튼 TDD
|
||||||
|
// - 각 템플릿 추가 버튼이 대응하는 editorStore 액션(add*TemplateSection)을 호출하는지 검증한다.
|
||||||
|
|
||||||
|
const templateActions = {
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// BlocksSidebar 가 사용하는 나머지 액션들도 더미 함수로 채워둔다.
|
||||||
|
const otherActions = {
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockState = {
|
||||||
|
...templateActions,
|
||||||
|
...otherActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("@/features/editor/state/editorStore", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
// useEditorStore(selector) 형태로 사용되므로, selector 에 mockState 를 넘겨준다.
|
||||||
|
useEditorStore: (selector: (state: typeof mockState) => unknown) => selector(mockState),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BlocksSidebar - 템플릿 버튼", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
Object.values(templateActions).forEach((fn) => fn.mockReset());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Hero 템플릿 추가 버튼 클릭 시 addHeroTemplateSection 이 호출되어야 한다", () => {
|
||||||
|
render(<BlocksSidebar />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: "Hero 템플릿 추가" });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(templateActions.addHeroTemplateSection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Features 템플릿 추가 버튼 클릭 시 addFeaturesTemplateSection 이 호출되어야 한다", () => {
|
||||||
|
render(<BlocksSidebar />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: "Features 템플릿 추가" });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(templateActions.addFeaturesTemplateSection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CTA 템플릿 추가 버튼 클릭 시 addCtaTemplateSection 이 호출되어야 한다", () => {
|
||||||
|
render(<BlocksSidebar />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: "CTA 템플릿 추가" });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(templateActions.addCtaTemplateSection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 템플릿 추가" }));
|
||||||
|
|
||||||
|
expect(templateActions.addFaqTemplateSection).toHaveBeenCalledTimes(1);
|
||||||
|
expect(templateActions.addPricingTemplateSection).toHaveBeenCalledTimes(1);
|
||||||
|
expect(templateActions.addTestimonialsTemplateSection).toHaveBeenCalledTimes(1);
|
||||||
|
expect(templateActions.addBlogTemplateSection).toHaveBeenCalledTimes(1);
|
||||||
|
expect(templateActions.addTeamTemplateSection).toHaveBeenCalledTimes(1);
|
||||||
|
expect(templateActions.addFooterTemplateSection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("템플릿 섹션에는 각 템플릿의 용도를 설명하는 텍스트가 함께 표시되어야 한다", () => {
|
||||||
|
render(<BlocksSidebar />);
|
||||||
|
|
||||||
|
expect(screen.getByText("페이지 상단 Hero 섹션 (큰 제목 + 서브텍스트 + 버튼)"));
|
||||||
|
expect(screen.getByText("3컬럼 기능 소개 섹션"));
|
||||||
|
expect(screen.getByText("콜투액션(CTA) 섹션"));
|
||||||
|
expect(screen.getByText("자주 묻는 질문(FAQ) 섹션"));
|
||||||
|
expect(screen.getByText("요금제/플랜 소개 섹션"));
|
||||||
|
expect(screen.getByText("고객 후기(Testimonials) 섹션"));
|
||||||
|
expect(screen.getByText("블로그 포스트 목록 섹션"));
|
||||||
|
expect(screen.getByText("팀 소개 섹션"));
|
||||||
|
expect(screen.getByText("페이지 푸터 섹션"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("템플릿들은 카테고리별로 그룹 헤더를 가져야 한다", () => {
|
||||||
|
render(<BlocksSidebar />);
|
||||||
|
|
||||||
|
expect(screen.getByText("히어로 · CTA")).toBeDefined();
|
||||||
|
expect(screen.getByText("콘텐츠 섹션")).toBeDefined();
|
||||||
|
expect(screen.getByText("신뢰/소개")).toBeDefined();
|
||||||
|
expect(screen.getByText("푸터/기타")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("각 템플릿 카드에는 미니 썸네일(레이아웃 힌트)가 렌더링되어야 한다", () => {
|
||||||
|
render(<BlocksSidebar />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("template-preview-hero")).toBeDefined();
|
||||||
|
expect(screen.getByTestId("template-preview-features")).toBeDefined();
|
||||||
|
expect(screen.getByTestId("template-preview-cta")).toBeDefined();
|
||||||
|
expect(screen.getByTestId("template-preview-faq")).toBeDefined();
|
||||||
|
expect(screen.getByTestId("template-preview-pricing")).toBeDefined();
|
||||||
|
expect(screen.getByTestId("template-preview-testimonials")).toBeDefined();
|
||||||
|
expect(screen.getByTestId("template-preview-blog")).toBeDefined();
|
||||||
|
expect(screen.getByTestId("template-preview-team")).toBeDefined();
|
||||||
|
expect(screen.getByTestId("template-preview-footer")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("미니 썸네일은 각 템플릿 레이아웃을 암시하는 구조를 가져야 한다", () => {
|
||||||
|
render(<BlocksSidebar />);
|
||||||
|
|
||||||
|
const heroThumb = screen.getByTestId("template-preview-hero");
|
||||||
|
const ctaThumb = screen.getByTestId("template-preview-cta");
|
||||||
|
const featuresThumb = screen.getByTestId("template-preview-features");
|
||||||
|
const pricingThumb = screen.getByTestId("template-preview-pricing");
|
||||||
|
const blogThumb = screen.getByTestId("template-preview-blog");
|
||||||
|
const teamThumb = screen.getByTestId("template-preview-team");
|
||||||
|
|
||||||
|
// Hero/CTA: 하나의 메인 영역을 가진 1열 구조
|
||||||
|
expect(heroThumb.children.length).toBe(1);
|
||||||
|
expect(ctaThumb.children.length).toBe(1);
|
||||||
|
|
||||||
|
// Features/Blog: 3컬럼 레이아웃을 암시하는 3개의 세그먼트
|
||||||
|
expect(featuresThumb.children.length).toBe(3);
|
||||||
|
expect(blogThumb.children.length).toBe(3);
|
||||||
|
|
||||||
|
// Pricing/Team: 높이가 다른 3개의 바를 가진 3컬럼 카드 레이아웃
|
||||||
|
expect(pricingThumb.children.length).toBe(3);
|
||||||
|
expect(teamThumb.children.length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
|
import { ButtonPropertiesPanel } from "@/app/editor/panels/ButtonPropertiesPanel";
|
||||||
|
import type { ButtonBlockProps } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
// ButtonPropertiesPanel 컨트롤 TDD
|
||||||
|
// - 색상 HEX 인풋 및 너비 모드 셀렉트가 updateBlock 을 올바르게 호출하는지 검증한다.
|
||||||
|
|
||||||
|
describe("ButtonPropertiesPanel", () => {
|
||||||
|
const baseProps: ButtonBlockProps = {
|
||||||
|
label: "버튼",
|
||||||
|
href: "#",
|
||||||
|
align: "left",
|
||||||
|
size: "md",
|
||||||
|
variant: "solid",
|
||||||
|
colorPalette: "primary",
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ButtonPropertiesPanel
|
||||||
|
buttonProps={{ ...baseProps, textColorCustom: "#ffffff" }}
|
||||||
|
selectedBlockId="btn-1"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexInput = screen.getByLabelText("버튼 텍스트 색상 HEX");
|
||||||
|
fireEvent.change(hexInput, { target: { value: "#112233" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"btn-1",
|
||||||
|
expect.objectContaining({ textColorCustom: "#112233" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("채움 색상 HEX 인풋 변경 시 updateBlock 이 fillColorCustom 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ButtonPropertiesPanel
|
||||||
|
buttonProps={{ ...baseProps, fillColorCustom: "#000000" }}
|
||||||
|
selectedBlockId="btn-2"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexInput = screen.getByLabelText("버튼 채움 색상 HEX");
|
||||||
|
fireEvent.change(hexInput, { target: { value: "#445566" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"btn-2",
|
||||||
|
expect.objectContaining({ fillColorCustom: "#445566" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("외곽선 색상 HEX 인풋 변경 시 updateBlock 이 strokeColorCustom 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ButtonPropertiesPanel
|
||||||
|
buttonProps={{ ...baseProps, strokeColorCustom: "#000000" }}
|
||||||
|
selectedBlockId="btn-3"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexInput = screen.getByLabelText("버튼 외곽선 색상 HEX");
|
||||||
|
fireEvent.change(hexInput, { target: { value: "#778899" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"btn-3",
|
||||||
|
expect.objectContaining({ strokeColorCustom: "#778899" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("버튼 너비 모드 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ButtonPropertiesPanel
|
||||||
|
buttonProps={{ ...baseProps, widthMode: "auto", fullWidth: false }}
|
||||||
|
selectedBlockId="btn-4"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("버튼 너비 모드");
|
||||||
|
fireEvent.change(select, { target: { value: "fixed" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"btn-4",
|
||||||
|
expect.objectContaining({ widthMode: "fixed" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
|
import { DividerPropertiesPanel } from "@/app/editor/panels/DividerPropertiesPanel";
|
||||||
|
import type { DividerBlockProps } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
// DividerPropertiesPanel 컨트롤 TDD
|
||||||
|
// - 정렬/두께 select 변경 시 updateBlock 이 올바른 필드로 호출되는지
|
||||||
|
// - 색상 HEX 인풋 변경 시 colorHex 로 호출되는지
|
||||||
|
// - 위/아래 여백 커스텀(px) 입력 시 marginYPx 로 호출되는지
|
||||||
|
|
||||||
|
describe("DividerPropertiesPanel", () => {
|
||||||
|
const baseProps: DividerBlockProps = {
|
||||||
|
align: "left",
|
||||||
|
thickness: "thin",
|
||||||
|
widthMode: "full",
|
||||||
|
colorHex: "#475569",
|
||||||
|
marginY: "md",
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("정렬 select 변경 시 updateBlock 이 align 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DividerPropertiesPanel
|
||||||
|
dividerProps={baseProps}
|
||||||
|
selectedBlockId="divider-1"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("구분선 정렬");
|
||||||
|
fireEvent.change(select, { target: { value: "center" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"divider-1",
|
||||||
|
expect.objectContaining({ align: "center" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("두께 select 변경 시 updateBlock 이 thickness 로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DividerPropertiesPanel
|
||||||
|
dividerProps={baseProps}
|
||||||
|
selectedBlockId="divider-2"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("구분선 두께");
|
||||||
|
fireEvent.change(select, { target: { value: "medium" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"divider-2",
|
||||||
|
expect.objectContaining({ thickness: "medium" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("색상 HEX 인풋 변경 시 updateBlock 이 colorHex 로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DividerPropertiesPanel
|
||||||
|
dividerProps={{ ...baseProps, colorHex: "#111111" }}
|
||||||
|
selectedBlockId="divider-3"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexInputs = screen.getAllByLabelText("구분선 색상 HEX");
|
||||||
|
const hexInput = hexInputs[0] as HTMLInputElement;
|
||||||
|
fireEvent.change(hexInput, { target: { value: "#123456" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"divider-3",
|
||||||
|
expect.objectContaining({ colorHex: "#123456" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("위/아래 여백 커스텀 (px) 인풋 변경 시 updateBlock 이 marginYPx 로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DividerPropertiesPanel
|
||||||
|
dividerProps={baseProps}
|
||||||
|
selectedBlockId="divider-4"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const marginInputs = screen.getAllByLabelText("위/아래 여백 커스텀 (px)");
|
||||||
|
const marginInput = marginInputs[0] as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(marginInput, { target: { value: "24" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"divider-4",
|
||||||
|
expect.objectContaining({ marginYPx: 24 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
import EditorPage from "@/app/editor/page";
|
||||||
|
|
||||||
|
let mockState: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const baseProjectConfig: ProjectConfig = {
|
||||||
|
title: "버튼 블록 스타일 테스트",
|
||||||
|
slug: "editor-button-style-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasWidthPx: 1024,
|
||||||
|
canvasBgColorHex: "#0f172a",
|
||||||
|
bodyBgColorHex: "#020617",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
mockState = {
|
||||||
|
blocks: [] 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(),
|
||||||
|
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(),
|
||||||
|
replaceBlocks: vi.fn(),
|
||||||
|
reorderBlocks: vi.fn(),
|
||||||
|
moveBlock: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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("EditorPage - 버튼 블록 스타일", () => {
|
||||||
|
it("크기/변형/색상/패딩/텍스트 정렬이 에디터 버튼 렌더에 반영되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "btn_style_1",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "테스트 버튼",
|
||||||
|
href: "#",
|
||||||
|
align: "center",
|
||||||
|
size: "lg",
|
||||||
|
variant: "outline",
|
||||||
|
colorPalette: "success",
|
||||||
|
borderRadius: "full",
|
||||||
|
fullWidth: true,
|
||||||
|
fontSizeCustom: "18px",
|
||||||
|
lineHeightCustom: "1.8",
|
||||||
|
letterSpacingCustom: "0.1em",
|
||||||
|
fillColorCustom: "#123456",
|
||||||
|
strokeColorCustom: "#654321",
|
||||||
|
textColorCustom: "#ff0000",
|
||||||
|
paddingX: 16,
|
||||||
|
paddingY: 10,
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
mockState.blocks = blocks;
|
||||||
|
mockState.selectedBlockId = "btn_style_1";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const editorBlock = screen.getByTestId("editor-block") as HTMLElement;
|
||||||
|
const button = screen.getByRole("button", { name: "테스트 버튼" }) as HTMLButtonElement;
|
||||||
|
|
||||||
|
// 정렬: align="center" → editor-block 에 pb-text-center 클래스
|
||||||
|
expect(editorBlock.className).toContain("pb-text-center");
|
||||||
|
|
||||||
|
// 버튼 클래스: 크기/변형/둥글기 토큰이 pb-btn-* 클래스로 반영되어야 한다.
|
||||||
|
expect(button.className).toContain("pb-btn-base");
|
||||||
|
expect(button.className).toContain("pb-btn-size-lg");
|
||||||
|
expect(button.className).toContain("pb-btn-radius-full");
|
||||||
|
expect(button.className).toContain("pb-btn-variant-outline-success");
|
||||||
|
|
||||||
|
// fullWidth=true 인 경우, 래퍼 div 가 w-full 클래스를 가져야 한다.
|
||||||
|
const wrapper = button.parentElement as HTMLElement;
|
||||||
|
expect(wrapper.className).toContain("w-full");
|
||||||
|
|
||||||
|
// 스타일: 색상/패딩/타이포
|
||||||
|
expect(button.style.backgroundColor).toBe("rgb(18, 52, 86)"); // #123456
|
||||||
|
expect(button.style.borderColor).toBe("rgb(101, 67, 33)"); // #654321
|
||||||
|
expect(button.style.color).toBe("rgb(255, 0, 0)"); // #ff0000
|
||||||
|
expect(button.style.paddingInline).toBe("16px");
|
||||||
|
expect(button.style.paddingBlock).toBe("10px");
|
||||||
|
expect(button.style.fontSize).toBe("18px");
|
||||||
|
expect(button.style.lineHeight).toBe("1.8");
|
||||||
|
expect(button.style.letterSpacing).toBe("0.1em");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("widthMode=fixed 인 경우 widthPx 가 에디터 버튼 width 스타일에 반영되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "btn_width_fixed",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "고정 너비 버튼",
|
||||||
|
href: "#",
|
||||||
|
align: "left",
|
||||||
|
size: "md",
|
||||||
|
variant: "solid",
|
||||||
|
colorPalette: "primary",
|
||||||
|
borderRadius: "md",
|
||||||
|
fullWidth: false,
|
||||||
|
widthMode: "fixed",
|
||||||
|
widthPx: 200,
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
mockState.blocks = blocks;
|
||||||
|
mockState.selectedBlockId = "btn_width_fixed";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: "고정 너비 버튼" }) as HTMLButtonElement;
|
||||||
|
|
||||||
|
// widthMode="fixed" + widthPx=200 이면 버튼 style.width 가 200px 이어야 한다.
|
||||||
|
expect(button.style.width).toBe("200px");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
import { EditorCanvas } from "@/app/editor/EditorCanvas";
|
||||||
|
|
||||||
|
// 에디터 캔버스/페이지 배경색 적용 규칙에 대한 최소 TDD
|
||||||
|
// - canvasBgColorHex: 실제 캔버스 영역(editor-canvas-inner)의 배경색
|
||||||
|
// - bodyBgColorHex: 에디터 내 페이지 배경(캔버스 바깥 래퍼)의 배경색
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EditorCanvas - 캔버스/페이지 배경색", () => {
|
||||||
|
const baseProjectConfig: ProjectConfig = {
|
||||||
|
title: "배경 테스트",
|
||||||
|
slug: "bg-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasBgColorHex: "#111111",
|
||||||
|
bodyBgColorHex: "#222222",
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
blocks: [] as Block[],
|
||||||
|
rootBlocks: [] as Block[],
|
||||||
|
selectedBlockId: null as string | null,
|
||||||
|
editingBlockId: null as string | null,
|
||||||
|
editingText: "",
|
||||||
|
activeDragId: null as string | null,
|
||||||
|
sensors: null as any,
|
||||||
|
onCanvasEmptyClick: () => {},
|
||||||
|
renderBlocks: () => null as any,
|
||||||
|
handleDragStart: () => {},
|
||||||
|
handleDragEnd: () => {},
|
||||||
|
handleDragCancel: () => {},
|
||||||
|
projectConfig: baseProjectConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("canvasBgColorHex 는 editor-canvas-inner 배경색에 적용되어야 한다", () => {
|
||||||
|
render(<EditorCanvas {...baseProps} />);
|
||||||
|
|
||||||
|
const inner = screen.getByTestId("editor-canvas-inner") as HTMLElement;
|
||||||
|
|
||||||
|
expect(inner.style.backgroundColor).toBe("rgb(17, 17, 17)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bodyBgColorHex 는 editor-canvas 바깥 래퍼(editor-canvas)의 배경색에 적용되어야 한다", () => {
|
||||||
|
const { container } = render(<EditorCanvas {...baseProps} />);
|
||||||
|
|
||||||
|
const outer = container.querySelector('[data-testid="editor-canvas"]') as HTMLElement | null;
|
||||||
|
|
||||||
|
expect(outer).not.toBeNull();
|
||||||
|
expect(outer!.style.backgroundColor).toBe("rgb(34, 34, 34)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("canvasPreset / canvasWidthPx 에 따라 editor-canvas-inner 의 maxWidth 가 설정되어야 한다", () => {
|
||||||
|
const renderWithConfig = (partial: Partial<ProjectConfig>) => {
|
||||||
|
const projectConfig = { ...baseProjectConfig, ...partial } as ProjectConfig;
|
||||||
|
const { getByTestId, unmount } = render(<EditorCanvas {...baseProps} projectConfig={projectConfig} />);
|
||||||
|
const inner = getByTestId("editor-canvas-inner") as HTMLElement;
|
||||||
|
const maxWidth = inner.style.maxWidth;
|
||||||
|
unmount();
|
||||||
|
return maxWidth;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(renderWithConfig({ canvasPreset: "mobile", canvasWidthPx: 390 })).toBe("390px");
|
||||||
|
expect(renderWithConfig({ canvasPreset: "tablet", canvasWidthPx: 768 })).toBe("768px");
|
||||||
|
expect(renderWithConfig({ canvasPreset: "desktop", canvasWidthPx: 1200 })).toBe("1200px");
|
||||||
|
|
||||||
|
// full 프리셋에서 canvasWidthPx 가 없으면 maxWidth 는 비워져 있어야 한다.
|
||||||
|
expect(renderWithConfig({ canvasPreset: "full", canvasWidthPx: undefined })).toBe("");
|
||||||
|
|
||||||
|
// custom 프리셋에서는 canvasWidthPx 값이 그대로 사용된다.
|
||||||
|
expect(renderWithConfig({ canvasPreset: "custom", canvasWidthPx: 1024 })).toBe("1024px");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
import EditorPage from "@/app/editor/page";
|
||||||
|
|
||||||
|
let mockState: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const baseProjectConfig: ProjectConfig = {
|
||||||
|
title: "디바이더 블록 스타일 테스트",
|
||||||
|
slug: "editor-divider-style-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasWidthPx: 1024,
|
||||||
|
canvasBgColorHex: "#0f172a",
|
||||||
|
bodyBgColorHex: "#020617",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
mockState = {
|
||||||
|
blocks: [] 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(),
|
||||||
|
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(),
|
||||||
|
replaceBlocks: vi.fn(),
|
||||||
|
reorderBlocks: vi.fn(),
|
||||||
|
moveBlock: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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 hexToRgb(hex: string) {
|
||||||
|
const clean = hex.replace("#", "");
|
||||||
|
const int = parseInt(clean, 16);
|
||||||
|
const r = (int >> 16) & 255;
|
||||||
|
const g = (int >> 8) & 255;
|
||||||
|
const b = int & 255;
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("EditorPage - 디바이더 블록 스타일", () => {
|
||||||
|
it("colorHex / widthMode+widthPx / marginYPx 가 에디터 디바이더 렌더에 반영되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "divider_style_1",
|
||||||
|
type: "divider",
|
||||||
|
props: {
|
||||||
|
align: "center",
|
||||||
|
thickness: "medium",
|
||||||
|
widthMode: "fixed",
|
||||||
|
widthPx: 400,
|
||||||
|
colorHex: "#123456",
|
||||||
|
marginYPx: 32,
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
mockState.blocks = blocks;
|
||||||
|
mockState.selectedBlockId = "divider_style_1";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
// EditorPage 의 디바이더는 wrapper div 안에 border-t 클래스를 가진 div 로 렌더된다.
|
||||||
|
const editorBlock = screen.getByTestId("editor-block") as HTMLElement;
|
||||||
|
const inner = editorBlock.querySelector("div.border-t") as HTMLElement;
|
||||||
|
expect(inner).toBeTruthy();
|
||||||
|
|
||||||
|
expect(inner.style.borderColor).toBe(hexToRgb("#123456"));
|
||||||
|
expect(inner.style.width).toBe("400px");
|
||||||
|
|
||||||
|
// marginTop/marginBottom 은 상위 wrapper div 에 적용된다.
|
||||||
|
const wrapper = inner.parentElement as HTMLElement;
|
||||||
|
expect(wrapper.style.marginTop).toBe("32px");
|
||||||
|
expect(wrapper.style.marginBottom).toBe("32px");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
import EditorPage from "@/app/editor/page";
|
||||||
|
|
||||||
|
let mockState: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const baseProjectConfig: ProjectConfig = {
|
||||||
|
title: "폼 블록 스타일 테스트",
|
||||||
|
slug: "editor-form-style-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasWidthPx: 1024,
|
||||||
|
canvasBgColorHex: "#0f172a",
|
||||||
|
bodyBgColorHex: "#020617",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
mockState = {
|
||||||
|
blocks: [] 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(),
|
||||||
|
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(),
|
||||||
|
replaceBlocks: vi.fn(),
|
||||||
|
reorderBlocks: vi.fn(),
|
||||||
|
moveBlock: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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 hexToRgb(hex: string) {
|
||||||
|
const clean = hex.replace("#", "");
|
||||||
|
const int = parseInt(clean, 16);
|
||||||
|
const r = (int >> 16) & 255;
|
||||||
|
const g = (int >> 8) & 255;
|
||||||
|
const b = int & 255;
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("EditorPage - 폼 블록 스타일", () => {
|
||||||
|
it("formInput: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_input_1",
|
||||||
|
type: "formInput",
|
||||||
|
props: {
|
||||||
|
label: "폼 입력",
|
||||||
|
inputType: "text",
|
||||||
|
textColorCustom: "#ff0000",
|
||||||
|
fillColorCustom: "#123456",
|
||||||
|
strokeColorCustom: "#654321",
|
||||||
|
widthMode: "fixed",
|
||||||
|
widthPx: 240,
|
||||||
|
borderRadius: "full",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
mockState.blocks = blocks;
|
||||||
|
mockState.selectedBlockId = "form_input_1";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const field = screen.getByTestId("form-input-field") as HTMLElement;
|
||||||
|
|
||||||
|
expect(field.style.color).toBe(hexToRgb("#ff0000"));
|
||||||
|
expect(field.style.backgroundColor).toBe(hexToRgb("#123456"));
|
||||||
|
expect(field.style.borderColor).toBe(hexToRgb("#654321"));
|
||||||
|
expect(field.style.width).toBe("240px");
|
||||||
|
// borderRadius=full → 9999px 로 적용
|
||||||
|
expect(field.style.borderRadius).toBe("9999px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formSelect: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_select_1",
|
||||||
|
type: "formSelect",
|
||||||
|
props: {
|
||||||
|
label: "폼 셀렉트",
|
||||||
|
textColorCustom: "#ff0000",
|
||||||
|
fillColorCustom: "#123456",
|
||||||
|
strokeColorCustom: "#654321",
|
||||||
|
widthMode: "fixed",
|
||||||
|
widthPx: 260,
|
||||||
|
borderRadius: "lg",
|
||||||
|
options: [
|
||||||
|
{ label: "A", value: "a" },
|
||||||
|
{ label: "B", value: "b" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
mockState.blocks = blocks;
|
||||||
|
mockState.selectedBlockId = "form_select_1";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("폼 셀렉트") as HTMLSelectElement;
|
||||||
|
const wrapper = select.parentElement as HTMLElement; // style 이 적용된 div
|
||||||
|
|
||||||
|
expect(wrapper.style.color).toBe(hexToRgb("#ff0000"));
|
||||||
|
expect(wrapper.style.backgroundColor).toBe(hexToRgb("#123456"));
|
||||||
|
expect(wrapper.style.borderColor).toBe(hexToRgb("#654321"));
|
||||||
|
expect(wrapper.style.width).toBe("260px");
|
||||||
|
expect(wrapper.style.borderRadius).toBe("9999px"); // lg → 9999 (에디터 구현 기준)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formRadio: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_radio_1",
|
||||||
|
type: "formRadio",
|
||||||
|
props: {
|
||||||
|
formFieldName: "radio-group",
|
||||||
|
groupLabel: "라디오 그룹",
|
||||||
|
textColorCustom: "#ff0000",
|
||||||
|
fillColorCustom: "#123456",
|
||||||
|
strokeColorCustom: "#654321",
|
||||||
|
widthMode: "fixed",
|
||||||
|
widthPx: 280,
|
||||||
|
borderRadius: "lg",
|
||||||
|
options: [
|
||||||
|
{ label: "옵션 A", value: "a" },
|
||||||
|
{ label: "옵션 B", value: "b" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
mockState.blocks = blocks;
|
||||||
|
mockState.selectedBlockId = "form_radio_1";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const label = screen.getByText("라디오 그룹");
|
||||||
|
const container = label.nextElementSibling as HTMLElement; // style 이 적용된 div
|
||||||
|
|
||||||
|
expect(container).toBeTruthy();
|
||||||
|
expect(container.style.color).toBe(hexToRgb("#ff0000"));
|
||||||
|
expect(container.style.backgroundColor).toBe(hexToRgb("#123456"));
|
||||||
|
expect(container.style.borderColor).toBe(hexToRgb("#654321"));
|
||||||
|
expect(container.style.width).toBe("280px");
|
||||||
|
expect(container.style.borderRadius).toBe("9999px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formCheckbox: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_checkbox_1",
|
||||||
|
type: "formCheckbox",
|
||||||
|
props: {
|
||||||
|
formFieldName: "check-group",
|
||||||
|
groupLabel: "체크 그룹",
|
||||||
|
textColorCustom: "#ff0000",
|
||||||
|
fillColorCustom: "#123456",
|
||||||
|
strokeColorCustom: "#654321",
|
||||||
|
widthMode: "fixed",
|
||||||
|
widthPx: 300,
|
||||||
|
borderRadius: "lg",
|
||||||
|
options: [
|
||||||
|
{ label: "체크 1", value: "c1" },
|
||||||
|
{ label: "체크 2", value: "c2" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
mockState.blocks = blocks;
|
||||||
|
mockState.selectedBlockId = "form_checkbox_1";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
// "체크 그룹" 텍스트를 포함하는 가장 가까운 컨테이너를 찾아 스타일을 검사한다.
|
||||||
|
const label = screen.getByText("체크 그룹");
|
||||||
|
const groupContainer = label.closest("div") as HTMLElement;
|
||||||
|
|
||||||
|
expect(groupContainer.style.color).toBe(hexToRgb("#ff0000"));
|
||||||
|
expect(groupContainer.style.backgroundColor).toBe(hexToRgb("#123456"));
|
||||||
|
expect(groupContainer.style.borderColor).toBe(hexToRgb("#654321"));
|
||||||
|
expect(groupContainer.style.width).toBe("300px");
|
||||||
|
expect(groupContainer.style.borderRadius).toBe("9999px");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
import EditorPage from "@/app/editor/page";
|
||||||
|
|
||||||
|
let mockState: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const baseProjectConfig: ProjectConfig = {
|
||||||
|
title: "이미지 블록 스타일 테스트",
|
||||||
|
slug: "editor-image-style-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasWidthPx: 1024,
|
||||||
|
canvasBgColorHex: "#0f172a",
|
||||||
|
bodyBgColorHex: "#020617",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
mockState = {
|
||||||
|
blocks: [] 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(),
|
||||||
|
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(),
|
||||||
|
replaceBlocks: vi.fn(),
|
||||||
|
reorderBlocks: vi.fn(),
|
||||||
|
moveBlock: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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("EditorPage - 이미지 블록 스타일", () => {
|
||||||
|
it("widthMode=fixed 일 때 widthPx / borderRadiusPx 가 에디터 이미지 렌더에 반영되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "image_style_1",
|
||||||
|
type: "image",
|
||||||
|
props: {
|
||||||
|
src: "/images/example.png",
|
||||||
|
alt: "예제 이미지",
|
||||||
|
align: "center",
|
||||||
|
widthMode: "fixed",
|
||||||
|
widthPx: 320,
|
||||||
|
borderRadius: "lg",
|
||||||
|
borderRadiusPx: 24,
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
mockState.blocks = blocks;
|
||||||
|
mockState.selectedBlockId = "image_style_1";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const img = screen.getByAltText("예제 이미지") as HTMLImageElement;
|
||||||
|
|
||||||
|
expect(img.style.width).toBe("320px");
|
||||||
|
expect(img.style.borderRadius).toBe("24px");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
import EditorPage from "@/app/editor/page";
|
||||||
|
|
||||||
|
let mockState: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const baseProjectConfig: ProjectConfig = {
|
||||||
|
title: "레이아웃 스크롤 테스트",
|
||||||
|
slug: "layout-scroll-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasWidthPx: 1024,
|
||||||
|
canvasBgColorHex: "#0f172a",
|
||||||
|
bodyBgColorHex: "#020617",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
mockState = {
|
||||||
|
blocks: [] 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(),
|
||||||
|
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(),
|
||||||
|
replaceBlocks: vi.fn(),
|
||||||
|
reorderBlocks: vi.fn(),
|
||||||
|
moveBlock: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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("EditorPage - 레이아웃 스크롤 컨테이너", () => {
|
||||||
|
it("좌측 사이드바, 캔버스, 우측 속성 패널에 pb-scroll 클래스가 적용되어야 한다", () => {
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const blocksHeading = screen.getByText("블록");
|
||||||
|
const blocksSidebar = blocksHeading.closest("aside") as HTMLElement;
|
||||||
|
const canvas = screen.getByTestId("editor-canvas");
|
||||||
|
const propertiesSidebar = screen.getByTestId("properties-sidebar");
|
||||||
|
|
||||||
|
expect(blocksSidebar).not.toBeNull();
|
||||||
|
expect(blocksSidebar.className).toContain("pb-scroll");
|
||||||
|
expect(canvas.className).toContain("pb-scroll");
|
||||||
|
expect(propertiesSidebar.className).toContain("pb-scroll");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
import EditorPage from "@/app/editor/page";
|
||||||
|
|
||||||
|
let mockState: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const baseProjectConfig: ProjectConfig = {
|
||||||
|
title: "리스트 블록 스타일 테스트",
|
||||||
|
slug: "editor-list-style-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasWidthPx: 1024,
|
||||||
|
canvasBgColorHex: "#0f172a",
|
||||||
|
bodyBgColorHex: "#020617",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
mockState = {
|
||||||
|
blocks: [] 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(),
|
||||||
|
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(),
|
||||||
|
replaceBlocks: vi.fn(),
|
||||||
|
reorderBlocks: vi.fn(),
|
||||||
|
moveBlock: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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 hexToRgb(hex: string) {
|
||||||
|
const clean = hex.replace("#", "");
|
||||||
|
const int = parseInt(clean, 16);
|
||||||
|
const r = (int >> 16) & 255;
|
||||||
|
const g = (int >> 8) & 255;
|
||||||
|
const b = int & 255;
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("EditorPage - 리스트 블록 스타일", () => {
|
||||||
|
it("fontSizeCustom / lineHeightCustom / textColorCustom 가 에디터 리스트 렌더에 반영되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "list_style_1",
|
||||||
|
type: "list",
|
||||||
|
props: {
|
||||||
|
items: ["Item 1", "Item 2"],
|
||||||
|
ordered: false,
|
||||||
|
align: "left",
|
||||||
|
fontSizeCustom: "18px",
|
||||||
|
lineHeightCustom: "1.8",
|
||||||
|
textColorCustom: "#ff0000",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
mockState.blocks = blocks;
|
||||||
|
mockState.selectedBlockId = "list_style_1";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const ul = screen.getByRole("list") as HTMLElement;
|
||||||
|
|
||||||
|
expect(ul.style.fontSize).toBe("18px");
|
||||||
|
expect(ul.style.lineHeight).toBe("1.8");
|
||||||
|
expect(ul.style.color).toBe(hexToRgb("#ff0000"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
let mockState: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const baseProjectConfig: ProjectConfig = {
|
||||||
|
title: "멀티 선택 테스트",
|
||||||
|
slug: "multi-select",
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasWidthPx: 1024,
|
||||||
|
canvasBgColorHex: "#0f172a",
|
||||||
|
bodyBgColorHex: "#020617",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
mockState = {
|
||||||
|
blocks: [] 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.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 setupThreeBlocks() {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "blk_1",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "블록 1",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: "blk_2",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "블록 2",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: "blk_3",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "블록 3",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
mockState.blocks = blocks;
|
||||||
|
mockState.selectedBlockId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("EditorPage - 멀티 선택", () => {
|
||||||
|
it("Cmd/Ctrl+클릭으로 여러 블록을 동시에 선택할 수 있어야 한다", () => {
|
||||||
|
setupThreeBlocks();
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const blocksEls = screen.getAllByTestId("editor-block") as HTMLElement[];
|
||||||
|
|
||||||
|
// 첫 번째와 두 번째 블록을 Cmd/Ctrl+클릭
|
||||||
|
fireEvent.click(blocksEls[0], { metaKey: true });
|
||||||
|
fireEvent.click(blocksEls[1], { metaKey: true });
|
||||||
|
|
||||||
|
const selectedEls = blocksEls.filter((el) => el.dataset.selected === "true");
|
||||||
|
expect(selectedEls.length).toBe(2);
|
||||||
|
const selectedIds = selectedEls.map((el) => el.getAttribute("data-block-id") ?? "");
|
||||||
|
expect(new Set(selectedIds)).toEqual(new Set(["blk_1", "blk_2"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("멀티 선택 상태에서 Backspace 로 선택된 모든 블록이 삭제되어야 한다", () => {
|
||||||
|
setupThreeBlocks();
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const blocksEls = screen.getAllByTestId("editor-block") as HTMLElement[];
|
||||||
|
|
||||||
|
// 블록 1, 2 를 멀티 선택
|
||||||
|
fireEvent.click(blocksEls[0], { metaKey: true });
|
||||||
|
fireEvent.click(blocksEls[1], { metaKey: true });
|
||||||
|
|
||||||
|
// Backspace 로 삭제
|
||||||
|
const event = new KeyboardEvent("keydown", { key: "Backspace" });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
// replaceBlocks 가 한 번 호출되고, 남은 블록은 blk_3 만 있어야 한다.
|
||||||
|
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
||||||
|
const arg = mockState.replaceBlocks.mock.calls[0][0] as Block[];
|
||||||
|
expect(arg.length).toBe(1);
|
||||||
|
expect(arg[0].id).toBe("blk_3");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
import EditorPage from "@/app/editor/page";
|
||||||
|
|
||||||
|
let mockState: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const baseProjectConfig: ProjectConfig = {
|
||||||
|
title: "배경 테스트",
|
||||||
|
slug: "bg-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasBgColorHex: "#111111",
|
||||||
|
bodyBgColorHex: "#222222",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
mockState = {
|
||||||
|
blocks: [] 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.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("main 요소는 bodyBgColorHex 로 직접 배경색을 갖지 않아야 하고, 캔버스 래퍼만 배경색을 가져야 한다", () => {
|
||||||
|
const { container } = render(<EditorPage />);
|
||||||
|
|
||||||
|
const mainEl = container.querySelector("main") as HTMLElement | null;
|
||||||
|
expect(mainEl).not.toBeNull();
|
||||||
|
// main 은 bodyBgColorHex 기반 배경색을 직접 갖지 않는다.
|
||||||
|
expect(mainEl!.style.backgroundColor).toBe("");
|
||||||
|
|
||||||
|
const canvasOuter = screen.getByTestId("editor-canvas") as HTMLElement;
|
||||||
|
expect(canvasOuter.style.backgroundColor).toBe("rgb(34, 34, 34)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi, beforeEach } from "vitest";
|
||||||
|
import { render, cleanup } from "@testing-library/react";
|
||||||
|
import EditorPage from "@/app/editor/page";
|
||||||
|
|
||||||
|
// EditorPage 전역 단축키 TDD
|
||||||
|
// - Undo/Redo, 삭제/복제, 선택 이동(ArrowUp/Down) 단축키가 editorStore 액션을 호출하는지 검증한다.
|
||||||
|
// - 입력 포커스가 input/textarea 인 경우에는 단축키가 동작하지 않아야 한다.
|
||||||
|
|
||||||
|
const undo = vi.fn();
|
||||||
|
const redo = vi.fn();
|
||||||
|
const removeBlock = vi.fn();
|
||||||
|
const duplicateBlock = vi.fn();
|
||||||
|
const selectBlock = vi.fn();
|
||||||
|
|
||||||
|
let mockState: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockState = {
|
||||||
|
// 블록 2개를 가진 기본 상태
|
||||||
|
blocks: [
|
||||||
|
{ id: "blk_1", type: "text", props: { text: "첫 번째" } },
|
||||||
|
{ id: "blk_2", type: "text", props: { text: "두 번째" } },
|
||||||
|
],
|
||||||
|
selectedBlockId: "blk_1",
|
||||||
|
selectedListItemId: null,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
removeBlock,
|
||||||
|
duplicateBlock,
|
||||||
|
selectBlock,
|
||||||
|
// EditorPage 가 참조하지만 이 테스트에서 중요하지 않은 액션들: no-op
|
||||||
|
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(),
|
||||||
|
selectListItem: vi.fn(),
|
||||||
|
indentSelectedListItem: vi.fn(),
|
||||||
|
outdentSelectedListItem: vi.fn(),
|
||||||
|
moveSelectedListItemUp: vi.fn(),
|
||||||
|
moveSelectedListItemDown: vi.fn(),
|
||||||
|
replaceBlocks: vi.fn(),
|
||||||
|
reorderBlocks: vi.fn(),
|
||||||
|
moveBlock: vi.fn(),
|
||||||
|
projectConfig: {
|
||||||
|
title: "테스트 페이지",
|
||||||
|
slug: "test-page",
|
||||||
|
canvasPreset: "full",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
undo.mockReset();
|
||||||
|
redo.mockReset();
|
||||||
|
removeBlock.mockReset();
|
||||||
|
duplicateBlock.mockReset();
|
||||||
|
selectBlock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
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>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EditorPage - 전역 단축키", () => {
|
||||||
|
it("Cmd/Ctrl+Z 는 undo 액션을 호출해야 한다", () => {
|
||||||
|
// Mac 플랫폼으로 강제 설정
|
||||||
|
const originalPlatform = Object.getOwnPropertyDescriptor(navigator, "platform");
|
||||||
|
Object.defineProperty(navigator, "platform", { value: "MacIntel", configurable: true });
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const event = new KeyboardEvent("keydown", { key: "z", metaKey: true });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(undo).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
if (originalPlatform) {
|
||||||
|
Object.defineProperty(navigator, "platform", originalPlatform);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Cmd/Ctrl+Shift+Z 는 redo 액션을 호출해야 한다", () => {
|
||||||
|
const originalPlatform = Object.getOwnPropertyDescriptor(navigator, "platform");
|
||||||
|
Object.defineProperty(navigator, "platform", { value: "MacIntel", configurable: true });
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const event = new KeyboardEvent("keydown", { key: "z", metaKey: true, shiftKey: true });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(redo).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
if (originalPlatform) {
|
||||||
|
Object.defineProperty(navigator, "platform", originalPlatform);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Delete/Backspace 는 선택된 블록을 removeBlock 으로 삭제해야 한다", () => {
|
||||||
|
mockState.selectedBlockId = "blk_1";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const event = new KeyboardEvent("keydown", { key: "Backspace" });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(removeBlock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(removeBlock).toHaveBeenCalledWith("blk_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Cmd/Ctrl+D 는 선택된 블록을 duplicateBlock 으로 복제해야 한다", () => {
|
||||||
|
const originalPlatform = Object.getOwnPropertyDescriptor(navigator, "platform");
|
||||||
|
Object.defineProperty(navigator, "platform", { value: "MacIntel", configurable: true });
|
||||||
|
|
||||||
|
mockState.selectedBlockId = "blk_2";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const event = new KeyboardEvent("keydown", { key: "d", metaKey: true });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(duplicateBlock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(duplicateBlock).toHaveBeenCalledWith("blk_2");
|
||||||
|
|
||||||
|
if (originalPlatform) {
|
||||||
|
Object.defineProperty(navigator, "platform", originalPlatform);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ArrowDown 은 선택이 없을 때 첫 번째 블록을 선택해야 한다", () => {
|
||||||
|
mockState.selectedBlockId = null;
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const event = new KeyboardEvent("keydown", { key: "ArrowDown" });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(selectBlock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(selectBlock).toHaveBeenCalledWith("blk_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ArrowUp 은 선택이 없을 때 마지막 블록을 선택해야 한다", () => {
|
||||||
|
mockState.selectedBlockId = null;
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const event = new KeyboardEvent("keydown", { key: "ArrowUp" });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(selectBlock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(selectBlock).toHaveBeenCalledWith("blk_2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("입력 포커스(input) 상태에서는 Backspace 로 removeBlock 이 호출되면 안 된다", () => {
|
||||||
|
mockState.selectedBlockId = "blk_1";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
const event = new KeyboardEvent("keydown", { key: "Backspace", bubbles: true });
|
||||||
|
Object.defineProperty(event, "target", { value: input, configurable: true });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(removeBlock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
document.body.removeChild(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
import EditorPage from "@/app/editor/page";
|
||||||
|
import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
|
||||||
|
|
||||||
|
let mockState: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const baseProjectConfig: ProjectConfig = {
|
||||||
|
title: "섹션 레이아웃 테스트",
|
||||||
|
slug: "section-layout-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasWidthPx: 1024,
|
||||||
|
canvasBgColorHex: "#0f172a",
|
||||||
|
bodyBgColorHex: "#020617",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
mockState = {
|
||||||
|
blocks: [] as Block[],
|
||||||
|
projectConfig: baseProjectConfig,
|
||||||
|
selectedBlockId: null as string | null,
|
||||||
|
selectedListItemId: null as string | null,
|
||||||
|
// 에디터 액션들은 모두 더미 함수로 채워 EditorPage 렌더링이 가능하도록 한다.
|
||||||
|
undo: vi.fn(),
|
||||||
|
redo: vi.fn(),
|
||||||
|
removeBlock: vi.fn(),
|
||||||
|
duplicateBlock: vi.fn(),
|
||||||
|
selectBlock: vi.fn(),
|
||||||
|
selectListItem: 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(),
|
||||||
|
replaceBlocks: vi.fn(),
|
||||||
|
reorderBlocks: vi.fn(),
|
||||||
|
moveBlock: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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("EditorPage - 섹션 레이아웃 속성", () => {
|
||||||
|
it("backgroundColorCustom / paddingYPx / maxWidthPx / gapXPx 가 editor-section 스타일에 반영되어야 한다", () => {
|
||||||
|
const section: Block = {
|
||||||
|
id: "sec_layout",
|
||||||
|
type: "section",
|
||||||
|
props: {
|
||||||
|
background: "default",
|
||||||
|
paddingY: "md",
|
||||||
|
columns: [
|
||||||
|
{ id: "sec_layout_col_1", span: 12 },
|
||||||
|
],
|
||||||
|
maxWidthMode: "normal",
|
||||||
|
gapX: "md",
|
||||||
|
alignItems: "top",
|
||||||
|
backgroundColorCustom: "#123456",
|
||||||
|
paddingYPx: 80,
|
||||||
|
maxWidthPx: 960,
|
||||||
|
gapXPx: 32,
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const child: Block = {
|
||||||
|
id: "txt_1",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "섹션 안 텍스트",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
sectionId: "sec_layout",
|
||||||
|
columnId: "sec_layout_col_1",
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
mockState.blocks = [section, child];
|
||||||
|
mockState.selectedBlockId = "sec_layout";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
|
||||||
|
// 배경색: #123456 → rgb(18, 52, 86)
|
||||||
|
expect(sectionEl.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||||
|
// 세로 패딩(px)이 스타일에 반영되어야 한다.
|
||||||
|
expect(sectionEl.style.paddingTop).toBe("80px");
|
||||||
|
expect(sectionEl.style.paddingBottom).toBe("80px");
|
||||||
|
|
||||||
|
// 내부 maxWidth
|
||||||
|
const inner = sectionEl.querySelector("div.mx-auto") as HTMLElement;
|
||||||
|
expect(inner).toBeTruthy();
|
||||||
|
expect(inner.style.maxWidth).toBe("960px");
|
||||||
|
|
||||||
|
// 컬럼 간 간격(gapXPx)
|
||||||
|
const columns = sectionEl.querySelector("div.flex") as HTMLElement;
|
||||||
|
expect(columns).toBeTruthy();
|
||||||
|
expect(columns.style.columnGap).toBe("32px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("alignItems 값에 따라 섹션 컬럼 컨테이너가 items-*- 정렬 클래스를 가져야 한다", () => {
|
||||||
|
const section: Block = {
|
||||||
|
id: "sec_align",
|
||||||
|
type: "section",
|
||||||
|
props: {
|
||||||
|
background: "default",
|
||||||
|
paddingY: "md",
|
||||||
|
columns: [
|
||||||
|
{ id: "sec_align_col_1", span: 12 },
|
||||||
|
],
|
||||||
|
maxWidthMode: "normal",
|
||||||
|
gapX: "md",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const child: Block = {
|
||||||
|
id: "txt_align",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "세로 정렬 테스트",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
sectionId: "sec_align",
|
||||||
|
columnId: "sec_align_col_1",
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
mockState.blocks = [section, child];
|
||||||
|
mockState.selectedBlockId = "sec_align";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
|
||||||
|
const columns = sectionEl.querySelector("div.flex") as HTMLElement;
|
||||||
|
expect(columns).toBeTruthy();
|
||||||
|
// alignItems="center" 인 경우 items-center 클래스가 포함되어야 한다.
|
||||||
|
expect(columns.className).toContain("items-center");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Hero 템플릿 섹션의 레이아웃 속성이 EditorPage 섹션 렌더에 반영되어야 한다", () => {
|
||||||
|
const sectionId = "hero_section_layout";
|
||||||
|
const createId = (() => {
|
||||||
|
let i = 0;
|
||||||
|
return () => `hero_${++i}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const { blocks } = createHeroTemplateBlocks({ sectionId, createId });
|
||||||
|
|
||||||
|
mockState.blocks = blocks as Block[];
|
||||||
|
mockState.selectedBlockId = sectionId;
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
|
||||||
|
// Hero 템플릿 배경색(#020617)과 패딩, 최대 폭, gap 이 에디터 섹션에도 반영되어야 한다.
|
||||||
|
expect(sectionEl.style.backgroundColor).toBe("rgb(2, 6, 23)");
|
||||||
|
expect(sectionEl.style.paddingTop).toBe("96px");
|
||||||
|
expect(sectionEl.style.paddingBottom).toBe("96px");
|
||||||
|
|
||||||
|
const inner = sectionEl.querySelector("div.mx-auto") as HTMLElement;
|
||||||
|
expect(inner.style.maxWidth).toBe("960px");
|
||||||
|
|
||||||
|
const columns = sectionEl.querySelector("div.flex") as HTMLElement;
|
||||||
|
expect(columns.style.columnGap).toBe("40px");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
import EditorPage from "@/app/editor/page";
|
||||||
|
|
||||||
|
let mockState: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const baseProjectConfig: ProjectConfig = {
|
||||||
|
title: "선택 포커스 테스트",
|
||||||
|
slug: "selection-focus",
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasWidthPx: 1024,
|
||||||
|
canvasBgColorHex: "#0f172a",
|
||||||
|
bodyBgColorHex: "#020617",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
mockState = {
|
||||||
|
blocks: [] 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(),
|
||||||
|
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(),
|
||||||
|
replaceBlocks: vi.fn(),
|
||||||
|
reorderBlocks: vi.fn(),
|
||||||
|
moveBlock: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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("EditorPage - 선택 포커스", () => {
|
||||||
|
it("선택된 블록은 editor-block 에 border-sky-500 / bg-slate-900/80 하이라이트를 가져야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "blk_1",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "첫 번째 블록",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: "blk_2",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "두 번째 블록",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
mockState.blocks = blocks;
|
||||||
|
mockState.selectedBlockId = "blk_2";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const allBlocks = screen.getAllByTestId("editor-block") as HTMLElement[];
|
||||||
|
const selected = allBlocks.find((el) => el.dataset.selected === "true");
|
||||||
|
|
||||||
|
expect(selected).toBeTruthy();
|
||||||
|
expect(selected!.className).toContain("border-sky-500");
|
||||||
|
expect(selected!.className).toContain("bg-slate-900/80");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("섹션 블록 자체를 선택하면 섹션 wrapper 가 강조되어야 한다", () => {
|
||||||
|
const section: Block = {
|
||||||
|
id: "sec_1",
|
||||||
|
type: "section",
|
||||||
|
props: {
|
||||||
|
background: "default",
|
||||||
|
paddingY: "md",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
id: "sec_1_col_1",
|
||||||
|
span: 12,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
maxWidthMode: "normal",
|
||||||
|
gapX: "md",
|
||||||
|
alignItems: "top",
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
mockState.blocks = [section];
|
||||||
|
mockState.selectedBlockId = "sec_1";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
|
||||||
|
expect(sectionEl.className).toContain("border-sky-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("섹션 자식 블록을 선택해도 섹션 wrapper 가 강조되어야 한다", () => {
|
||||||
|
const section: Block = {
|
||||||
|
id: "sec_1",
|
||||||
|
type: "section",
|
||||||
|
props: {
|
||||||
|
background: "default",
|
||||||
|
paddingY: "md",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
id: "sec_1_col_1",
|
||||||
|
span: 12,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
maxWidthMode: "normal",
|
||||||
|
gapX: "md",
|
||||||
|
alignItems: "top",
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const child: Block = {
|
||||||
|
id: "txt_child",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "섹션 안 텍스트",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
sectionId: "sec_1",
|
||||||
|
columnId: "sec_1_col_1",
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
mockState.blocks = [section, child];
|
||||||
|
mockState.selectedBlockId = "txt_child";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
|
||||||
|
expect(sectionEl.className).toContain("border-sky-500");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
import EditorPage from "@/app/editor/page";
|
||||||
|
|
||||||
|
let mockState: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const baseProjectConfig: ProjectConfig = {
|
||||||
|
title: "텍스트 블록 스타일 테스트",
|
||||||
|
slug: "editor-text-style-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasWidthPx: 1024,
|
||||||
|
canvasBgColorHex: "#0f172a",
|
||||||
|
bodyBgColorHex: "#020617",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
mockState = {
|
||||||
|
blocks: [] 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(),
|
||||||
|
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(),
|
||||||
|
replaceBlocks: vi.fn(),
|
||||||
|
reorderBlocks: vi.fn(),
|
||||||
|
moveBlock: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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("EditorPage - 텍스트 블록 스타일", () => {
|
||||||
|
it("colorCustom / backgroundColorCustom / maxWidthCustom 이 에디터 텍스트 렌더에 반영되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "txt_style_1",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "스타일 테스트 텍스트",
|
||||||
|
align: "center",
|
||||||
|
size: "base",
|
||||||
|
colorCustom: "#ff0000",
|
||||||
|
backgroundColorCustom: "#123456",
|
||||||
|
maxWidthCustom: "320px",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
mockState.blocks = blocks;
|
||||||
|
mockState.selectedBlockId = "txt_style_1";
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const blockEl = screen.getByTestId("editor-block") as HTMLElement;
|
||||||
|
const textEl = blockEl.querySelector("div.whitespace-pre-wrap") as HTMLElement;
|
||||||
|
expect(textEl).toBeTruthy();
|
||||||
|
|
||||||
|
// 텍스트 색상: #ff0000 → rgb(255, 0, 0)
|
||||||
|
expect(textEl.style.color).toBe("rgb(255, 0, 0)");
|
||||||
|
// 배경색: #123456 → rgb(18, 52, 86)
|
||||||
|
expect(textEl.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||||
|
// 최대 너비: maxWidthCustom 이 style.maxWidth 로 반영되어야 한다.
|
||||||
|
expect(textEl.style.maxWidth).toBe("320px");
|
||||||
|
|
||||||
|
// 정렬 클래스는 pb-text-center 로 유지되어야 한다.
|
||||||
|
expect(blockEl.className).toContain("pb-text-center");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
|
import { FormControllerPanel } from "@/app/editor/forms/FormControllerPanel";
|
||||||
|
import type { Block, FormBlockProps } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
// FormControllerPanel 컨트롤 TDD
|
||||||
|
// - 성공/에러 메시지 인풋이 updateBlock 을 통해 FormBlockProps.successMessage / errorMessage 를 갱신하는지 검증한다.
|
||||||
|
|
||||||
|
function makeFormBlock(id: string, props: Partial<FormBlockProps> = {}): Block {
|
||||||
|
const base: FormBlockProps = {
|
||||||
|
kind: "contact",
|
||||||
|
submitTarget: "internal",
|
||||||
|
fieldIds: [],
|
||||||
|
submitButtonId: null,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: "form",
|
||||||
|
props: { ...base, ...props } as any,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FormControllerPanel", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("성공 메시지 인풋 변경 시 updateBlock 이 successMessage 로 호출되어야 한다", () => {
|
||||||
|
const formBlock = makeFormBlock("form-1", {
|
||||||
|
successMessage: "기존 성공 메시지",
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormControllerPanel
|
||||||
|
block={formBlock}
|
||||||
|
blocks={[formBlock]}
|
||||||
|
selectedBlockId="form-1"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("성공 메시지");
|
||||||
|
fireEvent.change(input, { target: { value: "새 성공 메시지" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"form-1",
|
||||||
|
expect.objectContaining({ successMessage: "새 성공 메시지" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("에러 메시지 인풋 변경 시 updateBlock 이 errorMessage 로 호출되어야 한다", () => {
|
||||||
|
const formBlock = makeFormBlock("form-2", {
|
||||||
|
errorMessage: "기존 에러 메시지",
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormControllerPanel
|
||||||
|
block={formBlock}
|
||||||
|
blocks={[formBlock]}
|
||||||
|
selectedBlockId="form-2"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("에러 메시지");
|
||||||
|
fireEvent.change(input, { target: { value: "새 에러 메시지" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"form-2",
|
||||||
|
expect.objectContaining({ errorMessage: "새 에러 메시지" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
|
import { FormInputPropertiesPanel } from "@/app/editor/forms/FormInputPropertiesPanel";
|
||||||
|
import { FormSelectPropertiesPanel } from "@/app/editor/forms/FormSelectPropertiesPanel";
|
||||||
|
import { FormCheckboxPropertiesPanel } from "@/app/editor/forms/FormCheckboxPropertiesPanel";
|
||||||
|
import { FormRadioPropertiesPanel } from "@/app/editor/forms/FormRadioPropertiesPanel";
|
||||||
|
import type {
|
||||||
|
Block,
|
||||||
|
FormInputBlockProps,
|
||||||
|
FormSelectBlockProps,
|
||||||
|
FormCheckboxBlockProps,
|
||||||
|
FormRadioBlockProps,
|
||||||
|
} from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
// Form*PropertiesPanel 컨트롤 TDD
|
||||||
|
// - 각 패널의 색상 HEX 인풋과 필드 너비 셀렉트가 updateBlock 을 올바르게 호출하는지 검증한다.
|
||||||
|
|
||||||
|
function makeBlock<T extends Block["props"]>(id: string, type: Block["type"], props: T): Block {
|
||||||
|
return { id, type, props } as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FormInputPropertiesPanel", () => {
|
||||||
|
const baseProps: FormInputBlockProps = {
|
||||||
|
label: "이메일",
|
||||||
|
formFieldName: "email",
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
const block = makeBlock<FormInputBlockProps>("f-input-1", "formInput", {
|
||||||
|
...baseProps,
|
||||||
|
textColorCustom: "#ffffff",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormInputPropertiesPanel
|
||||||
|
block={block}
|
||||||
|
selectedBlockId="f-input-1"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexInput = screen.getByLabelText("필드 텍스트 색상 HEX");
|
||||||
|
fireEvent.change(hexInput, { target: { value: "#112233" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"f-input-1",
|
||||||
|
expect.objectContaining({ textColorCustom: "#112233" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("채움 색상 HEX 인풋 변경 시 updateBlock 이 fillColorCustom 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
const block = makeBlock<FormInputBlockProps>("f-input-2", "formInput", {
|
||||||
|
...baseProps,
|
||||||
|
fillColorCustom: "#000000",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormInputPropertiesPanel
|
||||||
|
block={block}
|
||||||
|
selectedBlockId="f-input-2"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexInput = screen.getByLabelText("필드 채움 색상 HEX");
|
||||||
|
fireEvent.change(hexInput, { target: { value: "#445566" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"f-input-2",
|
||||||
|
expect.objectContaining({ fillColorCustom: "#445566" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("테두리 색상 HEX 인풋 변경 시 updateBlock 이 strokeColorCustom 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
const block = makeBlock<FormInputBlockProps>("f-input-3", "formInput", {
|
||||||
|
...baseProps,
|
||||||
|
strokeColorCustom: "#000000",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormInputPropertiesPanel
|
||||||
|
block={block}
|
||||||
|
selectedBlockId="f-input-3"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexInput = screen.getByLabelText("필드 테두리 색상 HEX");
|
||||||
|
fireEvent.change(hexInput, { target: { value: "#778899" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"f-input-3",
|
||||||
|
expect.objectContaining({ strokeColorCustom: "#778899" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
const block = makeBlock<FormInputBlockProps>("f-input-4", "formInput", {
|
||||||
|
...baseProps,
|
||||||
|
widthMode: "auto",
|
||||||
|
fullWidth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormInputPropertiesPanel
|
||||||
|
block={block}
|
||||||
|
selectedBlockId="f-input-4"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("너비");
|
||||||
|
fireEvent.change(select, { target: { value: "fixed" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"f-input-4",
|
||||||
|
expect.objectContaining({ widthMode: "fixed" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FormSelectPropertiesPanel", () => {
|
||||||
|
const baseProps: FormSelectBlockProps = {
|
||||||
|
label: "카테고리",
|
||||||
|
formFieldName: "category",
|
||||||
|
options: [{ label: "A", value: "a" }],
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
const block = makeBlock<FormSelectBlockProps>("f-select-1", "formSelect", {
|
||||||
|
...baseProps,
|
||||||
|
textColorCustom: "#ffffff",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormSelectPropertiesPanel
|
||||||
|
block={block}
|
||||||
|
selectedBlockId="f-select-1"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexInput = screen.getByLabelText("셀렉트 텍스트 색상 HEX");
|
||||||
|
fireEvent.change(hexInput, { target: { value: "#112233" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"f-select-1",
|
||||||
|
expect.objectContaining({ textColorCustom: "#112233" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("채움 색상 HEX 인풋 변경 시 updateBlock 이 fillColorCustom 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
const block = makeBlock<FormSelectBlockProps>("f-select-2", "formSelect", {
|
||||||
|
...baseProps,
|
||||||
|
fillColorCustom: "#000000",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormSelectPropertiesPanel
|
||||||
|
block={block}
|
||||||
|
selectedBlockId="f-select-2"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexInput = screen.getByLabelText("셀렉트 채움 색상 HEX");
|
||||||
|
fireEvent.change(hexInput, { target: { value: "#445566" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"f-select-2",
|
||||||
|
expect.objectContaining({ fillColorCustom: "#445566" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("테두리 색상 HEX 인풋 변경 시 updateBlock 이 strokeColorCustom 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
const block = makeBlock<FormSelectBlockProps>("f-select-3", "formSelect", {
|
||||||
|
...baseProps,
|
||||||
|
strokeColorCustom: "#000000",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormSelectPropertiesPanel
|
||||||
|
block={block}
|
||||||
|
selectedBlockId="f-select-3"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexInput = screen.getByLabelText("셀렉트 테두리 색상 HEX");
|
||||||
|
fireEvent.change(hexInput, { target: { value: "#778899" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"f-select-3",
|
||||||
|
expect.objectContaining({ strokeColorCustom: "#778899" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
const block = makeBlock<FormSelectBlockProps>("f-select-4", "formSelect", {
|
||||||
|
...baseProps,
|
||||||
|
widthMode: "auto",
|
||||||
|
fullWidth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormSelectPropertiesPanel
|
||||||
|
block={block}
|
||||||
|
selectedBlockId="f-select-4"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("필드 너비");
|
||||||
|
fireEvent.change(select, { target: { value: "fixed" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"f-select-4",
|
||||||
|
expect.objectContaining({ widthMode: "fixed" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FormCheckboxPropertiesPanel", () => {
|
||||||
|
const baseProps: FormCheckboxBlockProps = {
|
||||||
|
groupLabel: "옵션들",
|
||||||
|
formFieldName: "features",
|
||||||
|
options: [{ label: "옵션 1", value: "opt1" }],
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
const block = makeBlock<FormCheckboxBlockProps>("f-check-1", "formCheckbox", {
|
||||||
|
...baseProps,
|
||||||
|
textColorCustom: "#ffffff",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormCheckboxPropertiesPanel
|
||||||
|
block={block}
|
||||||
|
selectedBlockId="f-check-1"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexInput = screen.getByLabelText("체크박스 텍스트 색상 HEX");
|
||||||
|
fireEvent.change(hexInput, { target: { value: "#112233" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"f-check-1",
|
||||||
|
expect.objectContaining({ textColorCustom: "#112233" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
const block = makeBlock<FormCheckboxBlockProps>("f-check-2", "formCheckbox", {
|
||||||
|
...baseProps,
|
||||||
|
widthMode: "auto",
|
||||||
|
fullWidth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormCheckboxPropertiesPanel
|
||||||
|
block={block}
|
||||||
|
selectedBlockId="f-check-2"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("필드 너비");
|
||||||
|
fireEvent.change(select, { target: { value: "fixed" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"f-check-2",
|
||||||
|
expect.objectContaining({ widthMode: "fixed" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FormRadioPropertiesPanel", () => {
|
||||||
|
const baseProps: FormRadioBlockProps = {
|
||||||
|
groupLabel: "플랜",
|
||||||
|
formFieldName: "plan",
|
||||||
|
options: [{ label: "플랜 A", value: "a" }],
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
const block = makeBlock<FormRadioBlockProps>("f-radio-1", "formRadio", {
|
||||||
|
...baseProps,
|
||||||
|
textColorCustom: "#ffffff",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormRadioPropertiesPanel
|
||||||
|
block={block}
|
||||||
|
selectedBlockId="f-radio-1"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexInput = screen.getByLabelText("라디오 텍스트 색상 HEX");
|
||||||
|
fireEvent.change(hexInput, { target: { value: "#112233" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"f-radio-1",
|
||||||
|
expect.objectContaining({ textColorCustom: "#112233" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
const block = makeBlock<FormRadioBlockProps>("f-radio-2", "formRadio", {
|
||||||
|
...baseProps,
|
||||||
|
widthMode: "auto",
|
||||||
|
fullWidth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormRadioPropertiesPanel
|
||||||
|
block={block}
|
||||||
|
selectedBlockId="f-radio-2"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("필드 너비");
|
||||||
|
fireEvent.change(select, { target: { value: "fixed" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"f-radio-2",
|
||||||
|
expect.objectContaining({ widthMode: "fixed" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
|
import { ImagePropertiesPanel } from "@/app/editor/panels/ImagePropertiesPanel";
|
||||||
|
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
// ImagePropertiesPanel 컨트롤 TDD
|
||||||
|
// - 카드 배경색(backgroundColorCustom) HEX 인풋 변경 시 updateBlock 이 올바르게 호출되는지
|
||||||
|
|
||||||
|
describe("ImagePropertiesPanel", () => {
|
||||||
|
const baseProps: ImageBlockProps = {
|
||||||
|
src: "/images/example.png",
|
||||||
|
alt: "예시 이미지",
|
||||||
|
align: "center",
|
||||||
|
widthMode: "auto",
|
||||||
|
borderRadius: "md",
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("카드 배경색 HEX 인풋 변경 시 updateBlock 이 backgroundColorCustom 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ImagePropertiesPanel
|
||||||
|
imageProps={{ ...baseProps, backgroundColorCustom: "#111111" }}
|
||||||
|
selectedBlockId="image-1"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexInput = screen.getByLabelText("이미지 카드 배경색 HEX");
|
||||||
|
fireEvent.change(hexInput, { target: { value: "#112233" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"image-1",
|
||||||
|
expect.objectContaining({ backgroundColorCustom: "#112233" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
import PreviewPage from "@/app/preview/page";
|
||||||
|
import type { ProjectConfig, Block } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
// PreviewPage 캔버스 프리셋/폭/배경색 TDD
|
||||||
|
// - canvasPreset / canvasWidthPx / canvasBgColorHex / bodyBgColorHex 가
|
||||||
|
// preview-canvas-inner 및 main 배경에 올바르게 반영되는지 검증한다.
|
||||||
|
|
||||||
|
let mockState: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const baseProjectConfig: ProjectConfig = {
|
||||||
|
title: "프리뷰 캔버스 테스트",
|
||||||
|
slug: "preview-canvas-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasBgColorHex: "#0f172a",
|
||||||
|
bodyBgColorHex: "#020617",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockState = {
|
||||||
|
blocks: [] as Block[],
|
||||||
|
projectConfig: baseProjectConfig,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PreviewPage - canvasPreset / canvasWidthPx", () => {
|
||||||
|
it("mobile 프리셋 + canvasWidthPx=390 은 preview-canvas-inner maxWidth 를 390px 로 설정해야 한다", () => {
|
||||||
|
mockState.projectConfig = {
|
||||||
|
...(mockState.projectConfig as ProjectConfig),
|
||||||
|
canvasPreset: "mobile",
|
||||||
|
canvasWidthPx: 390,
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
render(<PreviewPage />);
|
||||||
|
|
||||||
|
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
|
||||||
|
expect(inner.style.maxWidth).toBe("390px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tablet 프리셋 + canvasWidthPx=768 은 maxWidth 를 768px 로 설정해야 한다", () => {
|
||||||
|
mockState.projectConfig = {
|
||||||
|
...(mockState.projectConfig as ProjectConfig),
|
||||||
|
canvasPreset: "tablet",
|
||||||
|
canvasWidthPx: 768,
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
render(<PreviewPage />);
|
||||||
|
|
||||||
|
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
|
||||||
|
expect(inner.style.maxWidth).toBe("768px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("desktop 프리셋 + canvasWidthPx=1200 은 maxWidth 를 1200px 로 설정해야 한다", () => {
|
||||||
|
mockState.projectConfig = {
|
||||||
|
...(mockState.projectConfig as ProjectConfig),
|
||||||
|
canvasPreset: "desktop",
|
||||||
|
canvasWidthPx: 1200,
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
render(<PreviewPage />);
|
||||||
|
|
||||||
|
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
|
||||||
|
expect(inner.style.maxWidth).toBe("1200px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("full 프리셋에서 canvasWidthPx 가 없으면 maxWidth 는 비워져 있어야 한다", () => {
|
||||||
|
mockState.projectConfig = {
|
||||||
|
...(mockState.projectConfig as ProjectConfig),
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasWidthPx: undefined,
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
render(<PreviewPage />);
|
||||||
|
|
||||||
|
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
|
||||||
|
expect(inner.style.maxWidth).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("custom 프리셋 + canvasWidthPx=1024 는 maxWidth 를 1024px 로 설정해야 한다", () => {
|
||||||
|
mockState.projectConfig = {
|
||||||
|
...(mockState.projectConfig as ProjectConfig),
|
||||||
|
canvasPreset: "custom",
|
||||||
|
canvasWidthPx: 1024,
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
render(<PreviewPage />);
|
||||||
|
|
||||||
|
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
|
||||||
|
expect(inner.style.maxWidth).toBe("1024px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("canvasBgColorHex 와 bodyBgColorHex 가 각각 preview-canvas-inner 및 main 배경에 적용되어야 한다", () => {
|
||||||
|
mockState.projectConfig = {
|
||||||
|
...(mockState.projectConfig as ProjectConfig),
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasBgColorHex: "#111111",
|
||||||
|
bodyBgColorHex: "#222222",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
const { container } = render(<PreviewPage />);
|
||||||
|
|
||||||
|
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
|
||||||
|
const main = container.querySelector("main") as HTMLElement;
|
||||||
|
|
||||||
|
expect(inner.style.backgroundColor).toBe("rgb(17, 17, 17)");
|
||||||
|
expect(main.style.backgroundColor).toBe("rgb(34, 34, 34)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
|
import { ProjectPropertiesPanel } from "@/app/editor/panels/ProjectPropertiesPanel";
|
||||||
|
import type { ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
let mockState: { projectConfig: ProjectConfig; updateProjectConfig: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const baseConfig: ProjectConfig = {
|
||||||
|
title: "프로젝트",
|
||||||
|
slug: "project",
|
||||||
|
canvasPreset: "full",
|
||||||
|
canvasWidthPx: 1024,
|
||||||
|
canvasBgColorHex: "#0f172a",
|
||||||
|
bodyBgColorHex: "#020617",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
mockState = {
|
||||||
|
projectConfig: baseConfig,
|
||||||
|
updateProjectConfig: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/features/editor/state/editorStore", () => {
|
||||||
|
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useEditorStore,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ProjectPropertiesPanel", () => {
|
||||||
|
it("캔버스 너비 프리셋을 변경하면 canvasPreset/canvasWidthPx 가 업데이트되어야 한다", () => {
|
||||||
|
render(<ProjectPropertiesPanel />);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("캔버스 너비 프리셋") as HTMLSelectElement;
|
||||||
|
|
||||||
|
fireEvent.change(select, { target: { value: "mobile" } });
|
||||||
|
|
||||||
|
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "mobile", canvasWidthPx: 390 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("태블릿 프리셋을 선택하면 canvasPreset=tablet 과 canvasWidthPx=768 로 업데이트되어야 한다", () => {
|
||||||
|
render(<ProjectPropertiesPanel />);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("캔버스 너비 프리셋") as HTMLSelectElement;
|
||||||
|
|
||||||
|
fireEvent.change(select, { target: { value: "tablet" } });
|
||||||
|
|
||||||
|
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "tablet", canvasWidthPx: 768 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("데스크톱 프리셋을 선택하면 canvasPreset=desktop 과 canvasWidthPx=1200 으로 업데이트되어야 한다", () => {
|
||||||
|
render(<ProjectPropertiesPanel />);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("캔버스 너비 프리셋") as HTMLSelectElement;
|
||||||
|
|
||||||
|
fireEvent.change(select, { target: { value: "desktop" } });
|
||||||
|
|
||||||
|
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "desktop", canvasWidthPx: 1200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("캔버스 너비를 390 으로 입력하면 mobile 프리셋으로 동기화되어야 한다", () => {
|
||||||
|
render(<ProjectPropertiesPanel />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("캔버스 너비 커스텀 (px)") as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "390" } });
|
||||||
|
|
||||||
|
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "mobile", canvasWidthPx: 390 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("캔버스 너비를 768 로 입력하면 tablet 프리셋으로 동기화되어야 한다", () => {
|
||||||
|
render(<ProjectPropertiesPanel />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("캔버스 너비 커스텀 (px)") as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "768" } });
|
||||||
|
|
||||||
|
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "tablet", canvasWidthPx: 768 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("캔버스 너비를 1200 으로 입력하면 desktop 프리셋으로 동기화되어야 한다", () => {
|
||||||
|
render(<ProjectPropertiesPanel />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("캔버스 너비 커스텀 (px)") as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "1200" } });
|
||||||
|
|
||||||
|
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "desktop", canvasWidthPx: 1200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("캔버스 너비를 커스텀 값으로 입력하면 canvasPreset=custom 과 함께 canvasWidthPx 가 설정되어야 한다", () => {
|
||||||
|
render(<ProjectPropertiesPanel />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("캔버스 너비 커스텀 (px)") as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "1000" } });
|
||||||
|
|
||||||
|
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "custom", canvasWidthPx: 1000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("캔버스 배경색 HEX 입력을 변경하면 canvasBgColorHex 가 업데이트되어야 한다", () => {
|
||||||
|
render(<ProjectPropertiesPanel />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("캔버스 배경색 HEX") as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "#123456" } });
|
||||||
|
|
||||||
|
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasBgColorHex: "#123456" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("페이지 배경색 HEX 입력을 변경하면 bodyBgColorHex 가 업데이트되어야 한다", () => {
|
||||||
|
render(<ProjectPropertiesPanel />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("페이지 배경색 HEX") as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "#654321" } });
|
||||||
|
|
||||||
|
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ bodyBgColorHex: "#654321" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||||
|
import type { Block } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
// 프리뷰 렌더러가 상위 컨테이너(프리뷰 캔버스)의 배경색을 덮어쓰지 않도록 하는 TDD
|
||||||
|
// - 루트 컨테이너는 레이아웃/텍스트 색상만 담당하고, 고정 배경색(bg-slate-950)은 사용하지 않아야 한다.
|
||||||
|
|
||||||
|
describe("PublicPageRenderer - 배경색 위임", () => {
|
||||||
|
it("루트 컨테이너는 bg-slate-950 같은 고정 배경 클래스 없이 렌더링되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [];
|
||||||
|
|
||||||
|
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const root = container.firstElementChild as HTMLElement | null;
|
||||||
|
expect(root).not.toBeNull();
|
||||||
|
|
||||||
|
const className = root!.getAttribute("class") ?? "";
|
||||||
|
|
||||||
|
expect(className).not.toContain("bg-slate-950");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("루트 텍스트/버튼 블록 영역은 고정 bg-slate-950 배경 대신 캔버스 배경을 그대로 보여야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "text_root",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "텍스트",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: "button_root",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "버튼",
|
||||||
|
href: "#",
|
||||||
|
align: "left",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
// 섹션 블록이 하나도 없으므로, 첫 번째 <section> 은 루트 텍스트/버튼 블록을 감싸는 래퍼다.
|
||||||
|
const firstSection = container.querySelector("section");
|
||||||
|
expect(firstSection).not.toBeNull();
|
||||||
|
|
||||||
|
const className = firstSection!.getAttribute("class") ?? "";
|
||||||
|
|
||||||
|
expect(className).not.toContain("bg-slate-950");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||||
|
import type { Block } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
// 블록 배경색 확장 TDD
|
||||||
|
// - backgroundColorCustom 이 설정된 경우에만 프리뷰에서 배경색이 적용되어야 한다.
|
||||||
|
// - 설정되지 않은 경우에는 기본 배경(투명)으로 남아야 한다.
|
||||||
|
|
||||||
|
describe("PublicPageRenderer - 블록 배경색", () => {
|
||||||
|
it("text 블록은 backgroundColorCustom 이 설정된 경우에만 배경색 스타일을 가져야 한다", () => {
|
||||||
|
const blocksWithBg: Block[] = [
|
||||||
|
{
|
||||||
|
id: "text_with_bg",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "배경 있는 텍스트",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
// TDD: 새 배경색 속성
|
||||||
|
backgroundColorCustom: "#123456",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
|
||||||
|
|
||||||
|
const pWithBg = container.querySelector("p") as HTMLElement | null;
|
||||||
|
expect(pWithBg).not.toBeNull();
|
||||||
|
// JSDOM 은 #123456 을 rgb(18, 52, 86) 형태로 노출한다.
|
||||||
|
expect(pWithBg!.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||||
|
|
||||||
|
const blocksWithoutBg: Block[] = [
|
||||||
|
{
|
||||||
|
id: "text_no_bg",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "배경 없는 텍스트",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
|
||||||
|
|
||||||
|
const pNoBg = container.querySelector("p") as HTMLElement | null;
|
||||||
|
expect(pNoBg).not.toBeNull();
|
||||||
|
// 기본값은 inline style 이 설정되지 않은 상태여야 한다.
|
||||||
|
expect(pNoBg!.style.backgroundColor === "" || pNoBg!.style.backgroundColor === "transparent").toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("list 블록은 backgroundColorCustom 이 설정된 경우에만 리스트 컨테이너에 배경색이 적용되어야 한다", () => {
|
||||||
|
const blocksWithBg: Block[] = [
|
||||||
|
{
|
||||||
|
id: "list_with_bg",
|
||||||
|
type: "list",
|
||||||
|
props: {
|
||||||
|
items: ["아이템 1"],
|
||||||
|
ordered: false,
|
||||||
|
// TDD: 새 배경색 속성
|
||||||
|
backgroundColorCustom: "#00ff88",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
|
||||||
|
|
||||||
|
const listWithBg = (container.querySelector("ul,ol") as HTMLElement | null);
|
||||||
|
expect(listWithBg).not.toBeNull();
|
||||||
|
expect(listWithBg!.style.backgroundColor).toBe("rgb(0, 255, 136)");
|
||||||
|
|
||||||
|
const blocksWithoutBg: Block[] = [
|
||||||
|
{
|
||||||
|
id: "list_no_bg",
|
||||||
|
type: "list",
|
||||||
|
props: {
|
||||||
|
items: ["아이템 1"],
|
||||||
|
ordered: false,
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
|
||||||
|
|
||||||
|
const listNoBg = (container.querySelector("ul,ol") as HTMLElement | null);
|
||||||
|
expect(listNoBg).not.toBeNull();
|
||||||
|
expect(listNoBg!.style.backgroundColor === "" || listNoBg!.style.backgroundColor === "transparent").toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("form 컨트롤러 블록은 backgroundColorCustom 이 설정된 경우에만 폼 래퍼에 배경색이 적용되어야 한다", () => {
|
||||||
|
const blocksWithBg: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_with_bg",
|
||||||
|
type: "form",
|
||||||
|
props: {
|
||||||
|
kind: "contact",
|
||||||
|
submitTarget: "internal",
|
||||||
|
fields: [],
|
||||||
|
fieldIds: [],
|
||||||
|
formWidthMode: "auto",
|
||||||
|
// TDD: 새 배경색 속성
|
||||||
|
backgroundColorCustom: "#111111",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
|
||||||
|
|
||||||
|
const formWithBg = getByTestId("preview-form-controller") as HTMLFormElement;
|
||||||
|
expect(formWithBg.style.backgroundColor).toBe("rgb(17, 17, 17)");
|
||||||
|
|
||||||
|
const blocksWithoutBg: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_no_bg",
|
||||||
|
type: "form",
|
||||||
|
props: {
|
||||||
|
kind: "contact",
|
||||||
|
submitTarget: "internal",
|
||||||
|
fields: [],
|
||||||
|
fieldIds: [],
|
||||||
|
formWidthMode: "auto",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
|
||||||
|
|
||||||
|
const formNoBg = getByTestId("preview-form-controller") as HTMLFormElement;
|
||||||
|
expect(formNoBg.style.backgroundColor === "" || formNoBg.style.backgroundColor === "transparent").toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("section 블록은 backgroundColorCustom 이 설정된 경우에만 섹션 요소에 배경색이 적용되어야 한다", () => {
|
||||||
|
const blocksWithBg: Block[] = [
|
||||||
|
{
|
||||||
|
id: "sec_1",
|
||||||
|
type: "section",
|
||||||
|
props: {
|
||||||
|
background: "default",
|
||||||
|
paddingY: "md",
|
||||||
|
columns: [{ id: "sec_1_col_1", span: 12 }],
|
||||||
|
backgroundColorCustom: "#123456",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: "sec_1_text",
|
||||||
|
type: "text",
|
||||||
|
sectionId: "sec_1",
|
||||||
|
columnId: "sec_1_col_1",
|
||||||
|
props: {
|
||||||
|
text: "섹션 안 텍스트",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
|
||||||
|
|
||||||
|
const sectionWithBg = getByTestId("preview-section") as HTMLElement;
|
||||||
|
// JSDOM 은 #123456 을 rgb(18, 52, 86) 형태로 노출한다.
|
||||||
|
expect(sectionWithBg.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||||
|
|
||||||
|
const blocksWithoutBg: Block[] = [
|
||||||
|
{
|
||||||
|
id: "sec_1",
|
||||||
|
type: "section",
|
||||||
|
props: {
|
||||||
|
background: "default",
|
||||||
|
paddingY: "md",
|
||||||
|
columns: [{ id: "sec_1_col_1", span: 12 }],
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: "sec_1_text",
|
||||||
|
type: "text",
|
||||||
|
sectionId: "sec_1",
|
||||||
|
columnId: "sec_1_col_1",
|
||||||
|
props: {
|
||||||
|
text: "섹션 안 텍스트",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
|
||||||
|
|
||||||
|
const sectionNoBg = getByTestId("preview-section") as HTMLElement;
|
||||||
|
expect(sectionNoBg.style.backgroundColor === "" || sectionNoBg.style.backgroundColor === "transparent").toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { render, cleanup } from "@testing-library/react";
|
||||||
|
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||||
|
import type { Block } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
// PublicPageRenderer 버튼 스타일 TDD
|
||||||
|
// - fillColorCustom / strokeColorCustom / textColorCustom
|
||||||
|
// - widthMode=fixed + widthPx, paddingX/paddingY
|
||||||
|
// - borderRadius 토큰에 따른 둥글기
|
||||||
|
|
||||||
|
describe("PublicPageRenderer - 버튼 블록 스타일", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("버튼 색상 커스텀 값은 배경/외곽선/텍스트 색상 스타일에 반영되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "btn_colors",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "컬러 버튼",
|
||||||
|
href: "#",
|
||||||
|
align: "center",
|
||||||
|
fillColorCustom: "#123456",
|
||||||
|
strokeColorCustom: "#ff0000",
|
||||||
|
textColorCustom: "#00ff00",
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const link = container.querySelector("a") as HTMLAnchorElement | null;
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
// 배경색: #123456 → rgb(18, 52, 86)
|
||||||
|
expect(link!.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||||
|
// 외곽선 색상: #ff0000 → rgb(255, 0, 0)
|
||||||
|
expect(link!.style.borderColor).toBe("rgb(255, 0, 0)");
|
||||||
|
// strokeColorCustom 이 있을 때 border-width/style 도 설정되어야 한다.
|
||||||
|
expect(link!.style.borderWidth).toBe("1px");
|
||||||
|
expect(link!.style.borderStyle).toBe("solid");
|
||||||
|
// 텍스트 색상: #00ff00 → rgb(0, 255, 0)
|
||||||
|
expect(link!.style.color).toBe("rgb(0, 255, 0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("widthMode=fixed 인 버튼은 widthPx/paddingX/paddingY 가 em 단위 스타일로 반영되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "btn_layout",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "레이아웃 버튼",
|
||||||
|
href: "#",
|
||||||
|
align: "left",
|
||||||
|
widthMode: "fixed",
|
||||||
|
widthPx: 320,
|
||||||
|
paddingX: 16,
|
||||||
|
paddingY: 8,
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const link = container.querySelector("a") as HTMLAnchorElement | null;
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
// width: 320px / 16 = 20em
|
||||||
|
expect(link!.style.width).toBe("20em");
|
||||||
|
// padding-inline: 16px / 16 = 1em
|
||||||
|
expect(link!.style.paddingInline).toBe("1em");
|
||||||
|
// padding-block: 8px / 16 = 0.5em
|
||||||
|
expect(link!.style.paddingBlock).toBe("0.5em");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("borderRadius 토큰은 em 단위 둥글기로 반영되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "btn_radius",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "둥근 버튼",
|
||||||
|
href: "#",
|
||||||
|
borderRadius: "lg",
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const link = container.querySelector("a") as HTMLAnchorElement | null;
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
// lg → 12px → 12 / 16 = 0.75em
|
||||||
|
expect(link!.style.borderRadius).toBe("0.75em");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { render, cleanup } from "@testing-library/react";
|
||||||
|
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||||
|
import type { Block } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
// divider 스타일 TDD
|
||||||
|
// - colorHex 에 따른 라인 색상
|
||||||
|
// - marginY/marginYPx 에 따른 상하 여백 em 스케일
|
||||||
|
// - widthMode/widthPx 에 따른 길이 모드
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PublicPageRenderer - divider 스타일", () => {
|
||||||
|
it("colorHex 가 지정되면 구분선 라인에 해당 색상이 적용되어야 한다", () => {
|
||||||
|
const blocksWithColor: Block[] = [
|
||||||
|
{
|
||||||
|
id: "div_color",
|
||||||
|
type: "divider",
|
||||||
|
props: {
|
||||||
|
align: "center",
|
||||||
|
thickness: "thin",
|
||||||
|
colorHex: "#123456",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithColor} />);
|
||||||
|
|
||||||
|
const lineWithColor = getByTestId("preview-divider-line") as HTMLElement;
|
||||||
|
// JSDOM 은 #123456 을 rgb(18, 52, 86) 으로 노출한다.
|
||||||
|
expect(lineWithColor.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||||
|
|
||||||
|
const blocksWithoutColor: Block[] = [
|
||||||
|
{
|
||||||
|
id: "div_default",
|
||||||
|
type: "divider",
|
||||||
|
props: {
|
||||||
|
align: "center",
|
||||||
|
thickness: "thin",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
rerender(<PublicPageRenderer blocks={blocksWithoutColor} />);
|
||||||
|
|
||||||
|
const lineDefault = getByTestId("preview-divider-line") as HTMLElement;
|
||||||
|
// 기본 색상은 #475569 이며, JSDOM 에서는 rgb(71, 85, 105) 로 표시된다.
|
||||||
|
expect(lineDefault.style.backgroundColor).toBe("rgb(71, 85, 105)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marginYPx 이 있으면 divider 컨테이너 상하 여백이 em 스케일로 적용되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "div_margin_px",
|
||||||
|
type: "divider",
|
||||||
|
props: {
|
||||||
|
align: "center",
|
||||||
|
thickness: "thin",
|
||||||
|
marginYPx: 32,
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const wrapper = getByTestId("preview-divider") as HTMLDivElement;
|
||||||
|
// 32px / 16 = 2em
|
||||||
|
expect(wrapper.style.marginTop).toBe("2em");
|
||||||
|
expect(wrapper.style.marginBottom).toBe("2em");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marginY 토큰만 있을 때도 적절한 px 값을 em 으로 변환해 상하 여백에 적용해야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "div_margin_token",
|
||||||
|
type: "divider",
|
||||||
|
props: {
|
||||||
|
align: "center",
|
||||||
|
thickness: "thin",
|
||||||
|
marginY: "lg", // 24px 로 매핑
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const wrapper = getByTestId("preview-divider") as HTMLDivElement;
|
||||||
|
// 24px / 16 = 1.5em
|
||||||
|
expect(wrapper.style.marginTop).toBe("1.5em");
|
||||||
|
expect(wrapper.style.marginBottom).toBe("1.5em");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("widthMode 가 fixed 이고 widthPx 가 있으면 divider 라인의 width 가 pxToEm 으로 변환되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "div_fixed_width",
|
||||||
|
type: "divider",
|
||||||
|
props: {
|
||||||
|
align: "center",
|
||||||
|
thickness: "thin",
|
||||||
|
widthMode: "fixed",
|
||||||
|
widthPx: 480,
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const line = getByTestId("preview-divider-line") as HTMLDivElement;
|
||||||
|
// 480px / 16 = 30em
|
||||||
|
expect(line.style.width).toBe("30em");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { render, cleanup } from "@testing-library/react";
|
||||||
|
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||||
|
import type { Block } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
// 폼 필드 스타일 TDD
|
||||||
|
// - formInput: 색상/패딩/너비/둥글기 스타일
|
||||||
|
// - formSelect: 색상/패딩/너비/둥글기 스타일
|
||||||
|
// - formCheckbox/formRadio: 옵션 컨테이너 색상/패딩/둥글기 스타일
|
||||||
|
|
||||||
|
function getFirstLabel(root: HTMLElement): HTMLLabelElement {
|
||||||
|
const label = root.querySelector("label");
|
||||||
|
if (!label) {
|
||||||
|
throw new Error("label element not found");
|
||||||
|
}
|
||||||
|
return label as HTMLLabelElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PublicPageRenderer - 폼 필드 스타일", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formInput 블록은 색상/패딩/너비/둥글기 스타일을 반영해야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_input_styles",
|
||||||
|
type: "formInput",
|
||||||
|
props: {
|
||||||
|
label: "이메일",
|
||||||
|
formFieldName: "email",
|
||||||
|
align: "center",
|
||||||
|
widthMode: "fixed",
|
||||||
|
widthPx: 320,
|
||||||
|
paddingX: 16,
|
||||||
|
paddingY: 8,
|
||||||
|
textColorCustom: "#112233",
|
||||||
|
fillColorCustom: "#123456",
|
||||||
|
strokeColorCustom: "#ff0000",
|
||||||
|
borderRadius: "lg",
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const wrapper = getByTestId("preview-form-input-wrapper") as HTMLElement;
|
||||||
|
const input = wrapper.querySelector('[data-testid="preview-form-input"]') as HTMLElement | null;
|
||||||
|
expect(input).not.toBeNull();
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
expect((input as HTMLInputElement).style.textAlign).toBe("center");
|
||||||
|
// width: 320px / 16 = 20em
|
||||||
|
expect((input as HTMLInputElement).style.width).toBe("20em");
|
||||||
|
// padding-inline/block: 16px/8px -> 1em / 0.5em
|
||||||
|
expect((input as HTMLInputElement).style.paddingInline).toBe("1em");
|
||||||
|
expect((input as HTMLInputElement).style.paddingBlock).toBe("0.5em");
|
||||||
|
// 색상: hex -> rgb 변환
|
||||||
|
expect((input as HTMLInputElement).style.color).toBe("rgb(17, 34, 51)");
|
||||||
|
expect((input as HTMLInputElement).style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||||
|
expect((input as HTMLInputElement).style.borderColor).toBe("rgb(255, 0, 0)");
|
||||||
|
// borderRadius: lg -> 6px
|
||||||
|
expect((input as HTMLInputElement).style.borderRadius).toBe("6px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formSelect 블록은 색상/패딩/너비/둥글기 스타일을 반영해야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_select_styles",
|
||||||
|
type: "formSelect",
|
||||||
|
props: {
|
||||||
|
label: "카테고리",
|
||||||
|
formFieldName: "category",
|
||||||
|
options: [
|
||||||
|
{ label: "A", value: "a" },
|
||||||
|
{ label: "B", value: "b" },
|
||||||
|
],
|
||||||
|
widthMode: "fixed",
|
||||||
|
widthPx: 240,
|
||||||
|
paddingX: 16,
|
||||||
|
paddingY: 8,
|
||||||
|
textColorCustom: "#00ff00",
|
||||||
|
fillColorCustom: "#123456",
|
||||||
|
strokeColorCustom: "#ff0000",
|
||||||
|
borderRadius: "lg",
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const wrapper = getByTestId("preview-form-select") as HTMLElement;
|
||||||
|
const select = wrapper.querySelector("select") as HTMLSelectElement | null;
|
||||||
|
expect(select).not.toBeNull();
|
||||||
|
|
||||||
|
// width: 240px / 16 = 15em
|
||||||
|
expect(wrapper.style.width).toBe("15em");
|
||||||
|
// padding-inline/block: 16px/8px -> 1em / 0.5em
|
||||||
|
expect(select!.style.paddingInline).toBe("1em");
|
||||||
|
expect(select!.style.paddingBlock).toBe("0.5em");
|
||||||
|
// 색상
|
||||||
|
expect(select!.style.color).toBe("rgb(0, 255, 0)");
|
||||||
|
expect(select!.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||||
|
expect(select!.style.borderColor).toBe("rgb(255, 0, 0)");
|
||||||
|
// 둥글기
|
||||||
|
expect(select!.style.borderRadius).toBe("6px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formCheckbox 블록은 옵션 컨테이너에 색상/패딩/둥글기 스타일을 반영해야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_checkbox_styles",
|
||||||
|
type: "formCheckbox",
|
||||||
|
props: {
|
||||||
|
groupLabel: "옵션들",
|
||||||
|
formFieldName: "features",
|
||||||
|
options: [{ label: "옵션 1", value: "opt1" }],
|
||||||
|
widthMode: "fixed",
|
||||||
|
widthPx: 320,
|
||||||
|
paddingX: 8,
|
||||||
|
paddingY: 4,
|
||||||
|
optionGapPx: 16,
|
||||||
|
textColorCustom: "#ffffff",
|
||||||
|
fillColorCustom: "#123456",
|
||||||
|
strokeColorCustom: "#ff0000",
|
||||||
|
borderRadius: "lg",
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
|
||||||
|
const firstLabel = getFirstLabel(group);
|
||||||
|
|
||||||
|
// width: 320px / 16 = 20em
|
||||||
|
expect(group.style.width).toBe("20em");
|
||||||
|
// 옵션 간 간격: 16px -> 1em
|
||||||
|
const optionsContainer = group.querySelector('[data-testid="preview-form-checkbox-options"]') as HTMLElement | null;
|
||||||
|
expect(optionsContainer).not.toBeNull();
|
||||||
|
expect(optionsContainer!.style.rowGap).toBe("1em");
|
||||||
|
|
||||||
|
// 옵션 컨테이너 스타일: padding, 배경, 보더, 둥글기
|
||||||
|
expect(firstLabel.style.paddingInline).toBe("0.5em");
|
||||||
|
expect(firstLabel.style.paddingBlock).toBe("0.25em");
|
||||||
|
expect(firstLabel.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||||
|
expect(firstLabel.style.borderColor).toBe("rgb(255, 0, 0)");
|
||||||
|
expect(firstLabel.style.borderRadius).toBe("6px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formRadio 블록은 옵션 컨테이너에 색상/패딩/둥글기 스타일을 반영해야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_radio_styles",
|
||||||
|
type: "formRadio",
|
||||||
|
props: {
|
||||||
|
groupLabel: "플랜",
|
||||||
|
formFieldName: "plan",
|
||||||
|
options: [{ label: "플랜 A", value: "a" }],
|
||||||
|
widthMode: "fixed",
|
||||||
|
widthPx: 320,
|
||||||
|
paddingX: 8,
|
||||||
|
paddingY: 4,
|
||||||
|
optionGapPx: 16,
|
||||||
|
textColorCustom: "#ffffff",
|
||||||
|
fillColorCustom: "#123456",
|
||||||
|
strokeColorCustom: "#ff0000",
|
||||||
|
borderRadius: "lg",
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const group = getByTestId("preview-form-radio-group") as HTMLElement;
|
||||||
|
const firstLabel = getFirstLabel(group);
|
||||||
|
|
||||||
|
// width: 320px / 16 = 20em
|
||||||
|
expect(group.style.width).toBe("20em");
|
||||||
|
// 옵션 간 간격: 16px -> 1em
|
||||||
|
const optionsContainer = group.querySelector('[data-testid="preview-form-radio-options"]') as HTMLElement | null;
|
||||||
|
expect(optionsContainer).not.toBeNull();
|
||||||
|
expect(optionsContainer!.style.rowGap).toBe("1em");
|
||||||
|
|
||||||
|
// 옵션 컨테이너 스타일: padding, 배경, 보더, 둥글기
|
||||||
|
expect(firstLabel.style.paddingInline).toBe("0.5em");
|
||||||
|
expect(firstLabel.style.paddingBlock).toBe("0.25em");
|
||||||
|
expect(firstLabel.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||||
|
expect(firstLabel.style.borderColor).toBe("rgb(255, 0, 0)");
|
||||||
|
expect(firstLabel.style.borderRadius).toBe("6px");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||||
|
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||||
|
import type { Block } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
// PublicPageRenderer 폼 제출 UX TDD
|
||||||
|
// - 성공 응답 시 FormBlockProps 의 successMessage 를 success 스타일로 표시해야 한다.
|
||||||
|
// - 실패 응답 시 FormBlockProps 의 errorMessage 를 error 스타일로 표시해야 한다.
|
||||||
|
|
||||||
|
describe("PublicPageRenderer - 폼 제출 메시지", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
// fetch 목 초기화
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(global as any).fetch = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("성공 응답 시 config.successMessage 를 success 스타일로 표시해야 한다", async () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_success",
|
||||||
|
type: "form",
|
||||||
|
props: {
|
||||||
|
kind: "contact",
|
||||||
|
submitTarget: "internal",
|
||||||
|
successMessage: "폼 성공 메시지 (config)",
|
||||||
|
errorMessage: "폼 에러 메시지 (config)",
|
||||||
|
fieldIds: [],
|
||||||
|
submitButtonId: null,
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async () =>
|
||||||
|
new Response("ok", {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "text/plain" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(global as any).fetch = fetchMock;
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("에러 응답 시 config.errorMessage 를 error 스타일로 표시해야 한다", async () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_error",
|
||||||
|
type: "form",
|
||||||
|
props: {
|
||||||
|
kind: "contact",
|
||||||
|
submitTarget: "internal",
|
||||||
|
successMessage: "폼 성공 메시지 (config)",
|
||||||
|
errorMessage: "폼 에러 메시지 (config)",
|
||||||
|
fieldIds: [],
|
||||||
|
submitButtonId: null,
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async () =>
|
||||||
|
new Response("error", {
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "text/plain" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(global as any).fetch = fetchMock;
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||||
|
import type { Block } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
// 폼 관련 블록(formSelect/formCheckbox/formRadio)의 textColorCustom 이
|
||||||
|
// 프리뷰 렌더러에서 실제 텍스트 color 스타일에 반영되는지 검증한다.
|
||||||
|
|
||||||
|
describe("PublicPageRenderer - 폼 텍스트 색상", () => {
|
||||||
|
it("formSelect 블록의 textColorCustom 은 셀렉트 텍스트 색상에 반영되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_select_color",
|
||||||
|
type: "formSelect",
|
||||||
|
props: {
|
||||||
|
label: "셀렉트 라벨",
|
||||||
|
formFieldName: "category",
|
||||||
|
options: [
|
||||||
|
{ label: "옵션 A", value: "a" },
|
||||||
|
{ label: "옵션 B", value: "b" },
|
||||||
|
],
|
||||||
|
textColorCustom: "#ff0000",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const wrapper = getByTestId("preview-form-select") as HTMLElement;
|
||||||
|
const select = wrapper.querySelector("select") as HTMLSelectElement | null;
|
||||||
|
|
||||||
|
expect(select).not.toBeNull();
|
||||||
|
expect(select!.style.color).toBe("rgb(255, 0, 0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formCheckbox 블록의 textColorCustom 은 옵션 텍스트 색상에 반영되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_checkbox_color",
|
||||||
|
type: "formCheckbox",
|
||||||
|
props: {
|
||||||
|
groupLabel: "체크박스 그룹",
|
||||||
|
formFieldName: "features",
|
||||||
|
options: [{ label: "옵션 1", value: "opt1" }],
|
||||||
|
textColorCustom: "#00ff00",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const optionSpan = getByTestId("preview-form-checkbox-option") as HTMLElement;
|
||||||
|
expect(optionSpan.style.color).toBe("rgb(0, 255, 0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formRadio 블록의 textColorCustom 은 옵션 텍스트 색상에 반영되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "form_radio_color",
|
||||||
|
type: "formRadio",
|
||||||
|
props: {
|
||||||
|
groupLabel: "라디오 그룹",
|
||||||
|
formFieldName: "plan",
|
||||||
|
options: [{ label: "플랜 A", value: "a" }],
|
||||||
|
textColorCustom: "#0000ff",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const optionSpan = getByTestId("preview-form-radio-option") as HTMLElement;
|
||||||
|
expect(optionSpan.style.color).toBe("rgb(0, 0, 255)");
|
||||||
|
});
|
||||||
|
})
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { render, cleanup } from "@testing-library/react";
|
||||||
|
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||||
|
import type { Block } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
// PublicPageRenderer 이미지 블록 스타일 TDD
|
||||||
|
// - backgroundColorCustom 카드 배경 적용 여부
|
||||||
|
// - widthMode=fixed, widthPx 의 em 변환
|
||||||
|
// - borderRadiusPx 의 em 변환
|
||||||
|
|
||||||
|
describe("PublicPageRenderer - 이미지 블록 스타일", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("image 블록은 backgroundColorCustom 이 설정된 경우에만 wrapper 에 배경색이 적용되어야 한다", () => {
|
||||||
|
const blocksWithBg: Block[] = [
|
||||||
|
{
|
||||||
|
id: "img_with_bg",
|
||||||
|
type: "image",
|
||||||
|
props: {
|
||||||
|
src: "/images/example.png",
|
||||||
|
alt: "배경 있는 이미지",
|
||||||
|
align: "center",
|
||||||
|
widthMode: "auto",
|
||||||
|
backgroundColorCustom: "#123456",
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
|
||||||
|
|
||||||
|
const imgWithBg = container.querySelector("img") as HTMLImageElement | null;
|
||||||
|
expect(imgWithBg).not.toBeNull();
|
||||||
|
const wrapperWithBg = imgWithBg!.parentElement as HTMLElement | null;
|
||||||
|
expect(wrapperWithBg).not.toBeNull();
|
||||||
|
// JSDOM 은 #123456 을 rgb(18, 52, 86) 형태로 노출한다.
|
||||||
|
expect(wrapperWithBg!.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||||
|
|
||||||
|
const blocksWithoutBg: Block[] = [
|
||||||
|
{
|
||||||
|
id: "img_no_bg",
|
||||||
|
type: "image",
|
||||||
|
props: {
|
||||||
|
src: "/images/example2.png",
|
||||||
|
alt: "배경 없는 이미지",
|
||||||
|
align: "center",
|
||||||
|
widthMode: "auto",
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
|
||||||
|
|
||||||
|
const imgNoBg = container.querySelector("img") as HTMLImageElement | null;
|
||||||
|
expect(imgNoBg).not.toBeNull();
|
||||||
|
const wrapperNoBg = imgNoBg!.parentElement as HTMLElement | null;
|
||||||
|
expect(wrapperNoBg).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
wrapperNoBg!.style.backgroundColor === "" || wrapperNoBg!.style.backgroundColor === "transparent",
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("widthMode 가 fixed 이고 widthPx 가 설정된 경우 img width 는 em 단위로 설정되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "img_fixed_width",
|
||||||
|
type: "image",
|
||||||
|
props: {
|
||||||
|
src: "/images/example.png",
|
||||||
|
alt: "고정 너비 이미지",
|
||||||
|
align: "center",
|
||||||
|
widthMode: "fixed",
|
||||||
|
widthPx: 480,
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const img = container.querySelector("img") as HTMLImageElement | null;
|
||||||
|
expect(img).not.toBeNull();
|
||||||
|
// 480px / 16 = 30em
|
||||||
|
expect(img!.style.width).toBe("30em");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("borderRadiusPx 가 설정된 경우 img borderRadius 는 em 단위로 설정되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "img_radius",
|
||||||
|
type: "image",
|
||||||
|
props: {
|
||||||
|
src: "/images/example.png",
|
||||||
|
alt: "둥근 이미지",
|
||||||
|
borderRadiusPx: 32,
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const img = container.querySelector("img") as HTMLImageElement | null;
|
||||||
|
expect(img).not.toBeNull();
|
||||||
|
// 32px / 16 = 2em
|
||||||
|
expect(img!.style.borderRadius).toBe("2em");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, afterEach } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||||
|
import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
|
||||||
|
import { createFeaturesTemplateBlocks } from "@/app/editor/templates/featuresTemplate";
|
||||||
|
import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate";
|
||||||
|
import type { Block } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
// PublicPageRenderer 템플릿 TDD
|
||||||
|
// - Hero / Features / CTA 템플릿이 섹션 + 텍스트/버튼 구조로 올바르게 렌더되는지 검증한다.
|
||||||
|
|
||||||
|
function createIdFactory() {
|
||||||
|
let i = 0;
|
||||||
|
return () => `tpl_${++i}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PublicPageRenderer - 섹션 템플릿", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Hero 템플릿 섹션은 섹션과 헤드라인/서브텍스트/버튼을 렌더해야 한다", () => {
|
||||||
|
const sectionId = "hero_section_1";
|
||||||
|
const { blocks } = createHeroTemplateBlocks({ sectionId, createId: createIdFactory() });
|
||||||
|
|
||||||
|
render(<PublicPageRenderer blocks={blocks as Block[]} />);
|
||||||
|
|
||||||
|
// 헤드라인/서브텍스트 텍스트가 존재해야 한다.
|
||||||
|
screen.getByText("Hero 제목을 여기에 입력하세요");
|
||||||
|
screen.getByText("제품이나 서비스를 한 문장으로 설명하는 서브텍스트입니다.");
|
||||||
|
// CTA 버튼 라벨 텍스트가 존재해야 한다.
|
||||||
|
screen.getByText("지금 시작하기");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Features 템플릿 섹션은 3컬럼 Feature 제목/설명 텍스트를 렌더해야 한다", () => {
|
||||||
|
const sectionId = "features_section_1";
|
||||||
|
const { blocks } = createFeaturesTemplateBlocks({ sectionId, createId: createIdFactory() });
|
||||||
|
|
||||||
|
render(<PublicPageRenderer blocks={blocks as Block[]} />);
|
||||||
|
|
||||||
|
// 각 컬럼의 Feature 제목과 설명 텍스트가 모두 존재해야 한다.
|
||||||
|
screen.getByText("Feature 1 제목");
|
||||||
|
screen.getByText("Feature 2 제목");
|
||||||
|
screen.getByText("Feature 3 제목");
|
||||||
|
|
||||||
|
const descText = "해당 기능을 간단히 설명하는 텍스트입니다.";
|
||||||
|
// 설명 텍스트는 3번 등장해야 한다.
|
||||||
|
const allDesc = screen.getAllByText(descText);
|
||||||
|
if (allDesc.length !== 3) {
|
||||||
|
throw new Error(`기대하는 설명 텍스트 개수(3)가 아니고 ${allDesc.length}개가 렌더되었습니다.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CTA 템플릿 섹션은 텍스트와 CTA 버튼을 렌더해야 한다", () => {
|
||||||
|
const sectionId = "cta_section_1";
|
||||||
|
const { blocks } = createCtaTemplateBlocks({ sectionId, createId: createIdFactory() });
|
||||||
|
|
||||||
|
render(<PublicPageRenderer blocks={blocks as Block[]} />);
|
||||||
|
|
||||||
|
// CTA 본문 텍스트와 버튼 라벨이 존재해야 한다.
|
||||||
|
screen.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.");
|
||||||
|
screen.getByText("CTA 버튼");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,15 +44,22 @@ describe("SectionPropertiesPanel - layout presets", () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<SectionPropertiesPanel
|
<SectionPropertiesPanel
|
||||||
sectionProps={baseProps}
|
sectionProps={{
|
||||||
|
...baseProps,
|
||||||
|
columns: [
|
||||||
|
{ id: "col-1", span: 6 },
|
||||||
|
{ id: "col-2", span: 6 },
|
||||||
|
],
|
||||||
|
}}
|
||||||
selectedBlockId="section-layout-2"
|
selectedBlockId="section-layout-2"
|
||||||
updateBlock={updateBlock}
|
updateBlock={updateBlock}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const input = screen.getByLabelText("1열 폭 (1~12)");
|
const inputs = screen.getAllByLabelText("1열 폭 (1~11)");
|
||||||
|
const numberInput = inputs.find((el) => (el as HTMLInputElement).tagName === "INPUT" && (el as HTMLInputElement).type === "number") as HTMLInputElement;
|
||||||
|
|
||||||
fireEvent.change(input, { target: { value: "8" } });
|
fireEvent.change(numberInput, { target: { value: "8" } });
|
||||||
|
|
||||||
expect(updateBlock).toHaveBeenCalledWith(
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
"section-layout-2",
|
"section-layout-2",
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { TextPropertiesPanel } from "@/app/editor/panels/TextPropertiesPanel";
|
||||||
|
import { ListPropertiesPanel } from "@/app/editor/panels/ListPropertiesPanel";
|
||||||
|
import { FormControllerPanel } from "@/app/editor/forms/FormControllerPanel";
|
||||||
|
import type { Block } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
|
// Text/List/Form 패널에 추가되는 블록 배경색(backgroundColorCustom) 컨트롤에 대한 최소 TDD
|
||||||
|
|
||||||
|
describe("Text/List/Form 패널 - 블록 배경색 컨트롤", () => {
|
||||||
|
it("TextPropertiesPanel 에서 배경색 HEX 인풋 변경 시 updateBlock 이 backgroundColorCustom 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TextPropertiesPanel
|
||||||
|
textProps={{
|
||||||
|
text: "텍스트",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
} as any}
|
||||||
|
selectedBlockId="text-1"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
editingBlockId={null}
|
||||||
|
setEditingText={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexInput = screen.getByLabelText("텍스트 블록 배경색 HEX");
|
||||||
|
fireEvent.change(hexInput, { target: { value: "#112233" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"text-1",
|
||||||
|
expect.objectContaining({ backgroundColorCustom: "#112233" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ListPropertiesPanel 에서 배경색 HEX 인풋 변경 시 updateBlock 이 backgroundColorCustom 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ListPropertiesPanel
|
||||||
|
listProps={{
|
||||||
|
items: ["아이템 1"],
|
||||||
|
ordered: false,
|
||||||
|
align: "left",
|
||||||
|
} as any}
|
||||||
|
selectedBlockId="list-1"
|
||||||
|
selectedListItemId={null}
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexInput = screen.getByLabelText("리스트 배경색 HEX");
|
||||||
|
fireEvent.change(hexInput, { target: { value: "#445566" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"list-1",
|
||||||
|
expect.objectContaining({ backgroundColorCustom: "#445566" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FormControllerPanel 에서 배경색 HEX 인풋 변경 시 updateBlock 이 backgroundColorCustom 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
const formBlock: Block = {
|
||||||
|
id: "form-1",
|
||||||
|
type: "form",
|
||||||
|
props: {
|
||||||
|
kind: "contact",
|
||||||
|
submitTarget: "internal",
|
||||||
|
} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormControllerPanel
|
||||||
|
block={formBlock}
|
||||||
|
blocks={[]}
|
||||||
|
selectedBlockId="form-1"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hexInput = screen.getByLabelText("폼 배경색 HEX");
|
||||||
|
fireEvent.change(hexInput, { target: { value: "#778899" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"form-1",
|
||||||
|
expect.objectContaining({ backgroundColorCustom: "#778899" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -708,6 +708,161 @@ describe("editorStore", () => {
|
|||||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("선택된 섹션이 있을 때 Team 템플릿 섹션 액션을 호출하면 해당 섹션이 새 Team 템플릿으로 교체되어야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
store.getState().addTeamTemplateSection();
|
||||||
|
|
||||||
|
let { blocks, selectedBlockId } = store.getState();
|
||||||
|
|
||||||
|
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||||
|
expect(sectionBlocks).toHaveLength(1);
|
||||||
|
|
||||||
|
const section = sectionBlocks[0] as any;
|
||||||
|
const sectionId = section.id;
|
||||||
|
|
||||||
|
const oldTextIds = blocks.filter((b) => b.type === "text").map((b) => b.id);
|
||||||
|
|
||||||
|
const originalLastSelectedId = selectedBlockId;
|
||||||
|
|
||||||
|
store.getState().selectBlock(sectionId);
|
||||||
|
store.getState().addTeamTemplateSection();
|
||||||
|
|
||||||
|
({ blocks, selectedBlockId } = store.getState());
|
||||||
|
|
||||||
|
const sectionBlocksAfter = blocks.filter((b) => b.type === "section");
|
||||||
|
expect(sectionBlocksAfter).toHaveLength(1);
|
||||||
|
|
||||||
|
const replacedSection = sectionBlocksAfter[0] as any;
|
||||||
|
expect(replacedSection.id).toBe(sectionId);
|
||||||
|
|
||||||
|
const textBlocksAfter = blocks.filter((b) => b.type === "text") as any[];
|
||||||
|
expect(textBlocksAfter.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
textBlocksAfter.forEach((tb) => {
|
||||||
|
expect(tb.sectionId).toBe(sectionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const allIdsAfter = blocks.map((b) => b.id);
|
||||||
|
oldTextIds.forEach((id) => {
|
||||||
|
expect(allIdsAfter).not.toContain(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
||||||
|
expect(selectedBlockId).not.toBe(originalLastSelectedId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("선택된 섹션이 있을 때 Blog 템플릿 섹션 액션을 호출하면 해당 섹션이 새 Blog 템플릿으로 교체되어야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
store.getState().addBlogTemplateSection();
|
||||||
|
|
||||||
|
let { blocks, selectedBlockId } = store.getState();
|
||||||
|
|
||||||
|
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||||
|
expect(sectionBlocks).toHaveLength(1);
|
||||||
|
|
||||||
|
const section = sectionBlocks[0] as any;
|
||||||
|
const sectionId = section.id;
|
||||||
|
|
||||||
|
const oldTextIds = blocks.filter((b) => b.type === "text").map((b) => b.id);
|
||||||
|
|
||||||
|
const originalLastSelectedId = selectedBlockId;
|
||||||
|
|
||||||
|
store.getState().selectBlock(sectionId);
|
||||||
|
store.getState().addBlogTemplateSection();
|
||||||
|
|
||||||
|
({ blocks, selectedBlockId } = store.getState());
|
||||||
|
|
||||||
|
const sectionBlocksAfter = blocks.filter((b) => b.type === "section");
|
||||||
|
expect(sectionBlocksAfter).toHaveLength(1);
|
||||||
|
|
||||||
|
const replacedSection = sectionBlocksAfter[0] as any;
|
||||||
|
expect(replacedSection.id).toBe(sectionId);
|
||||||
|
|
||||||
|
const textBlocksAfter = blocks.filter((b) => b.type === "text") as any[];
|
||||||
|
expect(textBlocksAfter.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
textBlocksAfter.forEach((tb) => {
|
||||||
|
expect(tb.sectionId).toBe(sectionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const allIdsAfter = blocks.map((b) => b.id);
|
||||||
|
oldTextIds.forEach((id) => {
|
||||||
|
expect(allIdsAfter).not.toContain(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
||||||
|
expect(selectedBlockId).not.toBe(originalLastSelectedId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("선택된 섹션이 있을 때 Hero 템플릿 섹션 액션을 호출하면 해당 섹션이 새 Hero 템플릿으로 교체되어야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
// 1) 먼저 Hero 템플릿 섹션을 하나 추가한다.
|
||||||
|
store.getState().addHeroTemplateSection();
|
||||||
|
|
||||||
|
let { blocks, selectedBlockId } = store.getState();
|
||||||
|
|
||||||
|
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||||
|
expect(sectionBlocks).toHaveLength(1);
|
||||||
|
|
||||||
|
const section = sectionBlocks[0] as any;
|
||||||
|
const sectionId = section.id;
|
||||||
|
|
||||||
|
// 기존 텍스트/버튼 블록들의 id 를 기록해 둔다.
|
||||||
|
const oldTextIds = blocks.filter((b) => b.type === "text").map((b) => b.id);
|
||||||
|
const oldButtonIds = blocks.filter((b) => b.type === "button").map((b) => b.id);
|
||||||
|
|
||||||
|
const originalLastSelectedId = selectedBlockId;
|
||||||
|
|
||||||
|
// 2) 해당 섹션을 선택한 상태에서 다시 Hero 템플릿 섹션 액션을 호출한다.
|
||||||
|
store.getState().selectBlock(sectionId);
|
||||||
|
store.getState().addHeroTemplateSection();
|
||||||
|
|
||||||
|
({ blocks, selectedBlockId } = store.getState());
|
||||||
|
|
||||||
|
const sectionBlocksAfter = blocks.filter((b) => b.type === "section");
|
||||||
|
expect(sectionBlocksAfter).toHaveLength(1);
|
||||||
|
|
||||||
|
const replacedSection = sectionBlocksAfter[0] as any;
|
||||||
|
// 교체 후에도 섹션 id 는 동일하게 유지되어야 한다 (섹션 컨테이너 재사용).
|
||||||
|
expect(replacedSection.id).toBe(sectionId);
|
||||||
|
|
||||||
|
const columns = (replacedSection.props as any).columns;
|
||||||
|
expect(Array.isArray(columns)).toBe(true);
|
||||||
|
expect(columns.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const firstColumnId = columns[0].id;
|
||||||
|
|
||||||
|
const textBlocksAfter = blocks.filter((b) => b.type === "text") as any[];
|
||||||
|
const buttonBlocksAfter = blocks.filter((b) => b.type === "button") as any[];
|
||||||
|
|
||||||
|
// 새 텍스트/버튼 블록이 최소 1개 이상 존재해야 한다.
|
||||||
|
expect(textBlocksAfter.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(buttonBlocksAfter.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// 새 텍스트/버튼 블록은 모두 동일 섹션의 첫 컬럼에 배치되어야 한다.
|
||||||
|
textBlocksAfter.forEach((tb) => {
|
||||||
|
expect(tb.sectionId).toBe(sectionId);
|
||||||
|
expect(tb.columnId).toBe(firstColumnId);
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonBlocksAfter.forEach((bb) => {
|
||||||
|
expect(bb.sectionId).toBe(sectionId);
|
||||||
|
expect(bb.columnId).toBe(firstColumnId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 기존 텍스트/버튼 블록 id 들은 모두 제거되어야 한다.
|
||||||
|
const allIdsAfter = blocks.map((b) => b.id);
|
||||||
|
[...oldTextIds, ...oldButtonIds].forEach((id) => {
|
||||||
|
expect(allIdsAfter).not.toContain(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 선택 상태는 새로 생성된 템플릿의 마지막 블록을 가리켜야 한다.
|
||||||
|
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
||||||
|
expect(selectedBlockId).not.toBe(originalLastSelectedId);
|
||||||
|
});
|
||||||
|
|
||||||
it("Blog 템플릿 섹션을 추가하면 3개의 포스트 카드(제목/요약)가 생성되어야 한다", () => {
|
it("Blog 템플릿 섹션을 추가하면 3개의 포스트 카드(제목/요약)가 생성되어야 한다", () => {
|
||||||
const store = createEditorStore();
|
const store = createEditorStore();
|
||||||
|
|
||||||
@@ -1191,6 +1346,71 @@ describe("editorStore", () => {
|
|||||||
expect(updatedFormProps.submitButtonId).toBe(submitButtonId);
|
expect(updatedFormProps.submitButtonId).toBe(submitButtonId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("addHeroTemplateSection 호출 시 Hero 템플릿 섹션과 블록들이 추가되고 마지막 블록이 선택되어야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
const stateAny = store.getState() as any;
|
||||||
|
|
||||||
|
stateAny.addHeroTemplateSection();
|
||||||
|
|
||||||
|
const { blocks, selectedBlockId } = store.getState();
|
||||||
|
expect(blocks.length).toBeGreaterThanOrEqual(4);
|
||||||
|
|
||||||
|
const section = blocks.find((b) => b.type === "section");
|
||||||
|
expect(section).toBeTruthy();
|
||||||
|
|
||||||
|
const heroBlocks = blocks.filter((b) => b.sectionId === section!.id);
|
||||||
|
// 헤드라인/서브텍스트/버튼 3개가 섹션 내부에 있어야 한다.
|
||||||
|
expect(heroBlocks).toHaveLength(3);
|
||||||
|
expect(heroBlocks.map((b) => b.type)).toEqual(["text", "text", "button"]);
|
||||||
|
|
||||||
|
// 마지막 블록이 선택 상태여야 한다.
|
||||||
|
const lastHeroBlock = heroBlocks[heroBlocks.length - 1];
|
||||||
|
expect(selectedBlockId).toBe(lastHeroBlock.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addFeaturesTemplateSection 호출 시 3컬럼 Feature 텍스트 블록들이 추가되고 마지막 블록이 선택되어야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
const stateAny = store.getState() as any;
|
||||||
|
|
||||||
|
stateAny.addFeaturesTemplateSection();
|
||||||
|
|
||||||
|
const { blocks, selectedBlockId } = store.getState();
|
||||||
|
// 섹션 1개 + 각 컬럼당 제목/설명 2개씩, 총 7개 블록이 생성된다.
|
||||||
|
expect(blocks.length).toBe(7);
|
||||||
|
|
||||||
|
const section = blocks.find((b) => b.type === "section");
|
||||||
|
expect(section).toBeTruthy();
|
||||||
|
|
||||||
|
const featureBlocks = blocks.filter((b) => b.sectionId === section!.id);
|
||||||
|
expect(featureBlocks).toHaveLength(6);
|
||||||
|
expect(featureBlocks.every((b) => b.type === "text")).toBe(true);
|
||||||
|
|
||||||
|
// 마지막 Feature 블록이 선택되어야 한다.
|
||||||
|
const lastFeature = featureBlocks[featureBlocks.length - 1];
|
||||||
|
expect(selectedBlockId).toBe(lastFeature.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addCtaTemplateSection 호출 시 CTA 섹션과 텍스트/버튼이 추가되고 버튼이 선택되어야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
const stateAny = store.getState() as any;
|
||||||
|
|
||||||
|
stateAny.addCtaTemplateSection();
|
||||||
|
|
||||||
|
const { blocks, selectedBlockId } = store.getState();
|
||||||
|
expect(blocks.length).toBe(3);
|
||||||
|
|
||||||
|
const section = blocks.find((b) => b.type === "section");
|
||||||
|
expect(section).toBeTruthy();
|
||||||
|
|
||||||
|
const ctaBlocks = blocks.filter((b) => b.sectionId === section!.id);
|
||||||
|
expect(ctaBlocks).toHaveLength(2);
|
||||||
|
expect(ctaBlocks.map((b) => b.type).sort()).toEqual(["button", "text"].sort());
|
||||||
|
|
||||||
|
const buttonBlock = ctaBlocks.find((b) => b.type === "button");
|
||||||
|
expect(buttonBlock).toBeTruthy();
|
||||||
|
expect(selectedBlockId).toBe(buttonBlock!.id);
|
||||||
|
});
|
||||||
|
|
||||||
it("projectConfig 기본값과 updateProjectConfig 액션으로 프로젝트 설정을 관리할 수 있어야 한다", () => {
|
it("projectConfig 기본값과 updateProjectConfig 액션으로 프로젝트 설정을 관리할 수 있어야 한다", () => {
|
||||||
const store = createEditorStore();
|
const store = createEditorStore();
|
||||||
const stateAny = store.getState() as any;
|
const stateAny = store.getState() as any;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import { resolve } from "node:path";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
include: ["tests/unit/**/*.spec.ts", "tests/api/**/*.spec.ts"],
|
include: ["tests/unit/**/*.spec.{ts,tsx}", "tests/api/**/*.spec.ts"],
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Reference in New Issue
Block a user