Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Center-canvas of the form-builder. Wraps a `DndContext` +
|
||||
* `SortableContext` so child `<SortableField/>` rows can be reordered
|
||||
* via pointer-drag or arrow-keys (via the bundled `KeyboardSensor`).
|
||||
*
|
||||
* The DndContext is intentionally kept inside the builder shell — the
|
||||
* parent `FormBuilder` component owns the field-array state and feeds
|
||||
* it down; this component only emits move events as new arrays so the
|
||||
* parent can decide whether to debounce / persist.
|
||||
*/
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
KeyboardSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import type { FormField } from "@toolctrl/form-widget";
|
||||
import { SortableField } from "./SortableField";
|
||||
|
||||
export type CanvasProps = {
|
||||
fields: FormField[];
|
||||
selectedKey: string | null;
|
||||
onSelect: (key: string) => void;
|
||||
onReorder: (next: FormField[]) => void;
|
||||
onUpdate: (key: string, next: FormField) => void;
|
||||
onDelete: (key: string) => void;
|
||||
};
|
||||
|
||||
export function Canvas({
|
||||
fields,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
onReorder,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: CanvasProps): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
function handleDragEnd(event: DragEndEvent): void {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = fields.findIndex((f) => f.key === active.id);
|
||||
const newIndex = fields.findIndex((f) => f.key === over.id);
|
||||
if (oldIndex < 0 || newIndex < 0) return;
|
||||
const next = fields.slice();
|
||||
const [removed] = next.splice(oldIndex, 1);
|
||||
next.splice(newIndex, 0, removed);
|
||||
onReorder(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex-1 min-w-0 border rounded bg-gray-50 p-3" aria-label={t("settings.forms.builder.canvas.ariaLabel")}>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-gray-600 mb-2">{t("settings.forms.builder.canvas.title")}</h3>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={fields.map((f) => f.key)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-2">
|
||||
{fields.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 italic">{t("settings.forms.builder.canvas.empty")}</p>
|
||||
) : (
|
||||
fields.map((field) => (
|
||||
<SortableField
|
||||
key={field.key}
|
||||
field={field}
|
||||
isSelected={field.key === selectedKey}
|
||||
onSelect={() => onSelect(field.key)}
|
||||
onUpdate={(next) => onUpdate(field.key, next)}
|
||||
onDelete={() => onDelete(field.key)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Left-rail field-type palette for the form-widget drag-drop builder
|
||||
* (Phase 05 #58 follow-up). Each tile supports two interaction modes:
|
||||
*
|
||||
* 1. Click → calls `onAdd(type)`, which the parent <FormBuilder/>
|
||||
* appends to the canvas using the palette's default field shape.
|
||||
* 2. Drag → exposes a `@dnd-kit` draggable so the user can drop the
|
||||
* tile directly onto a position on the sortable canvas.
|
||||
*
|
||||
* Keeping click+drag both functional is an explicit a11y requirement —
|
||||
* the keyboard sensor on the canvas handles reorder, and the click
|
||||
* path is the no-pointer "add field" affordance.
|
||||
*/
|
||||
import type * as React from "react";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { paletteItems } from "./palette";
|
||||
import type { BuilderFieldType } from "./types";
|
||||
|
||||
export type FieldPaletteProps = {
|
||||
onAdd: (type: BuilderFieldType) => void;
|
||||
};
|
||||
|
||||
function DraggablePaletteTile({
|
||||
type,
|
||||
labelKey,
|
||||
helpKey,
|
||||
onAdd,
|
||||
}: {
|
||||
type: BuilderFieldType;
|
||||
labelKey: string;
|
||||
helpKey: string;
|
||||
onAdd: (type: BuilderFieldType) => void;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const label = t(labelKey);
|
||||
const help = t(helpKey);
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `palette:${type}`,
|
||||
data: { source: "palette", type },
|
||||
});
|
||||
return (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
type="button"
|
||||
className={`text-left border rounded p-2 hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${isDragging ? "opacity-50" : ""}`}
|
||||
aria-label={t("settings.forms.builder.palette.addField", { label })}
|
||||
onClick={() => onAdd(type)}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<div className="font-medium text-sm">{label}</div>
|
||||
<div className="text-xs text-gray-500">{help}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldPalette({ onAdd }: FieldPaletteProps): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<aside className="w-full shrink-0 border rounded p-3 space-y-2 bg-white lg:w-56" aria-label={t("settings.forms.builder.palette.ariaLabel")}>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-gray-600">{t("settings.forms.builder.palette.title")}</h3>
|
||||
<div className="grid gap-2">
|
||||
{paletteItems.map((item) => (
|
||||
<DraggablePaletteTile
|
||||
key={item.type}
|
||||
type={item.type}
|
||||
labelKey={item.labelKey}
|
||||
helpKey={item.helpKey}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 leading-snug pt-2 border-t">
|
||||
{t("settings.forms.builder.palette.instructions")}
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Right-rail field-properties editor for the form-builder. Renders the
|
||||
* full editable surface for the currently-selected field — label, key,
|
||||
* required, placeholder, help-text, length validation and per-option
|
||||
* editing for select/radio types.
|
||||
*
|
||||
* The component is a controlled view: every input change calls back
|
||||
* through `onChange` with a fresh field object so the parent
|
||||
* `<FormBuilder/>` can persist + dispatch the canvas update.
|
||||
*/
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FormField } from "@toolctrl/form-widget";
|
||||
|
||||
export type FieldPropertiesProps = {
|
||||
field: FormField | null;
|
||||
onChange: (next: FormField) => void;
|
||||
};
|
||||
|
||||
function NumericInput({
|
||||
fieldId,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
fieldId: string;
|
||||
value: number | undefined;
|
||||
onChange: (next: number | undefined) => void;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<input
|
||||
id={fieldId}
|
||||
type="number"
|
||||
min={0}
|
||||
className="border rounded px-2 py-1 w-full"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim();
|
||||
if (raw === "") return onChange(undefined);
|
||||
const n = Number.parseInt(raw, 10);
|
||||
onChange(Number.isFinite(n) ? n : undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionsEditor({
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
options: { value: string; label: string }[];
|
||||
onChange: (next: { value: string; label: string }[]) => void;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 text-xs uppercase tracking-wide text-gray-500">
|
||||
<span>{t("settings.forms.builder.properties.options.value")}</span>
|
||||
<span>{t("settings.forms.builder.properties.options.label")}</span>
|
||||
<span aria-hidden="true">{" "}</span>
|
||||
</div>
|
||||
{options.map((opt, idx) => (
|
||||
<div key={idx} className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||
<input
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
value={opt.value}
|
||||
onChange={(e) => {
|
||||
const next = options.slice();
|
||||
next[idx] = { ...opt, value: e.target.value };
|
||||
onChange(next);
|
||||
}}
|
||||
aria-label={t("settings.forms.builder.properties.options.valueAria", { number: idx + 1 })}
|
||||
/>
|
||||
<input
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
value={opt.label}
|
||||
onChange={(e) => {
|
||||
const next = options.slice();
|
||||
next[idx] = { ...opt, label: e.target.value };
|
||||
onChange(next);
|
||||
}}
|
||||
aria-label={t("settings.forms.builder.properties.options.labelAria", { number: idx + 1 })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-red-600 px-2 text-xs"
|
||||
onClick={() => onChange(options.filter((_, i) => i !== idx))}
|
||||
aria-label={t("settings.forms.builder.properties.options.removeAria", { number: idx + 1 })}
|
||||
>
|
||||
{"✕"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
onClick={() =>
|
||||
onChange([
|
||||
...options,
|
||||
{
|
||||
value: `opt_${options.length + 1}`,
|
||||
label: t("settings.forms.builder.properties.options.defaultLabel", { number: options.length + 1 }),
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
{t("settings.forms.builder.properties.options.add")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldProperties({ field, onChange }: FieldPropertiesProps): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
if (!field) {
|
||||
return (
|
||||
<aside className="w-full shrink-0 border rounded p-3 bg-white lg:w-72" aria-label={t("settings.forms.builder.properties.ariaLabel")}>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-gray-600">{t("settings.forms.builder.properties.title")}</h3>
|
||||
<p className="text-sm text-gray-500 mt-2">{t("settings.forms.builder.properties.empty")}</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
const supportsOptions = field.type === "select" || field.type === "radio";
|
||||
const supportsPlaceholder = field.type === "text" || field.type === "email" || field.type === "number" || field.type === "textarea" || field.type === "select";
|
||||
const supportsLength = field.type === "text" || field.type === "email" || field.type === "textarea";
|
||||
return (
|
||||
<aside className="w-full shrink-0 border rounded p-3 bg-white space-y-3 lg:w-72" aria-label={t("settings.forms.builder.properties.ariaLabel")}>
|
||||
<header>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-gray-600">{t("settings.forms.builder.properties.title")}</h3>
|
||||
<p className="text-[11px] text-gray-500">{field.type}</p>
|
||||
</header>
|
||||
<label className="block text-sm">
|
||||
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.label")}</span>
|
||||
<input
|
||||
className="border rounded px-2 py-1 w-full"
|
||||
value={field.label}
|
||||
onChange={(e) => onChange({ ...field, label: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.fieldKey")}</span>
|
||||
<input
|
||||
className="border rounded px-2 py-1 w-full font-mono text-xs"
|
||||
value={field.key}
|
||||
onChange={(e) => onChange({ ...field, key: e.target.value })}
|
||||
/>
|
||||
<span className="text-[10px] text-gray-500">
|
||||
{t("settings.forms.builder.properties.fieldKeyHelp")}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(e) => onChange({ ...field, required: e.target.checked })}
|
||||
/>
|
||||
<span>{t("settings.forms.builder.properties.required")}</span>
|
||||
</label>
|
||||
{supportsPlaceholder ? (
|
||||
<label className="block text-sm">
|
||||
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.placeholder")}</span>
|
||||
<input
|
||||
className="border rounded px-2 py-1 w-full"
|
||||
value={field.placeholder ?? ""}
|
||||
onChange={(e) => onChange({ ...field, placeholder: e.target.value || undefined })}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="block text-sm">
|
||||
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.helpText")}</span>
|
||||
<input
|
||||
className="border rounded px-2 py-1 w-full"
|
||||
value={field.helpText ?? ""}
|
||||
onChange={(e) => onChange({ ...field, helpText: e.target.value || undefined })}
|
||||
/>
|
||||
</label>
|
||||
{supportsLength ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="block text-sm">
|
||||
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.minLength")}</span>
|
||||
<NumericInput
|
||||
fieldId={`${field.key}-min`}
|
||||
value={field.minLength}
|
||||
onChange={(next) => onChange({ ...field, minLength: next })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.maxLength")}</span>
|
||||
<NumericInput
|
||||
fieldId={`${field.key}-max`}
|
||||
value={field.maxLength}
|
||||
onChange={(next) => onChange({ ...field, maxLength: next })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
{supportsOptions ? (
|
||||
<div>
|
||||
<span className="block text-xs uppercase tracking-wide text-gray-500 mb-1">{t("settings.forms.builder.properties.options.title")}</span>
|
||||
<OptionsEditor
|
||||
options={field.options ?? []}
|
||||
onChange={(next) => onChange({ ...field, options: next })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FormDefinition, FormField, FormTheme } from "@toolctrl/form-widget";
|
||||
import { FormBuilder } from "./FormBuilder";
|
||||
import type { FormSettingsValue } from "./SettingsTab";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
({
|
||||
"settings.forms.builder.tabs.ariaLabel": "Form-builder tabs",
|
||||
"settings.forms.builder.tabs.builder": "Builder",
|
||||
"settings.forms.builder.tabs.settings": "Settings",
|
||||
"settings.forms.builder.tabs.preview": "Preview",
|
||||
})[key] ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./FieldPalette", () => ({
|
||||
FieldPalette: () => <div data-testid="field-palette" />,
|
||||
}));
|
||||
|
||||
vi.mock("./Canvas", () => ({
|
||||
Canvas: ({ fields }: { fields: FormField[] }) => <div data-testid="form-canvas">{fields.length}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./FieldProperties", () => ({
|
||||
FieldProperties: ({ field }: { field: FormField | null }) => <div data-testid="field-properties">{field?.key ?? "none"}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./SettingsTab", () => ({
|
||||
SettingsTab: () => <div data-testid="settings-tab" />,
|
||||
}));
|
||||
|
||||
vi.mock("./PreviewTab", () => ({
|
||||
PreviewTab: () => <div data-testid="preview-tab" />,
|
||||
}));
|
||||
|
||||
const definition: FormDefinition = {
|
||||
title: "Contact",
|
||||
submitLabel: "Submit",
|
||||
successMessage: "Thanks",
|
||||
fields: [{ key: "email", type: "email", label: "Email", required: true }],
|
||||
};
|
||||
|
||||
const theme: FormTheme = {};
|
||||
|
||||
const settings: FormSettingsValue = {
|
||||
allowedOrigins: [],
|
||||
notifyEmails: [],
|
||||
webhookUrl: "",
|
||||
targetType: "generic",
|
||||
fieldMapping: {},
|
||||
};
|
||||
|
||||
function renderBuilder() {
|
||||
return render(
|
||||
<FormBuilder
|
||||
initialDefinition={definition}
|
||||
initialTheme={theme}
|
||||
initialSettings={settings}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("FormBuilder", () => {
|
||||
it("keeps the tab strip scrollable with touch-sized non-wrapping tabs", () => {
|
||||
renderBuilder();
|
||||
|
||||
const tablist = screen.getByRole("tablist", { name: "Form-builder tabs" });
|
||||
expect(tablist).toHaveClass("min-w-0", "overflow-x-auto");
|
||||
|
||||
for (const tab of screen.getAllByRole("tab")) {
|
||||
expect(tab).toHaveClass("min-h-10", "shrink-0", "whitespace-nowrap");
|
||||
expect(tab).toHaveAttribute("aria-controls");
|
||||
}
|
||||
});
|
||||
|
||||
it("links each selected tab to its visible panel", () => {
|
||||
renderBuilder();
|
||||
|
||||
const builderTab = screen.getByRole("tab", { name: "Builder" });
|
||||
const builderPanel = screen.getByRole("tabpanel");
|
||||
|
||||
expect(builderTab).toHaveAttribute("aria-selected", "true");
|
||||
expect(builderPanel).toHaveAttribute("id", builderTab.getAttribute("aria-controls"));
|
||||
expect(builderPanel).toHaveAttribute("aria-labelledby", builderTab.id);
|
||||
|
||||
const settingsTab = screen.getByRole("tab", { name: "Settings" });
|
||||
fireEvent.click(settingsTab);
|
||||
|
||||
const settingsPanel = screen.getByRole("tabpanel");
|
||||
expect(settingsTab).toHaveAttribute("aria-selected", "true");
|
||||
expect(settingsPanel).toHaveAttribute("id", settingsTab.getAttribute("aria-controls"));
|
||||
expect(settingsPanel).toHaveAttribute("aria-labelledby", settingsTab.id);
|
||||
expect(screen.getByTestId("settings-tab")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Top-level FormBuilder shell — composes the palette / canvas /
|
||||
* properties / settings / preview into a tabbed surface and owns the
|
||||
* editable definition state.
|
||||
*
|
||||
* Mounts inside the `forms.$id.tsx` admin route which is responsible
|
||||
* for the loader (Form + tenant), the action (save → Prisma), and
|
||||
* surrounding chrome (page title, embed snippet, submissions inbox).
|
||||
*/
|
||||
import type * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FormDefinition, FormField, FormTheme } from "@toolctrl/form-widget";
|
||||
import { FieldPalette } from "./FieldPalette";
|
||||
import { Canvas } from "./Canvas";
|
||||
import { FieldProperties } from "./FieldProperties";
|
||||
import { SettingsTab, type FormSettingsValue } from "./SettingsTab";
|
||||
import { PreviewTab } from "./PreviewTab";
|
||||
import { paletteItems } from "./palette";
|
||||
import { uniqueKey, type BuilderFieldType } from "./types";
|
||||
|
||||
export type FormBuilderProps = {
|
||||
initialDefinition: FormDefinition;
|
||||
initialTheme: FormTheme;
|
||||
initialSettings: FormSettingsValue;
|
||||
onChange: (snapshot: {
|
||||
definition: FormDefinition;
|
||||
theme: FormTheme;
|
||||
settings: FormSettingsValue;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
type TabKey = "builder" | "settings" | "preview";
|
||||
|
||||
const TAB_DEFS: { key: TabKey; labelKey: string }[] = [
|
||||
{ key: "builder", labelKey: "settings.forms.builder.tabs.builder" },
|
||||
{ key: "settings", labelKey: "settings.forms.builder.tabs.settings" },
|
||||
{ key: "preview", labelKey: "settings.forms.builder.tabs.preview" },
|
||||
];
|
||||
|
||||
function getTabId(key: TabKey): string {
|
||||
return `form-builder-tab-${key}`;
|
||||
}
|
||||
|
||||
function getPanelId(key: TabKey): string {
|
||||
return `form-builder-panel-${key}`;
|
||||
}
|
||||
|
||||
function localizeDefaultField(field: FormField, t: ReturnType<typeof useTranslation>["t"]): FormField {
|
||||
const localized = {
|
||||
...field,
|
||||
label: t(`settings.forms.builder.defaults.${field.type}.label`, { defaultValue: field.label }),
|
||||
};
|
||||
if ("options" in localized && localized.options) {
|
||||
return {
|
||||
...localized,
|
||||
options: localized.options.map((option) => ({
|
||||
...option,
|
||||
label: t(`settings.forms.builder.defaults.options.${option.value}`, { defaultValue: option.label }),
|
||||
})),
|
||||
};
|
||||
}
|
||||
return localized;
|
||||
}
|
||||
|
||||
export function FormBuilder({
|
||||
initialDefinition,
|
||||
initialTheme,
|
||||
initialSettings,
|
||||
onChange,
|
||||
}: FormBuilderProps): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<TabKey>("builder");
|
||||
const [definition, setDefinition] = useState<FormDefinition>(initialDefinition);
|
||||
const [theme] = useState<FormTheme>(initialTheme);
|
||||
const [settings, setSettings] = useState<FormSettingsValue>(initialSettings);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(
|
||||
initialDefinition.fields[0]?.key ?? null,
|
||||
);
|
||||
|
||||
const fieldKeys = useMemo(() => new Set(definition.fields.map((f) => f.key)), [definition.fields]);
|
||||
|
||||
function emit(next: { definition?: FormDefinition; theme?: FormTheme; settings?: FormSettingsValue }): void {
|
||||
const snapshot = {
|
||||
definition: next.definition ?? definition,
|
||||
theme: next.theme ?? theme,
|
||||
settings: next.settings ?? settings,
|
||||
};
|
||||
onChange(snapshot);
|
||||
}
|
||||
|
||||
function setDef(next: FormDefinition): void {
|
||||
setDefinition(next);
|
||||
emit({ definition: next });
|
||||
}
|
||||
|
||||
function setSettingsLocal(next: FormSettingsValue): void {
|
||||
setSettings(next);
|
||||
emit({ settings: next });
|
||||
}
|
||||
|
||||
function addField(type: BuilderFieldType): void {
|
||||
const palette = paletteItems.find((p) => p.type === type);
|
||||
if (!palette) return;
|
||||
const base = localizeDefaultField(palette.defaults(), t);
|
||||
const key = uniqueKey(base.key, fieldKeys);
|
||||
const next: FormDefinition = {
|
||||
...definition,
|
||||
fields: [...definition.fields, { ...base, key }],
|
||||
};
|
||||
setDef(next);
|
||||
setSelectedKey(key);
|
||||
}
|
||||
|
||||
function updateField(key: string, replacement: FormField): void {
|
||||
const renamed = replacement.key !== key;
|
||||
if (renamed && fieldKeys.has(replacement.key)) {
|
||||
// Reject silent key-collision; keep the prior key.
|
||||
replacement = { ...replacement, key };
|
||||
}
|
||||
const next: FormDefinition = {
|
||||
...definition,
|
||||
fields: definition.fields.map((f) => (f.key === key ? replacement : f)),
|
||||
};
|
||||
setDef(next);
|
||||
if (renamed) setSelectedKey(replacement.key);
|
||||
}
|
||||
|
||||
function reorderFields(next: FormField[]): void {
|
||||
setDef({ ...definition, fields: next });
|
||||
}
|
||||
|
||||
function deleteField(key: string): void {
|
||||
const next: FormDefinition = {
|
||||
...definition,
|
||||
fields: definition.fields.filter((f) => f.key !== key),
|
||||
};
|
||||
setDef(next);
|
||||
if (selectedKey === key) {
|
||||
setSelectedKey(next.fields[0]?.key ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedField = definition.fields.find((f) => f.key === selectedKey) ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("settings.forms.builder.tabs.ariaLabel")}
|
||||
className="flex min-w-0 gap-2 overflow-x-auto border-b"
|
||||
>
|
||||
{TAB_DEFS.map((def) => (
|
||||
<button
|
||||
id={getTabId(def.key)}
|
||||
key={def.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === def.key}
|
||||
aria-controls={getPanelId(def.key)}
|
||||
className={`-mb-px min-h-10 shrink-0 whitespace-nowrap border-b-2 px-3 py-2 text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${
|
||||
tab === def.key ? "border-blue-600 text-blue-700" : "border-transparent text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
onClick={() => setTab(def.key)}
|
||||
>
|
||||
{t(def.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === "builder" ? (
|
||||
<div
|
||||
id={getPanelId("builder")}
|
||||
role="tabpanel"
|
||||
aria-labelledby={getTabId("builder")}
|
||||
className="flex flex-col items-stretch gap-3 lg:flex-row lg:items-start"
|
||||
>
|
||||
<FieldPalette onAdd={addField} />
|
||||
<Canvas
|
||||
fields={definition.fields}
|
||||
selectedKey={selectedKey}
|
||||
onSelect={setSelectedKey}
|
||||
onReorder={reorderFields}
|
||||
onUpdate={updateField}
|
||||
onDelete={deleteField}
|
||||
/>
|
||||
<FieldProperties
|
||||
field={selectedField}
|
||||
onChange={(next) => selectedField && updateField(selectedField.key, next)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{tab === "settings" ? (
|
||||
<div id={getPanelId("settings")} role="tabpanel" aria-labelledby={getTabId("settings")}>
|
||||
<SettingsTab value={settings} fields={definition.fields} onChange={setSettingsLocal} />
|
||||
</div>
|
||||
) : null}
|
||||
{tab === "preview" ? (
|
||||
<div id={getPanelId("preview")} role="tabpanel" aria-labelledby={getTabId("preview")}>
|
||||
<PreviewTab definition={definition} theme={theme} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Preview tab for the form-widget builder. Renders the in-flight
|
||||
* definition using the SAME field-rendering pipeline + inline CSS as
|
||||
* the embed route at `/embed/forms/$formKey`. That is intentional:
|
||||
* what the customer sees in their iframe must match what the operator
|
||||
* sees in the admin preview.
|
||||
*
|
||||
* The component is deliberately self-contained — it is rendered
|
||||
* outside the sandboxed iframe so no postMessage / parent-resize
|
||||
* concerns apply, and form-submission is disabled (preview is for
|
||||
* visual inspection only).
|
||||
*/
|
||||
import type * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { HONEYPOT_FIELD_KEY, type FormDefinition, type FormField, type FormTheme } from "@toolctrl/form-widget";
|
||||
|
||||
export type PreviewTabProps = {
|
||||
definition: FormDefinition;
|
||||
theme: FormTheme;
|
||||
};
|
||||
|
||||
function themeCssVars(theme: FormTheme): React.CSSProperties {
|
||||
const vars: Record<string, string> = {};
|
||||
if (theme.primaryColor) vars["--tcform-primary"] = theme.primaryColor;
|
||||
if (theme.backgroundColor) vars["--tcform-bg"] = theme.backgroundColor;
|
||||
if (theme.textColor) vars["--tcform-text"] = theme.textColor;
|
||||
if (theme.fontFamily) vars["--tcform-font"] = theme.fontFamily;
|
||||
if (theme.borderRadius) vars["--tcform-radius"] = theme.borderRadius;
|
||||
return vars as React.CSSProperties;
|
||||
}
|
||||
|
||||
function PreviewField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: FormField;
|
||||
value: string | boolean;
|
||||
onChange: (next: string | boolean) => void;
|
||||
}): React.JSX.Element | null {
|
||||
const inputId = `tcform-preview-${field.key}`;
|
||||
if (field.type === "honeypot") {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
name={field.key}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
autoComplete="off"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ position: "absolute", left: "-9999px", width: 1, height: 1, opacity: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const labelEl = (
|
||||
<label htmlFor={inputId} className="tcform-label">
|
||||
{field.label}
|
||||
{field.required ? <span aria-hidden="true">{" *"}</span> : null}
|
||||
</label>
|
||||
);
|
||||
const helpEl = field.helpText ? (
|
||||
<div className="tcform-help" id={`${inputId}-help`}>
|
||||
{field.helpText}
|
||||
</div>
|
||||
) : null;
|
||||
if (field.type === "textarea") {
|
||||
return (
|
||||
<div className="tcform-row">
|
||||
{labelEl}
|
||||
<textarea
|
||||
id={inputId}
|
||||
name={field.key}
|
||||
required={field.required}
|
||||
placeholder={field.placeholder}
|
||||
maxLength={field.maxLength}
|
||||
minLength={field.minLength}
|
||||
aria-describedby={helpEl ? `${inputId}-help` : undefined}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
{helpEl}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (field.type === "select") {
|
||||
const options = field.options ?? [];
|
||||
return (
|
||||
<div className="tcform-row">
|
||||
{labelEl}
|
||||
<select
|
||||
id={inputId}
|
||||
name={field.key}
|
||||
required={field.required}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">{field.placeholder ?? ""}</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{helpEl}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (field.type === "radio") {
|
||||
const options = field.options ?? [];
|
||||
return (
|
||||
<fieldset className="tcform-row tcform-radio-group" aria-describedby={helpEl ? `${inputId}-help` : undefined}>
|
||||
<legend>{field.label}</legend>
|
||||
{options.map((opt) => (
|
||||
<label key={opt.value} className="tcform-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name={field.key}
|
||||
value={opt.value}
|
||||
checked={value === opt.value}
|
||||
onChange={() => onChange(opt.value)}
|
||||
required={field.required}
|
||||
/>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
{helpEl}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
if (field.type === "checkbox") {
|
||||
return (
|
||||
<div className="tcform-row tcform-checkbox">
|
||||
<label className="tcform-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={field.key}
|
||||
checked={value === true}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
required={field.required}
|
||||
/>
|
||||
<span>{field.label}</span>
|
||||
</label>
|
||||
{helpEl}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const inputType = field.type === "email" ? "email" : field.type === "number" ? "number" : "text";
|
||||
return (
|
||||
<div className="tcform-row">
|
||||
{labelEl}
|
||||
<input
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
name={field.key}
|
||||
required={field.required}
|
||||
placeholder={field.placeholder}
|
||||
maxLength={field.maxLength}
|
||||
minLength={field.minLength}
|
||||
aria-describedby={helpEl ? `${inputId}-help` : undefined}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
{helpEl}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewTab({ definition, theme }: PreviewTabProps): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const styleVars = useMemo(() => themeCssVars(theme), [theme]);
|
||||
const [values, setValues] = useState<Record<string, string | boolean>>(() => {
|
||||
const init: Record<string, string | boolean> = {};
|
||||
for (const f of definition.fields) {
|
||||
init[f.key] = f.type === "checkbox" ? false : "";
|
||||
}
|
||||
return init;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="border rounded bg-white p-3 max-w-xl">
|
||||
<p className="text-[11px] text-gray-500 mb-2">
|
||||
{t(
|
||||
"settings.forms.builder.preview.description",
|
||||
"Preview renders with the same CSS variables and field components as the embedded form route. Submission is disabled.",
|
||||
)}
|
||||
</p>
|
||||
<div className="tcform-root" style={styleVars}>
|
||||
<style>{`
|
||||
.tcform-root { font-family: var(--tcform-font, system-ui, sans-serif); background: var(--tcform-bg, #fff); color: var(--tcform-text, #111); padding: 16px; box-sizing: border-box; border: 1px dashed #e5e7eb; border-radius: 6px; }
|
||||
.tcform-root h1 { font-size: 1.25rem; margin: 0 0 0.25em; }
|
||||
.tcform-root p.tcform-description { margin: 0 0 1em; opacity: 0.8; }
|
||||
.tcform-row { display: block; margin-bottom: 12px; }
|
||||
.tcform-root .tcform-label { display: block; font-weight: 500; margin-bottom: 4px; }
|
||||
.tcform-root input[type=text], .tcform-root input[type=email], .tcform-root input[type=number], .tcform-root textarea, .tcform-root select { width: 100%; padding: 8px 10px; border: 1px solid #ccc; border-radius: var(--tcform-radius, 6px); font: inherit; color: inherit; background: #fff; box-sizing: border-box; }
|
||||
.tcform-root textarea { min-height: 100px; resize: vertical; }
|
||||
.tcform-root .tcform-help { font-size: 0.85em; opacity: 0.7; margin-top: 4px; }
|
||||
.tcform-root button { background: var(--tcform-primary, #2563eb); color: #fff; border: 0; padding: 10px 16px; border-radius: var(--tcform-radius, 6px); cursor: pointer; font: inherit; }
|
||||
`}</style>
|
||||
<h1>{definition.title}</h1>
|
||||
{definition.description ? <p className="tcform-description">{definition.description}</p> : null}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{definition.fields.map((field) => (
|
||||
<PreviewField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={values[field.key] ?? (field.type === "checkbox" ? false : "")}
|
||||
onChange={(next) => setValues((prev) => ({ ...prev, [field.key]: next }))}
|
||||
/>
|
||||
))}
|
||||
<input type="hidden" name="__hp_key" value={HONEYPOT_FIELD_KEY} />
|
||||
<button type="button" disabled>
|
||||
{definition.submitLabel}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Settings tab for the form-builder. Surfaces non-definition settings
|
||||
* the parent `Form` row persists alongside the JSON definition:
|
||||
*
|
||||
* - allowedOrigins → array of hostnames the submit API accepts
|
||||
* - notifyEmails → mail recipients for new submissions
|
||||
* - webhookUrl → optional outbound webhook
|
||||
* - targetType → routing target (generic FormSubmission, CRM
|
||||
* contact, or entity-row)
|
||||
* - fieldMapping → field → target-column mapping for non-generic
|
||||
* target types
|
||||
*
|
||||
* Save semantics live with the parent route action — this is a
|
||||
* controlled view that emits onChange callbacks.
|
||||
*/
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FormField } from "@toolctrl/form-widget";
|
||||
|
||||
export type FormSettingsValue = {
|
||||
allowedOrigins: string[];
|
||||
notifyEmails: string[];
|
||||
webhookUrl: string;
|
||||
targetType: string;
|
||||
fieldMapping: Record<string, string>;
|
||||
};
|
||||
|
||||
export type SettingsTabProps = {
|
||||
value: FormSettingsValue;
|
||||
fields: FormField[];
|
||||
onChange: (next: FormSettingsValue) => void;
|
||||
};
|
||||
|
||||
const TARGET_OPTIONS = [
|
||||
{ value: "generic", labelKey: "settings.forms.builder.settings.targets.generic" },
|
||||
{ value: "crm-contact", labelKey: "settings.forms.builder.settings.targets.crmContact" },
|
||||
{ value: "entity-row", labelKey: "settings.forms.builder.settings.targets.entityRow" },
|
||||
];
|
||||
|
||||
function joinLines(values: string[]): string {
|
||||
return values.join("\n");
|
||||
}
|
||||
|
||||
function parseLines(raw: string): string[] {
|
||||
return raw
|
||||
.split(/\n+/u)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
function parseCsvOrLines(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,]+/u)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
export function SettingsTab({ value, fields, onChange }: SettingsTabProps): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const showMapping = value.targetType === "crm-contact" || value.targetType === "entity-row";
|
||||
return (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<label className="block">
|
||||
<span className="block text-xs uppercase tracking-wide text-gray-500">
|
||||
{t("settings.forms.builder.settings.allowedOrigins")}
|
||||
</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="border rounded px-2 py-1 w-full font-mono text-xs"
|
||||
value={joinLines(value.allowedOrigins)}
|
||||
onChange={(e) => onChange({ ...value, allowedOrigins: parseLines(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="block text-xs uppercase tracking-wide text-gray-500">
|
||||
{t("settings.forms.builder.settings.notifyEmails")}
|
||||
</span>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="border rounded px-2 py-1 w-full font-mono text-xs"
|
||||
value={joinLines(value.notifyEmails)}
|
||||
onChange={(e) => onChange({ ...value, notifyEmails: parseCsvOrLines(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="block text-xs uppercase tracking-wide text-gray-500">
|
||||
{t("settings.forms.builder.settings.webhookUrl")}
|
||||
</span>
|
||||
<input
|
||||
className="border rounded px-2 py-1 w-full font-mono text-xs"
|
||||
value={value.webhookUrl}
|
||||
onChange={(e) => onChange({ ...value, webhookUrl: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.settings.targetType")}</span>
|
||||
<select
|
||||
className="border rounded px-2 py-1 w-full"
|
||||
value={value.targetType}
|
||||
onChange={(e) => onChange({ ...value, targetType: e.target.value })}
|
||||
>
|
||||
{TARGET_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{t(opt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{showMapping ? (
|
||||
<div>
|
||||
<span className="block text-xs uppercase tracking-wide text-gray-500 mb-1">{t("settings.forms.builder.settings.fieldMapping")}</span>
|
||||
<p className="text-[11px] text-gray-500 mb-2">
|
||||
{t("settings.forms.builder.settings.fieldMappingDescription")}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{fields.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 italic">{t("settings.forms.builder.settings.noFields")}</p>
|
||||
) : (
|
||||
fields
|
||||
.filter((f) => f.type !== "honeypot")
|
||||
.map((f) => (
|
||||
<div key={f.key} className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||
<code className="text-xs text-gray-600">{f.key}</code>
|
||||
<span aria-hidden="true">{"→"}</span>
|
||||
<input
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
placeholder={f.key}
|
||||
value={value.fieldMapping[f.key] ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...value,
|
||||
fieldMapping: { ...value.fieldMapping, [f.key]: e.target.value },
|
||||
})
|
||||
}
|
||||
aria-label={t("settings.forms.builder.settings.targetColumnAria", { fieldKey: f.key })}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Sortable canvas row for the form-builder. Renders a single
|
||||
* `FormField` with a drag-handle (keyboard + pointer), an inline
|
||||
* label editor, a "select" affordance to open the right-rail
|
||||
* properties panel, and a delete button.
|
||||
*
|
||||
* Wraps `useSortable` from `@dnd-kit/sortable`. The drag-handle is the
|
||||
* explicit listener target so that focused inputs (e.g. the inline
|
||||
* label edit) don't capture pointer-down + start a drag.
|
||||
*/
|
||||
import type * as React from "react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FormField } from "@toolctrl/form-widget";
|
||||
|
||||
export type SortableFieldProps = {
|
||||
field: FormField;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onUpdate: (next: FormField) => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export function SortableField({ field, isSelected, onSelect, onUpdate, onDelete }: SortableFieldProps): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: field.key });
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={`flex items-start gap-2 rounded border bg-white p-2 ${isSelected ? "ring-2 ring-blue-500" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex min-h-10 min-w-10 cursor-grab items-center justify-center px-2 py-2 text-gray-400 hover:text-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label={t("settings.forms.builder.field.reorderAria", { label: field.label })}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<span aria-hidden="true" className="text-lg leading-none select-none">
|
||||
{"☰"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className="min-w-0 flex-1 rounded px-1 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label={t("settings.forms.builder.field.selectAria", { label: field.label })}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[10px] tracking-wide text-gray-500 uppercase">
|
||||
<span>{field.type}</span>
|
||||
{field.required ? <span>{t("settings.forms.builder.field.required")}</span> : null}
|
||||
<code className="text-gray-400">{field.key}</code>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
className="flex-1 border-b border-transparent px-1 py-0.5 text-sm focus:border-blue-500 focus:outline-none"
|
||||
value={field.label}
|
||||
onChange={(e) => onUpdate({ ...field, label: e.target.value })}
|
||||
aria-label={t("settings.forms.builder.field.labelAria", { fieldKey: field.key })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="inline-flex min-h-10 min-w-10 items-center justify-center rounded px-2 py-2 text-xs text-red-600 hover:bg-red-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
|
||||
aria-label={t("settings.forms.builder.field.deleteAria", { label: field.label })}
|
||||
>
|
||||
{"✕"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Static palette definition for the form-builder. Each entry exposes a
|
||||
* factory that returns a fresh `FormField` shape (matching the
|
||||
* `@toolctrl/form-widget` zod schema) so the canvas can append without
|
||||
* having to deep-clone a frozen prototype.
|
||||
*
|
||||
* The palette deliberately exposes a superset of `FormFieldType` —
|
||||
* `heading` + `paragraph` are presentational items the renderer treats
|
||||
* as `text` fields with `helpText` carrying the body. They keep parity
|
||||
* with the original spec ("Heading, Paragraph" callouts in the
|
||||
* drag-drop UX) without expanding the storage schema.
|
||||
*/
|
||||
import { HONEYPOT_FIELD_KEY } from "@toolctrl/form-widget";
|
||||
import type { PaletteItem } from "./types";
|
||||
|
||||
export const paletteDefaultLanguage = "en";
|
||||
|
||||
function defaultLabelKey(type: string): string {
|
||||
return `settings.forms.builder.defaults.${type}.label`;
|
||||
}
|
||||
|
||||
function defaultOptionLabelKey(value: string): string {
|
||||
return `settings.forms.builder.defaults.options.${value}`;
|
||||
}
|
||||
|
||||
export const paletteItems: PaletteItem[] = [
|
||||
{
|
||||
type: "text",
|
||||
labelKey: "settings.forms.builder.palette.text.label",
|
||||
helpKey: "settings.forms.builder.palette.text.help",
|
||||
defaults: () => ({ key: "text_field", type: "text", label: defaultLabelKey("text"), required: false }),
|
||||
},
|
||||
{
|
||||
type: "email",
|
||||
labelKey: "settings.forms.builder.palette.email.label",
|
||||
helpKey: "settings.forms.builder.palette.email.help",
|
||||
defaults: () => ({ key: "email", type: "email", label: defaultLabelKey("email"), required: true }),
|
||||
},
|
||||
{
|
||||
type: "textarea",
|
||||
labelKey: "settings.forms.builder.palette.textarea.label",
|
||||
helpKey: "settings.forms.builder.palette.textarea.help",
|
||||
defaults: () => ({ key: "message", type: "textarea", label: defaultLabelKey("textarea"), required: false }),
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
labelKey: "settings.forms.builder.palette.select.label",
|
||||
helpKey: "settings.forms.builder.palette.select.help",
|
||||
defaults: () => ({
|
||||
key: "choice",
|
||||
type: "select",
|
||||
label: defaultLabelKey("select"),
|
||||
required: false,
|
||||
options: [
|
||||
{ value: "option_a", label: defaultOptionLabelKey("option_a") },
|
||||
{ value: "option_b", label: defaultOptionLabelKey("option_b") },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "checkbox",
|
||||
labelKey: "settings.forms.builder.palette.checkbox.label",
|
||||
helpKey: "settings.forms.builder.palette.checkbox.help",
|
||||
defaults: () => ({ key: "consent", type: "checkbox", label: defaultLabelKey("checkbox"), required: false }),
|
||||
},
|
||||
{
|
||||
type: "radio",
|
||||
labelKey: "settings.forms.builder.palette.radio.label",
|
||||
helpKey: "settings.forms.builder.palette.radio.help",
|
||||
defaults: () => ({
|
||||
key: "pick",
|
||||
type: "radio",
|
||||
label: defaultLabelKey("radio"),
|
||||
required: false,
|
||||
options: [
|
||||
{ value: "option_a", label: defaultOptionLabelKey("option_a") },
|
||||
{ value: "option_b", label: defaultOptionLabelKey("option_b") },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
labelKey: "settings.forms.builder.palette.number.label",
|
||||
helpKey: "settings.forms.builder.palette.number.help",
|
||||
defaults: () => ({ key: "amount", type: "number", label: defaultLabelKey("number"), required: false }),
|
||||
},
|
||||
{
|
||||
type: "honeypot",
|
||||
labelKey: "settings.forms.builder.palette.honeypot.label",
|
||||
helpKey: "settings.forms.builder.palette.honeypot.help",
|
||||
defaults: () => ({ key: HONEYPOT_FIELD_KEY, type: "honeypot", label: defaultLabelKey("honeypot"), required: false }),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Unit tests for the form-builder helper utilities. The drag-drop +
|
||||
* keyboard-reorder integration tests live in a separate Playwright
|
||||
* suite (deferred follow-up under form-widget.md); this file covers
|
||||
* the pure helpers + the palette factory contracts.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { uniqueKey } from "./types";
|
||||
import { paletteItems } from "./palette";
|
||||
import { FormFieldSchema } from "@toolctrl/form-widget";
|
||||
import enTranslations from "../../../../public/locales/en/translations.json";
|
||||
import deTranslations from "../../../../public/locales/de/translations.json";
|
||||
|
||||
function lookupTranslation(source: unknown, key: string): unknown {
|
||||
return key.split(".").reduce<unknown>((current, segment) => {
|
||||
if (current && typeof current === "object" && segment in current) {
|
||||
return (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return undefined;
|
||||
}, source);
|
||||
}
|
||||
|
||||
describe("uniqueKey", () => {
|
||||
it("returns the base when it is unused", () => {
|
||||
expect(uniqueKey("email", new Set())).toBe("email");
|
||||
});
|
||||
|
||||
it("appends a numeric suffix on collision", () => {
|
||||
expect(uniqueKey("email", new Set(["email"]))).toBe("email_2");
|
||||
expect(uniqueKey("email", new Set(["email", "email_2"]))).toBe("email_3");
|
||||
});
|
||||
|
||||
it("normalizes invalid identifiers", () => {
|
||||
expect(uniqueKey("123-bad key!", new Set())).toBe("field_123_bad_key_");
|
||||
});
|
||||
|
||||
it("keeps a valid leading letter", () => {
|
||||
expect(uniqueKey("user_name", new Set())).toBe("user_name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("palette defaults", () => {
|
||||
it("produces FormField shapes that satisfy the canonical zod schema", () => {
|
||||
for (const item of paletteItems) {
|
||||
const parsed = FormFieldSchema.safeParse(item.defaults());
|
||||
expect(parsed.success, `${item.type} default failed: ${parsed.success ? "" : parsed.error.message}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("issues fresh objects on every call (no shared prototype)", () => {
|
||||
for (const item of paletteItems) {
|
||||
const a = item.defaults();
|
||||
const b = item.defaults();
|
||||
expect(a).not.toBe(b);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses locale-backed keys for inserted default labels and option labels", () => {
|
||||
for (const item of paletteItems) {
|
||||
const field = item.defaults();
|
||||
const labelKey = `settings.forms.builder.defaults.${field.type}.label`;
|
||||
|
||||
expect(field.label).toBe(labelKey);
|
||||
expect(lookupTranslation(enTranslations, field.label)).toEqual(expect.any(String));
|
||||
expect(lookupTranslation(deTranslations, field.label)).toEqual(expect.any(String));
|
||||
|
||||
if ("options" in field && field.options) {
|
||||
for (const option of field.options) {
|
||||
const optionKey = `settings.forms.builder.defaults.options.${option.value}`;
|
||||
|
||||
expect(option.label).toBe(optionKey);
|
||||
expect(lookupTranslation(enTranslations, option.label)).toEqual(expect.any(String));
|
||||
expect(lookupTranslation(deTranslations, option.label)).toEqual(expect.any(String));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Shared types for the form-widget drag-drop builder (Phase 05 #58
|
||||
* follow-up). The runtime contract still flows through the canonical
|
||||
* `@toolctrl/form-widget` zod schemas — these helpers exist to make the
|
||||
* UI components terse and to keep palette definitions in one place.
|
||||
*/
|
||||
import type { FormField, FormFieldType, FormDefinition, FormTheme } from "@toolctrl/form-widget";
|
||||
|
||||
export type BuilderFieldType = FormFieldType | "heading" | "paragraph";
|
||||
|
||||
export type PaletteItem = {
|
||||
/** Field-type id; matches FormFieldType for actual form fields. */
|
||||
type: BuilderFieldType;
|
||||
/** Translation key for the operator-visible name. */
|
||||
labelKey: string;
|
||||
/** Translation key for the short one-liner shown in the palette tile. */
|
||||
helpKey: string;
|
||||
/** Default `FormField` shape used when this palette item is dropped. */
|
||||
defaults: () => FormField;
|
||||
};
|
||||
|
||||
export type BuilderFormShape = {
|
||||
definition: FormDefinition;
|
||||
theme: FormTheme;
|
||||
/** UI-only settings — persisted on the parent `Form` row, not in the JSON. */
|
||||
settings: {
|
||||
allowedOrigins: string[];
|
||||
notifyEmails: string[];
|
||||
webhookUrl: string;
|
||||
targetType: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** Produce a stable but unique key from a base + existing keys. */
|
||||
export function uniqueKey(base: string, existing: Set<string>): string {
|
||||
const safeBase = base.replace(/[^a-zA-Z0-9_]/g, "_").replace(/^_+/, "");
|
||||
const fallback = safeBase.length > 0 && /^[a-zA-Z]/.test(safeBase) ? safeBase : `field_${safeBase}`;
|
||||
if (!existing.has(fallback)) return fallback;
|
||||
for (let i = 2; i < 1000; i += 1) {
|
||||
const candidate = `${fallback}_${i}`;
|
||||
if (!existing.has(candidate)) return candidate;
|
||||
}
|
||||
return `${fallback}_${Date.now()}`;
|
||||
}
|
||||
Reference in new issue
Block a user