TDD,E2E 개선, 미적용 스타일 개선
This commit is contained in:
+184
-49
@@ -184,6 +184,21 @@ function EditorPageInner() {
|
||||
const slug = initialSlugFromQuery.trim();
|
||||
if (!slug) return;
|
||||
|
||||
// 이미 해당 slug 에 대한 autosave 스냅샷이 있으면,
|
||||
// 서버에서 프로젝트를 다시 불러와서 현재 로컬 상태를 덮어쓰지 않는다.
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const key = `pb:autosave:${slug}`;
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (raw) {
|
||||
setHasLoadedInitialProjectFromSlug(true);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// localStorage 접근 실패 시에는 서버 로드를 계속 시도한다.
|
||||
}
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadFromServer = async () => {
|
||||
@@ -218,7 +233,7 @@ function EditorPageInner() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authChecked, initialSlugFromQuery, hasLoadedInitialProjectFromSlug, replaceBlocks]);
|
||||
}, [authChecked, initialSlugFromQuery, hasLoadedInitialProjectFromSlug, replaceBlocks, updateProjectConfig, resetHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -707,6 +722,13 @@ function EditorPageInner() {
|
||||
|
||||
const rootBlocks = blocks.filter((block) => !block.sectionId);
|
||||
|
||||
const projectSlugRaw = projectConfig?.slug?.trim?.();
|
||||
const projectSlug =
|
||||
typeof projectSlugRaw === "string" && projectSlugRaw.length > 0 ? projectSlugRaw : "";
|
||||
const previewHref = projectSlug
|
||||
? `/preview?slug=${encodeURIComponent(projectSlug)}`
|
||||
: "/preview";
|
||||
|
||||
useEffect(() => {
|
||||
if (!authChecked) return;
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -951,7 +973,7 @@ function EditorPageInner() {
|
||||
<span>프로젝트 목록</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/preview"
|
||||
href={previewHref}
|
||||
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
|
||||
>
|
||||
<Eye className="w-3 h-3" aria-hidden="true" />
|
||||
@@ -1443,6 +1465,7 @@ function SortableEditorBlock({
|
||||
{block.type === "formInput" && (() => {
|
||||
const inputProps = block.props as FormInputBlockProps;
|
||||
const inputType = inputProps.inputType ?? "text";
|
||||
const fieldName = inputProps.formFieldName ?? block.id;
|
||||
|
||||
// 필드 스타일: 텍스트 색상/배경/테두리/모서리 등은 커스텀 props 를 통해 제어한다.
|
||||
const inputTokens = computeFormInputEditorTokens(inputProps);
|
||||
@@ -1452,47 +1475,100 @@ function SortableEditorBlock({
|
||||
const isInline = labelLayout === "inline";
|
||||
|
||||
const inputAlignClass = inputTokens.inputAlignClass;
|
||||
const labelDisplay = (inputProps as any).labelDisplay ?? "visible";
|
||||
|
||||
// 프리뷰/퍼블릭과 동일한 pb-input/pb-textarea 클래스를 사용해 높이/패딩을 통일한다.
|
||||
const baseInputClass = `pb-input w-full text-xs outline-none ${inputAlignClass}`;
|
||||
const baseTextareaClass = `pb-textarea w-full text-xs outline-none ${inputAlignClass}`;
|
||||
|
||||
// 에디터에서는 폼 입력 블록이 실제 입력 UI처럼 보이도록 라벨 + 인풋(또는 textarea)을 함께 렌더링한다.
|
||||
return (
|
||||
<div className={`text-xs ${isInline ? "flex items-center gap-2" : "flex flex-col gap-1"}`}>
|
||||
{inputProps.labelMode === "image" && inputProps.labelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={inputProps.labelImageUrl}
|
||||
alt={inputProps.labelImageAlt || inputProps.label || "폼 라벨"}
|
||||
className="h-4 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-slate-200 shrink-0">{inputProps.label}</span>
|
||||
{labelDisplay === "visible" && (
|
||||
inputProps.labelMode === "image" && inputProps.labelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={inputProps.labelImageUrl}
|
||||
alt={inputProps.labelImageAlt || inputProps.label || "폼 라벨"}
|
||||
className="h-4 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-slate-200 shrink-0">{inputProps.label}</span>
|
||||
)
|
||||
)}
|
||||
{(() => {
|
||||
// widthMode 와 labelLayout 에 따라 wrapper 의 Tailwind width/flex 클래스를 결정한다.
|
||||
const widthClass = inputTokens.widthClass;
|
||||
|
||||
if (labelDisplay === "floating") {
|
||||
const placeholder = " ";
|
||||
|
||||
return (
|
||||
<div className={`${widthClass} relative`}>
|
||||
<span
|
||||
data-testid="editor-form-input-floating-label"
|
||||
className="absolute left-2 pointer-events-none"
|
||||
style={{
|
||||
top: "-0.3rem",
|
||||
fontSize: "0.725rem",
|
||||
color: "#e5e7eb",
|
||||
backgroundColor: "#020617",
|
||||
padding: "0 0.25rem",
|
||||
}}
|
||||
>
|
||||
{inputProps.label}
|
||||
</span>
|
||||
{inputType === "textarea" ? (
|
||||
<textarea
|
||||
data-testid="form-input-field"
|
||||
className={`${baseTextareaClass} pt-6`}
|
||||
style={fieldStyle}
|
||||
name={fieldName}
|
||||
placeholder={placeholder}
|
||||
aria-label={inputProps.label}
|
||||
readOnly
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
data-testid="form-input-field"
|
||||
className={`${baseInputClass} pt-6`}
|
||||
style={fieldStyle}
|
||||
type={inputType === "email" ? "email" : "text"}
|
||||
name={fieldName}
|
||||
placeholder={placeholder}
|
||||
aria-label={inputProps.label}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="form-input-field"
|
||||
style={fieldStyle}
|
||||
className={`${widthClass} rounded border border-slate-700 bg-slate-950`}
|
||||
>
|
||||
{inputType === "textarea" ? (
|
||||
<textarea
|
||||
className={`w-full min-h-[60px] bg-transparent px-2 py-1 text-xs outline-none ${inputAlignClass}`}
|
||||
placeholder={inputProps.placeholder || inputProps.label}
|
||||
aria-label={inputProps.label}
|
||||
readOnly
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={`w-full bg-transparent px-2 py-1 text-xs outline-none ${inputAlignClass}`}
|
||||
type={inputType === "email" ? "email" : "text"}
|
||||
placeholder={inputProps.placeholder || inputProps.label}
|
||||
aria-label={inputProps.label}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={widthClass}>
|
||||
{inputType === "textarea" ? (
|
||||
<textarea
|
||||
data-testid="form-input-field"
|
||||
className={baseTextareaClass}
|
||||
style={fieldStyle}
|
||||
name={fieldName}
|
||||
placeholder={inputProps.placeholder || inputProps.label}
|
||||
aria-label={inputProps.label}
|
||||
readOnly
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
data-testid="form-input-field"
|
||||
className={baseInputClass}
|
||||
style={fieldStyle}
|
||||
type={inputType === "email" ? "email" : "text"}
|
||||
name={fieldName}
|
||||
placeholder={inputProps.placeholder || inputProps.label}
|
||||
aria-label={inputProps.label}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
@@ -1500,6 +1576,7 @@ function SortableEditorBlock({
|
||||
})()}
|
||||
{block.type === "formSelect" && (() => {
|
||||
const selectProps = block.props as FormSelectBlockProps;
|
||||
const fieldName = selectProps.formFieldName ?? block.id;
|
||||
const options = Array.isArray(selectProps.options) && selectProps.options.length > 0
|
||||
? selectProps.options
|
||||
: [
|
||||
@@ -1523,12 +1600,12 @@ function SortableEditorBlock({
|
||||
) : (
|
||||
<span className="text-slate-200">{selectProps.label}</span>
|
||||
)}
|
||||
<div
|
||||
style={fieldStyle}
|
||||
className="w-full rounded border border-slate-700 bg-slate-950"
|
||||
>
|
||||
<div className="w-full">
|
||||
<select
|
||||
className="w-full bg-transparent px-2 py-1 text-xs outline-none"
|
||||
data-testid="form-select-field"
|
||||
className="pb-select w-full text-xs outline-none"
|
||||
style={fieldStyle}
|
||||
name={fieldName}
|
||||
aria-label={selectProps.label}
|
||||
defaultValue={options[0]?.value}
|
||||
>
|
||||
@@ -1552,12 +1629,42 @@ function SortableEditorBlock({
|
||||
];
|
||||
|
||||
const groupLabelMode = radioProps.groupLabelMode ?? "text";
|
||||
const groupLabelDisplay = radioProps.groupLabelDisplay ?? "visible";
|
||||
const labelLayout = radioProps.labelLayout ?? "stacked";
|
||||
const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
|
||||
|
||||
const radioTokens = computeFormOptionGroupEditorTokens(radioProps);
|
||||
const fieldStyle = radioTokens.fieldStyle;
|
||||
|
||||
const optionLayout = radioProps.optionLayout ?? "stacked";
|
||||
const optionsLayoutClass = [
|
||||
"pb-form-options",
|
||||
optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
|
||||
].join(" ");
|
||||
|
||||
// optionGapPx 를 사용해 옵션 컨테이너의 rowGap/columnGap 을 제어한다.
|
||||
const optionGapPx =
|
||||
typeof radioProps.optionGapPx === "number" && radioProps.optionGapPx >= 0
|
||||
? radioProps.optionGapPx
|
||||
: undefined;
|
||||
const gapStyle: CSSProperties =
|
||||
optionGapPx !== undefined
|
||||
? optionLayout === "inline"
|
||||
? { rowGap: `${optionGapPx}px`, columnGap: `${optionGapPx}px` }
|
||||
: { rowGap: `${optionGapPx}px` }
|
||||
: {};
|
||||
|
||||
// 그룹 타이틀 레이아웃이 inline 인 경우, 라벨과 옵션 컨테이너를 가로 방향으로 배치하고 labelGapPx 를 columnGap 으로 사용한다.
|
||||
const inlineGapPx = typeof radioProps.labelGapPx === "number" ? radioProps.labelGapPx : 8;
|
||||
const groupContainerStyle: CSSProperties = isInlineLayout
|
||||
? { columnGap: `${inlineGapPx}px` }
|
||||
: {};
|
||||
const groupContainerClassName = isInlineLayout
|
||||
? "flex flex-row items-center text-xs"
|
||||
: "flex flex-col gap-1 text-xs";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<div className={groupContainerClassName} style={groupContainerStyle}>
|
||||
{/* 그룹 타이틀은 텍스트/이미지 모드를 지원한다. */}
|
||||
{groupLabelMode === "image" && radioProps.groupLabelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -1569,15 +1676,12 @@ function SortableEditorBlock({
|
||||
) : (
|
||||
<span className="text-slate-200">{radioProps.groupLabel}</span>
|
||||
)}
|
||||
<div
|
||||
style={fieldStyle}
|
||||
className="flex flex-col gap-1 rounded border border-slate-700 bg-slate-950"
|
||||
>
|
||||
<div style={{ ...fieldStyle, ...gapStyle }} className={optionsLayoutClass}>
|
||||
{options.map((opt) => (
|
||||
<label key={opt.value} className="inline-flex items-center gap-2 text-slate-200">
|
||||
<label key={opt.value} className="pb-form-option">
|
||||
<input
|
||||
type="radio"
|
||||
className="h-3 w-3 rounded border border-slate-700 bg-slate-950"
|
||||
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
|
||||
name={radioProps.formFieldName}
|
||||
value={opt.value}
|
||||
readOnly
|
||||
@@ -1609,12 +1713,43 @@ function SortableEditorBlock({
|
||||
];
|
||||
|
||||
const groupLabelMode = checkboxProps.groupLabelMode ?? "text";
|
||||
const groupLabelDisplay = checkboxProps.groupLabelDisplay ?? "visible";
|
||||
const labelLayout = checkboxProps.labelLayout ?? "stacked";
|
||||
const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
|
||||
|
||||
const checkboxTokens = computeFormOptionGroupEditorTokens(checkboxProps);
|
||||
const fieldStyle = checkboxTokens.fieldStyle;
|
||||
|
||||
const optionLayout = checkboxProps.optionLayout ?? "stacked";
|
||||
const optionsLayoutClass = [
|
||||
"pb-form-options",
|
||||
optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
|
||||
].join(" ");
|
||||
|
||||
// 그룹 타이틀 레이아웃이 inline 인 경우, 라벨과 옵션 컨테이너를 가로 방향으로 배치하고 labelGapPx 를 columnGap 으로 사용한다.
|
||||
const inlineGapPx = typeof checkboxProps.labelGapPx === "number" ? checkboxProps.labelGapPx : 8;
|
||||
const groupContainerStyle: CSSProperties = {
|
||||
...fieldStyle,
|
||||
...(isInlineLayout ? { columnGap: `${inlineGapPx}px` } : {}),
|
||||
};
|
||||
|
||||
// optionGapPx 를 사용해 옵션 컨테이너의 rowGap/columnGap 을 제어한다.
|
||||
const optionGapPx =
|
||||
typeof checkboxProps.optionGapPx === "number" && checkboxProps.optionGapPx >= 0
|
||||
? checkboxProps.optionGapPx
|
||||
: undefined;
|
||||
const optionsStyle: CSSProperties =
|
||||
optionGapPx !== undefined
|
||||
? optionLayout === "inline"
|
||||
? { rowGap: `${optionGapPx}px`, columnGap: `${optionGapPx}px` }
|
||||
: { rowGap: `${optionGapPx}px` }
|
||||
: {};
|
||||
const groupContainerClassName = isInlineLayout
|
||||
? "flex flex-row items-center text-xs text-slate-200"
|
||||
: "flex flex-col gap-1 text-xs text-slate-200";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-xs text-slate-200" style={fieldStyle}>
|
||||
<div className={groupContainerClassName} style={groupContainerStyle}>
|
||||
{/* 체크박스 그룹 타이틀도 텍스트/이미지 모드를 지원한다. */}
|
||||
{groupLabelMode === "image" && checkboxProps.groupLabelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -1626,12 +1761,12 @@ function SortableEditorBlock({
|
||||
) : (
|
||||
<span className="text-slate-200">{checkboxProps.groupLabel}</span>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className={optionsLayoutClass} style={optionsStyle}>
|
||||
{options.map((opt) => (
|
||||
<label key={opt.value} className="inline-flex items-center gap-2">
|
||||
<label key={opt.value} className="pb-form-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border border-slate-700 bg-slate-950"
|
||||
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
|
||||
name={checkboxProps.formFieldName}
|
||||
value={opt.value}
|
||||
readOnly
|
||||
|
||||
Reference in New Issue
Block a user