Files
page-builder/src/app/editor/forms/FormControllerPanel.tsx
T
jaybe 4840a530b6
CI / test (push) Failing after 5m41s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped
오류 수정
2025-12-12 18:04:31 +09:00

435 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import type { Block, ButtonBlockProps, FormBlockProps } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { computeFormControllerPublicTokens } from "@/features/editor/utils/formHelpers";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorFormControllerPanelMessages } from "@/features/i18n/messages/editorFormControllerPanel";
interface FormControllerPanelProps {
block: Block; // type === "form"
blocks: Block[];
selectedBlockId: string | null;
updateBlock: (id: string, partial: any) => void;
}
export function FormControllerPanel({ block, blocks, selectedBlockId, updateBlock }: FormControllerPanelProps) {
if (!selectedBlockId || block.id !== selectedBlockId) return null;
const [showSheetsGuide, setShowSheetsGuide] = useState(false);
const formProps = block.props as FormBlockProps;
const locale = useAppLocale();
const m = getEditorFormControllerPanelMessages(locale);
const sheetsScriptExample = (() => {
const tokens = computeFormControllerPublicTokens(block, blocks);
const fieldNames = tokens.fields.map((f) => f.name).filter((name) => !!name);
if (fieldNames.length === 0) {
return (
"function doPost(e) {\n" +
' var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("시트1");\n' +
" if (!sheet) { sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; }\n" +
" var params = e.parameter; // x-www-form-urlencoded\n" +
" var row = [\n" +
' params.name || "",\n' +
' params.email || "",\n' +
' params.message || "",\n' +
" new Date(),\n" +
" ];\n" +
" sheet.appendRow(row);\n" +
" return ContentService\n" +
" .createTextOutput(JSON.stringify({ ok: true }))\n" +
" .setMimeType(ContentService.MimeType.JSON);\n" +
"}"
);
}
const headerNames = [...fieldNames, "createdAt"];
const rowLines = fieldNames.map((name) => ` params.${name} || "",`);
rowLines.push(" new Date(),");
const lines: string[] = [];
lines.push("function doPost(e) {");
lines.push(' var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("시트1");');
lines.push(" if (!sheet) { sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; }");
lines.push(" var params = e.parameter; // x-www-form-urlencoded");
lines.push(" // 시트의 1행 헤더는 다음과 같이 맞춰 주세요:");
lines.push(` // ${headerNames.join(", ")}`);
lines.push(" var row = [");
for (const line of rowLines) {
lines.push(line);
}
lines.push(" ];");
lines.push(" sheet.appendRow(row);");
lines.push(" return ContentService");
lines.push(" .createTextOutput(JSON.stringify({ ok: true }))");
lines.push(" .setMimeType(ContentService.MimeType.JSON);");
lines.push("}");
return lines.join("\n");
})();
return (
<div className="space-y-4 text-xs">
<p className="text-slate-400">
{m.introTextPrefix}
<code className="font-mono text-[11px]">/api/forms/submit</code>
{m.introTextSuffix}
</p>
<div className="space-y-2">
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">{m.submitTargetLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={formProps.submitTarget ?? "internal"}
onChange={(e) =>
updateBlock(selectedBlockId, {
submitTarget: e.target.value as FormBlockProps["submitTarget"],
} as any)
}
>
<option value="internal">{m.submitTargetOptionInternal}</option>
<option value="webhook">{m.submitTargetOptionWebhook}</option>
</select>
</label>
{formProps.submitTarget === "webhook" && (
<div className="space-y-3">
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">{m.webhookUrlLabel}</span>
<input
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.webhookUrlPlaceholder}
value={formProps.destinationUrl ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
destinationUrl: e.target.value,
} as any)
}
/>
</label>
<label className="flex items-center justify-between gap-2">
<span className="text-slate-500 dark:text-slate-400">{m.payloadFormatLabel}</span>
<select
className="w-40 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={formProps.payloadFormat ?? "form"}
onChange={(e) =>
updateBlock(selectedBlockId, {
payloadFormat: e.target.value as any,
} as any)
}
>
<option value="form">{m.payloadFormatOptionForm}</option>
<option value="json">{m.payloadFormatOptionJson}</option>
</select>
</label>
<label className="flex items-center justify-between gap-2">
<span className="text-slate-500 dark:text-slate-400">{m.httpMethodLabel}</span>
<select
className="w-28 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={formProps.method ?? "POST"}
onChange={(e) =>
updateBlock(selectedBlockId, {
method: e.target.value as FormBlockProps["method"],
} as any)
}
>
<option value="POST">POST</option>
<option value="GET">GET</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">{m.authTokenLabel}</span>
<input
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.authTokenPlaceholder}
value={formProps.headers?.Authorization ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
headers: {
...(formProps.headers ?? {}),
Authorization: e.target.value,
},
} as any)
}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">{m.extraParamsLabel}</span>
<textarea
className="w-full h-16 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] font-mono text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.extraParamsPlaceholder}
value={Object.entries(formProps.extraParams ?? {})
.map(([k, v]) => `${k}=${v}`)
.join("\n")}
onChange={(e) => {
const lines = e.target.value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const next: Record<string, string> = {};
for (const line of lines) {
const idx = line.indexOf("=");
if (idx > 0) {
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
if (key) next[key] = value;
}
}
updateBlock(selectedBlockId, {
extraParams: next,
} as any);
}}
/>
</label>
<div className="flex justify-end">
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={() => setShowSheetsGuide(true)}
>
{m.sheetsGuideButtonLabel}
</button>
</div>
</div>
)}
</div>
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.layoutSectionTitle}</h3>
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
<span>{m.formWidthModeLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={formProps.formWidthMode ?? "auto"}
onChange={(e) =>
updateBlock(selectedBlockId, {
formWidthMode: e.target.value as FormBlockProps["formWidthMode"],
} as any)
}
>
<option value="auto">{m.formWidthModeOptionAuto}</option>
<option value="full">{m.formWidthModeOptionFull}</option>
<option value="fixed">{m.formWidthModeOptionFixed}</option>
</select>
</label>
{(formProps.formWidthMode ?? "auto") === "fixed" && (
<NumericPropertyControl
label={m.formFixedWidthLabel}
unitLabel="(px)"
value={typeof formProps.formWidthPx === "number" ? formProps.formWidthPx : 480}
min={160}
max={1200}
step={10}
presets={[
{ id: "sm", label: m.formFixedWidthPresetNarrow, value: 320 },
{ id: "md", label: m.formFixedWidthPresetNormal, value: 480 },
{ id: "lg", label: m.formFixedWidthPresetWide, value: 640 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
formWidthPx: v,
} as any)
}
/>
)}
<NumericPropertyControl
label={m.marginYLabel}
unitLabel="(px)"
value={typeof formProps.marginYPx === "number" ? formProps.marginYPx : 24}
min={0}
max={160}
step={4}
presets={[
{ id: "none", label: m.marginYPresetNone, value: 0 },
{ id: "compact", label: m.marginYPresetCompact, value: 16 },
{ id: "normal", label: m.marginYPresetNormal, value: 24 },
{ id: "spacious", label: m.marginYPresetSpacious, value: 40 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
marginYPx: v,
} as any)
}
/>
</div>
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.messagesSectionTitle}</h3>
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
<span>{m.successMessageLabel}</span>
<input
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.successMessagePlaceholder}
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-500 dark:text-slate-400">
<span>{m.errorMessageLabel}</span>
<input
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.errorMessagePlaceholder}
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">
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.controllerSectionTitle}</h3>
<fieldset className="space-y-2" aria-label={m.fieldMappingAriaLabel}>
<legend className="text-[11px] text-slate-400 mb-1">{m.fieldMappingLegend}</legend>
{blocks
.filter((b) =>
b.type === "formInput" ||
b.type === "formSelect" ||
b.type === "formCheckbox" ||
b.type === "formRadio",
)
.map((fieldBlock) => {
const fieldId = fieldBlock.id;
const anyProps: any = fieldBlock.props ?? {};
const transmissionKey: string | undefined = anyProps.formFieldName;
const labelText: string | undefined = anyProps.label ?? anyProps.groupLabel;
const displayLabel = transmissionKey
? labelText && labelText !== transmissionKey
? `${transmissionKey} (${labelText})`
: transmissionKey
: labelText || fieldId;
const checked = (formProps.fieldIds ?? []).includes(fieldId);
const required = (formProps.requiredFieldIds ?? []).includes(fieldId);
return (
<label
key={fieldId}
className="flex items-center gap-2 text-[11px] text-slate-800 dark:text-slate-200"
>
<input
type="checkbox"
className="h-3 w-3 rounded border border-slate-300 bg-white text-slate-900 dark:border-slate-600 dark:bg-slate-900"
checked={checked}
onChange={(e) => {
const current = formProps.fieldIds ?? [];
const next = e.target.checked
? [...current, fieldId]
: current.filter((id) => id !== fieldId);
updateBlock(selectedBlockId, {
fieldIds: next,
} as any);
}}
/>
<span className="flex-1 truncate">{displayLabel}</span>
<label className="inline-flex items-center gap-1 text-[11px] text-slate-400">
<input
type="checkbox"
className="h-3 w-3 rounded border border-slate-300 bg-white text-slate-900 dark:border-slate-600 dark:bg-slate-900"
checked={required}
onChange={(e) => {
const current = formProps.requiredFieldIds ?? [];
const next = e.target.checked
? Array.from(new Set([...current, fieldId]))
: current.filter((id) => id !== fieldId);
updateBlock(selectedBlockId, {
requiredFieldIds: next,
} as any);
}}
/>
<span>{m.requiredLabel}</span>
</label>
</label>
);
})}
</fieldset>
<div className="space-y-1">
<span className="text-[11px] text-slate-400">{m.submitButtonLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.submitButtonAriaLabel}
value={formProps.submitButtonId ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
submitButtonId: e.target.value || null,
} as any)
}
>
<option value="">{m.submitButtonNoneOptionLabel}</option>
{blocks
.filter((b) => b.type === "button")
.map((buttonBlock) => {
const btnProps = buttonBlock.props as ButtonBlockProps;
const transmissionKey = btnProps.formFieldName;
const labelText = btnProps.label;
const displayLabel = transmissionKey
? labelText && labelText !== transmissionKey
? `${transmissionKey} (${labelText})`
: transmissionKey
: labelText || buttonBlock.id;
return (
<option key={buttonBlock.id} value={buttonBlock.id}>
{displayLabel}
</option>
);
})}
</select>
</div>
</div>
{showSheetsGuide && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="w-full max-w-xl rounded border border-slate-200 bg-white px-4 py-3 text-xs text-slate-900 shadow-lg dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">
<div className="mb-2 flex items-center justify-between">
<h4 className="text-[11px] font-semibold">{m.sheetsGuideHeading}</h4>
<button
type="button"
className="text-[11px] text-slate-400 hover:text-slate-100"
onClick={() => setShowSheetsGuide(false)}
aria-label={m.sheetsGuideCloseAriaLabel}
>
×
</button>
</div>
<p className="text-[11px] text-slate-500 dark:text-slate-400 leading-relaxed">
{m.sheetsGuideIntro}
</p>
<ol className="list-decimal list-inside space-y-1 text-[11px] text-slate-400 mt-2">
<li>{m.sheetsGuideStep1}</li>
<li>{m.sheetsGuideStep2}</li>
<li>{m.sheetsGuideStep3}</li>
</ol>
<div className="mt-2 space-y-1">
<span className="text-[11px] text-slate-500 dark:text-slate-400">{m.sheetsGuideExampleCodeLabel}</span>
<textarea
className="w-full h-40 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] font-mono text-slate-900 outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
readOnly
value={sheetsScriptExample}
/>
</div>
</div>
</div>
)}
</div>
);
}